Rudder in the Studio corner is a chat-style bubble with a compact panel
Collapsed, the corner is a round bubble that turns red, pulses and shows a dot while Rudder listens. Open, it is a 360px panel (a 60vh bottom sheet on a phone) with a one-line header, the draft as the main element, and record and copy under it; language, microphone, clear and debug sit behind a settings button. Collapsing only hides the panel, so a recording keeps going and the draft stays.
This commit is contained in:
parent
4415519a83
commit
3ae2390536
2 changed files with 108 additions and 28 deletions
|
|
@ -459,6 +459,34 @@ function whenTagDefined(tag, ms) {
|
|||
])
|
||||
}
|
||||
|
||||
// Corner widgets: a round chat-style bubble; open, a small panel above it (a bottom sheet on a phone).
|
||||
const WIDGET_CSS = `
|
||||
[data-studio-widgets]{position:fixed;z-index:2147483647;display:flex;flex-direction:column;gap:10px;pointer-events:none}
|
||||
[data-studio-widgets] > *{pointer-events:auto}
|
||||
[data-studio-widgets="bottom-right"]{bottom:max(16px,env(safe-area-inset-bottom));right:max(16px,env(safe-area-inset-right));align-items:flex-end}
|
||||
[data-studio-widgets="bottom-left"]{bottom:max(16px,env(safe-area-inset-bottom));left:max(16px,env(safe-area-inset-left));align-items:flex-start}
|
||||
[data-studio-widgets="top-right"]{top:max(16px,env(safe-area-inset-top));right:max(16px,env(safe-area-inset-right));align-items:flex-end}
|
||||
[data-studio-widgets="top-left"]{top:max(16px,env(safe-area-inset-top));left:max(16px,env(safe-area-inset-left));align-items:flex-start}
|
||||
[data-widget]{display:flex;flex-direction:column;align-items:inherit;gap:10px}
|
||||
[data-studio-widgets^="top"] [data-widget]{flex-direction:column-reverse}
|
||||
[data-widget-panel]{display:none;position:relative;width:360px;max-width:calc(100vw - 32px);height:min(480px,calc(100vh - 110px));height:min(480px,calc(100dvh - 110px - env(safe-area-inset-top) - env(safe-area-inset-bottom)));border-radius:16px;overflow:hidden;background:#0b0f14;box-shadow:0 12px 40px rgba(0,0,0,.35)}
|
||||
[data-widget-panel][data-open]{display:block}
|
||||
[data-widget-bubble]{position:relative;width:56px;height:56px;padding:0;border:0;border-radius:50%;background:#0284c7;color:#fff;cursor:pointer;display:grid;place-items:center;box-shadow:0 4px 16px rgba(0,0,0,.3)}
|
||||
[data-widget-bubble]:focus-visible{outline:3px solid #7dd3fc;outline-offset:3px}
|
||||
[data-widget-bubble] svg{width:26px;height:26px;display:block}
|
||||
[data-widget-bubble][data-listening="true"]{background:#dc2626;animation:studio-widget-pulse 1.4s ease-out infinite}
|
||||
[data-widget-bubble][data-listening="true"]::after{content:"";position:absolute;top:3px;right:3px;width:12px;height:12px;border-radius:50%;background:#fff;border:2px solid #dc2626}
|
||||
@keyframes studio-widget-pulse{0%{box-shadow:0 0 0 0 rgba(220,38,38,.6)}70%{box-shadow:0 0 0 14px rgba(220,38,38,0)}100%{box-shadow:0 0 0 0 rgba(220,38,38,0)}}
|
||||
@media (prefers-reduced-motion:reduce){[data-widget-bubble][data-listening="true"]{animation:none}}
|
||||
@media (max-width:480px){
|
||||
[data-studio-widgets^="bottom"]{bottom:max(8px,env(safe-area-inset-bottom))}
|
||||
[data-studio-widgets$="right"]{right:max(8px,env(safe-area-inset-right))}
|
||||
[data-studio-widgets$="left"]{left:max(8px,env(safe-area-inset-left))}
|
||||
[data-widget-panel]{width:calc(100vw - 16px);max-width:none;height:60vh;height:60dvh}
|
||||
}`
|
||||
const WIDGET_ICON_OPEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v1a7 7 0 0 0 14 0v-1"/><path d="M12 18v4"/></svg>'
|
||||
const WIDGET_ICON_CLOSE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PluginStudio',
|
||||
// A host's attributes (style="height:…", class, id) belong on the <plugin-studio> HOST, not on the
|
||||
|
|
@ -1069,11 +1097,15 @@ export default defineComponent({
|
|||
},
|
||||
widgetHost(corner) {
|
||||
const W = this.widgetStore()
|
||||
if (!document.getElementById('studio-widget-css')) {
|
||||
const st = document.createElement('style')
|
||||
st.id = 'studio-widget-css'
|
||||
st.textContent = WIDGET_CSS
|
||||
document.head.appendChild(st)
|
||||
}
|
||||
if (W.hosts[corner] && W.hosts[corner].isConnected) return W.hosts[corner]
|
||||
const h = document.createElement('div')
|
||||
h.setAttribute('data-studio-widgets', corner)
|
||||
const [v, x] = corner.split('-')
|
||||
h.style.cssText = `position:fixed;${v}:max(12px,env(safe-area-inset-${v}));${x}:max(12px,env(safe-area-inset-${x}));z-index:2147483647;display:flex;flex-direction:column;align-items:${x === 'right' ? 'flex-end' : 'flex-start'};gap:8px;`
|
||||
document.body.appendChild(h)
|
||||
W.hosts[corner] = h
|
||||
return h
|
||||
|
|
@ -1086,20 +1118,21 @@ export default defineComponent({
|
|||
if (!box) {
|
||||
box = document.createElement('div')
|
||||
box.setAttribute('data-widget', row.key)
|
||||
box.style.cssText = 'display:flex;flex-direction:column;align-items:inherit;gap:6px;'
|
||||
const panel = document.createElement('div')
|
||||
panel.setAttribute('data-widget-panel', '')
|
||||
panel.style.cssText = 'display:none;width:min(380px,calc(100vw - 24px));height:min(520px,calc(100vh - 80px));height:min(520px,calc(100dvh - 80px - env(safe-area-inset-top) - env(safe-area-inset-bottom)));position:relative;background:#fff;border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,.25);overflow:hidden;'
|
||||
const btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.setAttribute('data-widget-bubble', '')
|
||||
// A catalogue label per widget ("widget:<row key>"), not the row key (rega / repo: the button read
|
||||
// the raw "rudder-voice"). Falls back to the row label for a widget with no key yet.
|
||||
// the raw "rudder-voice"). Falls back to the row label for a widget with no key yet. The bubble is
|
||||
// an icon, so the label is its accessible name and tooltip.
|
||||
const wk = 'widget:' + row.key
|
||||
const wl = this.tr(wk)
|
||||
btn.textContent = wl !== wk ? wl : row.label
|
||||
btn.setAttribute('aria-label', wl !== wk ? wl : row.label)
|
||||
btn.title = btn.getAttribute('aria-label')
|
||||
btn.setAttribute('aria-expanded', 'false')
|
||||
btn.style.cssText = 'font:600 13px system-ui,sans-serif;padding:8px 14px;border-radius:999px;border:1px solid #30363d;background:#0d1117;color:#e6edf3;cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.25);'
|
||||
btn.addEventListener('click', () => this.openWidget(row, panel.style.display === 'none'))
|
||||
btn.innerHTML = WIDGET_ICON_OPEN
|
||||
btn.addEventListener('click', () => this.openWidget(row, !panel.hasAttribute('data-open')))
|
||||
box.appendChild(panel); box.appendChild(btn); host.appendChild(box)
|
||||
}
|
||||
if (W.els[row.key] && !box.querySelector(row.tag)) box.querySelector('[data-widget-panel]').appendChild(W.els[row.key])
|
||||
|
|
@ -1113,9 +1146,12 @@ export default defineComponent({
|
|||
const box = host.querySelector(`[data-widget="${row.key}"]`)
|
||||
if (!box) { this.setupWidgets(); return }
|
||||
const panel = box.querySelector('[data-widget-panel]')
|
||||
const btn = box.querySelector('button')
|
||||
panel.style.display = open ? 'block' : 'none'
|
||||
const btn = box.querySelector('[data-widget-bubble]')
|
||||
// Collapsing only hides the panel: the element stays mounted, so a recording keeps going and the
|
||||
// transcript stays where it was.
|
||||
if (open) panel.setAttribute('data-open', ''); else panel.removeAttribute('data-open')
|
||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false')
|
||||
btn.innerHTML = open ? WIDGET_ICON_CLOSE : WIDGET_ICON_OPEN
|
||||
try { localStorage.setItem('plugin-studio:widget-open:' + row.key, open ? '1' : '0') } catch (_) {}
|
||||
const W = this.widgetStore()
|
||||
if (!open || W.els[row.key]) return
|
||||
|
|
@ -1123,21 +1159,16 @@ export default defineComponent({
|
|||
if (W.els[row.key]) return
|
||||
const el = document.createElement(row.tag)
|
||||
el.setAttribute('profile-config', JSON.stringify(this.paneConfig(row, this.locale)))
|
||||
// A widget that knows the corner variant (Rudder does) drops its full-page header and chrome.
|
||||
el.setAttribute('variant', 'widget')
|
||||
if (this.base) el.setAttribute('tree-base', this.base.replace(/\/+$/, ''))
|
||||
// The panel is a fixed size; on a phone the widget's content is taller than it, so the element scrolls
|
||||
// inside the panel rather than being clipped by it.
|
||||
el.style.cssText = 'display:block;position:absolute;inset:0;width:100%;height:100%;overflow-y:auto;overscroll-behavior:contain;'
|
||||
// LIVE MIC, VISIBLE WHEN COLLAPSED: a widget that emits `recording` (Rudder does) turns its corner
|
||||
// button red with a dot while it listens, so closing the panel never hides that the mic is on.
|
||||
// LIVE MIC, VISIBLE WHEN COLLAPSED: a widget that emits `recording` (Rudder does) turns its bubble
|
||||
// red, pulsing, with a dot while it listens, so closing the panel never hides that the mic is on.
|
||||
el.addEventListener('recording', (e) => {
|
||||
const on = !!(Array.isArray(e.detail) ? e.detail[0] : e.detail)
|
||||
const b = box.querySelector(':scope > button')
|
||||
if (!b) return
|
||||
if (!b.dataset.label) b.dataset.label = b.textContent
|
||||
b.textContent = on ? '● ' + b.dataset.label : b.dataset.label
|
||||
b.style.background = on ? '#7f1d1d' : '#0d1117'
|
||||
b.style.borderColor = on ? '#ef4444' : '#30363d'
|
||||
b.setAttribute('data-listening', on ? 'true' : 'false')
|
||||
const b = box.querySelector('[data-widget-bubble]')
|
||||
if (b) b.setAttribute('data-listening', on ? 'true' : 'false')
|
||||
})
|
||||
W.els[row.key] = el
|
||||
panel.appendChild(el)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,10 @@ import { computed, onBeforeUnmount, onMounted, ref, nextTick, watch } from 'vue'
|
|||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = defineProps({ profileConfig: { type: String, default: '' } })
|
||||
// variant="widget": the Plugin Studio's corner panel — a compact header, the draft as the main element, and
|
||||
// record + copy under it; language and microphone move behind the settings button.
|
||||
const props = defineProps({ profileConfig: { type: String, default: '' }, variant: { type: String, default: '' } })
|
||||
const compact = computed(() => props.variant === 'widget')
|
||||
// `recording` (true/false) tells a host that Rudder started or stopped listening — the Plugin Studio's
|
||||
// corner button turns red with it, so a collapsed corner still shows the mic is live.
|
||||
const emit = defineEmits(['recording'])
|
||||
|
|
@ -61,6 +64,7 @@ const noticeKind = ref('info')
|
|||
const copied = ref('')
|
||||
const showDebug = ref(false)
|
||||
const showInfo = ref(false)
|
||||
const showSettings = ref(false)
|
||||
const clientLog = ref([])
|
||||
|
||||
// ─── Microphones (the Jitsi device-selection pattern, served natively by the engine) ───
|
||||
|
|
@ -465,8 +469,21 @@ const statusText = computed(() => ({
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rudder" :class="{ rec: recording }">
|
||||
<header class="top">
|
||||
<div class="rudder" :class="{ rec: recording, compact }">
|
||||
<header v-if="compact" class="bar">
|
||||
<b class="name">Rudder</b>
|
||||
<div class="status" :data-state="engine" role="status">
|
||||
<span class="dot" aria-hidden="true"></span>{{ statusText }}
|
||||
</div>
|
||||
<span class="spacer"></span>
|
||||
<button class="icon-btn" :aria-expanded="showSettings" title="Language and microphone" aria-label="Language and microphone" @click="showSettings = !showSettings">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" :aria-expanded="showInfo" title="What is Rudder?" aria-label="What is Rudder?" @click="showInfo = !showInfo">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7.5v.5"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
<header v-else class="top">
|
||||
<div class="brand">
|
||||
<span class="logo" aria-hidden="true">⎈</span>
|
||||
<div>
|
||||
|
|
@ -501,7 +518,7 @@ const statusText = computed(() => ({
|
|||
higher-quality mode (mSBC) kept dropping the mic after a few words.</p>
|
||||
</section>
|
||||
|
||||
<section class="controls">
|
||||
<section v-if="!compact || showSettings" class="controls">
|
||||
<button
|
||||
class="mic"
|
||||
:class="{ on: recording }"
|
||||
|
|
@ -522,7 +539,7 @@ const statusText = computed(() => ({
|
|||
</label>
|
||||
</section>
|
||||
|
||||
<section class="mics">
|
||||
<section v-if="!compact || showSettings" class="mics">
|
||||
<label class="mic-pick">
|
||||
<span>Microphone</span>
|
||||
<select v-model="device" :disabled="recording || testing" @change="save(); testResult = null">
|
||||
|
|
@ -566,11 +583,23 @@ const statusText = computed(() => ({
|
|||
</section>
|
||||
|
||||
<footer class="bottom">
|
||||
<button
|
||||
v-if="compact"
|
||||
class="mic sm"
|
||||
:class="{ on: recording }"
|
||||
:disabled="busy || engine !== 'ready'"
|
||||
:aria-pressed="recording"
|
||||
:aria-label="recording ? 'Stop recording' : 'Start recording'"
|
||||
@click="toggle"
|
||||
>
|
||||
<span class="mic-icon" aria-hidden="true">{{ recording ? '■' : '●' }}</span>
|
||||
{{ recording ? 'Stop' : 'Record' }}
|
||||
</button>
|
||||
<button :disabled="!draft.trim()" @click="copy(draft, 'all')">{{ copied === 'all' ? 'Copied ✓' : 'Copy all' }}</button>
|
||||
<button :disabled="!draft.trim() || recording" @click="clearAll">Clear</button>
|
||||
<button v-if="!compact || showSettings" :disabled="!draft.trim() || recording" @click="clearAll">Clear</button>
|
||||
<span class="count">{{ words }} {{ words === 1 ? 'word' : 'words' }}</span>
|
||||
<span class="spacer"></span>
|
||||
<button class="ghost" :aria-expanded="showDebug" @click="showDebug = !showDebug">🐞 Debug</button>
|
||||
<button v-if="!compact || showSettings" class="ghost" :aria-expanded="showDebug" @click="showDebug = !showDebug">🐞 Debug</button>
|
||||
</footer>
|
||||
|
||||
<section v-if="showDebug" class="debug">
|
||||
|
|
@ -658,6 +687,26 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 1
|
|||
.debug dl { display: grid; grid-template-columns: 90px 1fr; gap: 6px 10px; margin: 0 0 10px; }
|
||||
.debug dt { color: var(--muted); }
|
||||
.debug dd { margin: 0; overflow-wrap: anywhere; }
|
||||
/* The corner panel (variant="widget"): no page header, the draft fills the panel. */
|
||||
.rudder.compact { min-height: 100%; padding: 10px 12px 12px; gap: 8px; font-size: 14px; }
|
||||
.bar { display: flex; align-items: center; gap: 8px; }
|
||||
.bar .name { font-size: 14px; }
|
||||
.bar .status { padding: 3px 9px; font-size: 12px; gap: 6px; }
|
||||
.icon-btn { display: grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 1px solid var(--line); border-radius: 8px;
|
||||
background: transparent; color: var(--muted); cursor: pointer; }
|
||||
.icon-btn svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
|
||||
.icon-btn[aria-expanded='true'], .icon-btn:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.compact .controls .mic { display: none; }
|
||||
.compact .compose { min-height: 140px; }
|
||||
.compact .draft { min-height: 120px; resize: none; padding: 10px 12px; font-size: 15px; line-height: 1.5; }
|
||||
.compact .hearing { display: none; }
|
||||
.compact .bottom { order: 2; gap: 8px; }
|
||||
.compact .bottom button { padding: 7px 12px; }
|
||||
.compact .bottom .mic.sm { padding: 7px 14px; font-size: 14px; gap: 6px; border-radius: 999px; }
|
||||
.compact .bottom .mic.sm:not(.on) { background: var(--accent); color: #04121c; border-color: var(--accent); }
|
||||
.compact .bottom .mic.sm.on { background: var(--rec); color: #fff; border-color: var(--rec); }
|
||||
.compact .notice, .compact .hint { order: 3; font-size: 12.5px; padding: 8px 10px; }
|
||||
.compact .debug { order: 4; }
|
||||
/* A phone-sized panel: less padding, and a shorter draft so it stays in view below the controls. */
|
||||
@media (max-width: 480px) {
|
||||
.rudder { min-height: 100%; padding: 12px; gap: 10px; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue