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:
Doxa Maps 2026-09-27 21:06:58 +00:00
parent 4415519a83
commit 3ae2390536
2 changed files with 108 additions and 28 deletions

View file

@ -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)

View file

@ -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; }