From 1775f5acb5bfa6bb3bd0482c406119ebc3c46419 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sun, 27 Sep 2026 08:32:42 +0000 Subject: [PATCH] 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. --- internal/generate-tree.mjs | 10 ++- .../plugin-studio/plugin-studio.vue | 75 +++++++++++++++++++ .../rudder-voice/rudder-voice.meta.json | 1 + 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/internal/generate-tree.mjs b/internal/generate-tree.mjs index 5a7b1cdce..94c0ff28e 100644 --- a/internal/generate-tree.mjs +++ b/internal/generate-tree.mjs @@ -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, diff --git a/plugin-designs/plugin-studio/plugin-studio.vue b/plugin-designs/plugin-studio/plugin-studio.vue index 28d8e0cec..aed452176 100644 --- a/plugin-designs/plugin-studio/plugin-studio.vue +++ b/plugin-designs/plugin-studio/plugin-studio.vue @@ -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' diff --git a/plugin-designs/rudder-voice/rudder-voice.meta.json b/plugin-designs/rudder-voice/rudder-voice.meta.json index 316e368c6..3960f33e1 100644 --- a/plugin-designs/rudder-voice/rudder-voice.meta.json +++ b/plugin-designs/rudder-voice/rudder-voice.meta.json @@ -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"