Corner widgets: a plugin can declare a fixed corner of the Plugin Studio, and Rudder takes bottom-right

BUILD-CORNER-WIDGET-CLASS-RUDDER-IN-PLUGIN-STUDIO (design approved by dx-shepherd). A profile's .meta.json
declares "widget": { "corner": "bottom-right" | ... }; generate-tree carries it into manifest.json, so the
Studio keeps no list. The Studio mounts each widget once in a fixed corner host (z-index above everything),
outside the rail and the pane, collapsed to a button, with its open state remembered. Picking it in the rail
opens the corner instead of mounting a second copy in a pane. The host and the mounted element live in a
page-level singleton on document.body, so a Studio hot update or remount re-attaches the same element and
Rudder keeps its live stream. rudder-voice declares bottom-right. Needs eyes: open, HMR, collapse.
This commit is contained in:
Doxa Maps 2026-09-27 08:32:42 +00:00
parent 4531374e68
commit 1775f5acb5
3 changed files with 83 additions and 3 deletions

View file

@ -313,8 +313,11 @@ function profileReachesAMap(dir, file) {
function metaStageHeavy(dir, profileName) {
try {
const meta = JSON.parse(readFileSync(join(dir, `${profileName}.meta.json`), 'utf8'))
return { stage: meta?.stage === 'production' ? 'production' : 'experimental', heavy: meta?.heavy === true }
} catch { return { stage: 'experimental', heavy: false } }
const corner = meta?.widget?.corner
return { stage: meta?.stage === 'production' ? 'production' : 'experimental', heavy: meta?.heavy === true,
// "widget": { "corner": "bottom-right" } — a corner widget the Plugin Studio mounts once, fixed, not in a pane
widget: ['bottom-right', 'bottom-left', 'top-right', 'top-left'].includes(corner) ? { corner } : null }
} catch { return { stage: 'experimental', heavy: false, widget: null } }
}
function metaIsMap(dir, profileName) {
@ -1302,11 +1305,12 @@ function scanBundles() {
? composesAMapLeaf(dir, profileName, file)
: (metaIsMap(dir, profileName) ?? profileReachesAMap(dir, file))
const { stage: profileStage, heavy: profileHeavy } = metaStageHeavy(dir, profileName)
const { stage: profileStage, heavy: profileHeavy, widget: profileWidget } = metaStageHeavy(dir, profileName)
return {
profile: profileName,
stage: profileStage,
...(profileHeavy ? { heavy: true } : {}),
...(profileWidget ? { widget: profileWidget } : {}),
file,
description: description || fileTag || '',
element: tag,

View file

@ -915,6 +915,7 @@ export default defineComponent({
const useModel = (m) => {
this.manifestBundles = (m && m.bundles) || []
this.rows = this.rowsFromManifest(m)
this.setupWidgets()
this.route()
}
const baked = (typeof window !== 'undefined' && window.__psBaked) || null
@ -961,6 +962,9 @@ export default defineComponent({
cap,
icon: CAP_ICON[cap] || 'embed',
description: p.description || '',
// CORNER WIDGET (the Driver, BUILD-CORNER-WIDGET-CLASS): a profile whose .meta.json declares
// "widget": { "corner": ... } is mounted once in a fixed corner of the Studio, not in a pane.
widget: (p.widget && ['bottom-right', 'bottom-left', 'top-right', 'top-left'].includes(p.widget.corner)) ? p.widget.corner : '',
heavy,
bundleJs: b.scriptUrl || (b.bundle + '/' + b.bundle + '.js'),
embed: (p.embed && p.embed.script) || '',
@ -1017,6 +1021,10 @@ export default defineComponent({
// guard would blank the content pane (flatRows has no matching entry to render) with the "Choose
// an element" hint also gone — a real, if minor, regression a synthetic row must never cause.
if (!this.rows.some(r => r.key === key)) return
// A corner widget is never mounted in a pane (one live instance, in its corner): picking it in the
// rail opens its corner instead.
const wrow = this.rows.find(r => r.key === key)
if (wrow && wrow.widget) { this.openWidget(wrow, true); return }
const prevKey = this.activeKey
this.activeKey = key
this.embedOpen = false
@ -1050,6 +1058,73 @@ export default defineComponent({
row.state = 'idle'
row.error = ''
},
// ── CORNER WIDGETS ────────────────────────────────────────────────────────────────────────────────
// One fixed host per corner, created on document.body and kept in a PAGE-LEVEL singleton
// (window.__doxaStudioWidgets), outside Vue's tree: a Studio hot update or remount finds the same host
// and the same mounted element, so a widget like Rudder keeps its live state across HMR. Collapsed to a
// button by default; the open state is remembered per widget.
widgetStore() {
if (!window.__doxaStudioWidgets) window.__doxaStudioWidgets = { hosts: {}, els: {} }
return window.__doxaStudioWidgets
},
widgetHost(corner) {
const W = this.widgetStore()
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}:12px;${x}:12px;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
},
setupWidgets() {
for (const row of this.rows.filter(r => r.widget)) {
const W = this.widgetStore()
const host = this.widgetHost(row.widget)
let box = host.querySelector(`[data-widget="${row.key}"]`)
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));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.textContent = row.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'))
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])
let open = false
try { open = localStorage.getItem('plugin-studio:widget-open:' + row.key) === '1' } catch (_) {}
if (open) this.openWidget(row, true)
}
},
openWidget(row, open) {
const host = this.widgetHost(row.widget)
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'
btn.setAttribute('aria-expanded', open ? 'true' : 'false')
try { localStorage.setItem('plugin-studio:widget-open:' + row.key, open ? '1' : '0') } catch (_) {}
const W = this.widgetStore()
if (!open || W.els[row.key]) return
loadScriptOnce(this.resolve(row.bundleJs)).then(() => whenTagDefined(row.tag)).then(() => {
if (W.els[row.key]) return
const el = document.createElement(row.tag)
el.setAttribute('profile-config', JSON.stringify(this.paneConfig(row, this.locale)))
if (this.base) el.setAttribute('tree-base', this.base.replace(/\/+$/, ''))
el.style.cssText = 'display:block;position:absolute;inset:0;width:100%;height:100%;'
W.els[row.key] = el
panel.appendChild(el)
}).catch((e) => { panel.textContent = (e && e.message) || String(e) })
},
mountElement(row) {
row.mounted = true
row.state = 'loading'

View file

@ -1,6 +1,7 @@
{
"_comment": "Rudder — private, offline, streaming voice-to-text. DESKTOP target: the speech model runs in the local backend (backend/rudder-engine: NVIDIA NeMo-Speech.cpp + Nemotron 3.5 ASR GGUF), started by `bun run dev` and run by Tauri as a sidecar. Not for public web embeds: a website has no local engine.",
"type": "single",
"widget": { "corner": "bottom-right" },
"label": "Rudder — voice to text (desktop)",
"targets": ["desktop"],
"backend": "backend/rudder-engine"