diff --git a/library/i18n/locales/ar/gospel.json b/library/i18n/locales/ar/gospel.json index 38e9cfcc..27bbb10d 100644 --- a/library/i18n/locales/ar/gospel.json +++ b/library/i18n/locales/ar/gospel.json @@ -91,9 +91,7 @@ "jesusFilm": "فيلم يسوع", "audioScripture": "الكتاب المقدس الصوتي", "bibleStoriesPlain": "قصص الكتاب المقدس", - "newTestament": "العهد الجديد", - "fullBible": "الكتاب المقدس كاملًا", - "nParts": "{n} أجزاء" + "nParts": "عدد الأجزاء: {n}" }, "state": { "noneFound": "لا شيء", diff --git a/library/i18n/locales/de/gospel.json b/library/i18n/locales/de/gospel.json index 353f882e..bc7d658b 100644 --- a/library/i18n/locales/de/gospel.json +++ b/library/i18n/locales/de/gospel.json @@ -91,8 +91,6 @@ "jesusFilm": "JESUS Film", "audioScripture": "Audio-Bibeltext", "bibleStoriesPlain": "Bibelgeschichten", - "newTestament": "Neues Testament", - "fullBible": "ganze Bibel", "nParts": "{n} Teile" }, "state": { diff --git a/library/i18n/locales/en/gospel.json b/library/i18n/locales/en/gospel.json index 9a7c4c36..df0d4203 100644 --- a/library/i18n/locales/en/gospel.json +++ b/library/i18n/locales/en/gospel.json @@ -91,8 +91,6 @@ "jesusFilm": "JESUS Film", "audioScripture": "Audio Scripture", "bibleStoriesPlain": "Bible Stories", - "newTestament": "New Testament", - "fullBible": "full Bible", "nParts": "{n} parts" }, "state": { diff --git a/library/i18n/locales/es/gospel.json b/library/i18n/locales/es/gospel.json index 181f085d..ed60cd8e 100644 --- a/library/i18n/locales/es/gospel.json +++ b/library/i18n/locales/es/gospel.json @@ -91,8 +91,6 @@ "jesusFilm": "Película JESÚS", "audioScripture": "Escrituras en audio", "bibleStoriesPlain": "Historias bíblicas", - "newTestament": "Nuevo Testamento", - "fullBible": "Biblia completa", "nParts": "{n} partes" }, "state": { diff --git a/library/i18n/locales/fr/gospel.json b/library/i18n/locales/fr/gospel.json index 8830bd87..86366e72 100644 --- a/library/i18n/locales/fr/gospel.json +++ b/library/i18n/locales/fr/gospel.json @@ -91,8 +91,6 @@ "jesusFilm": "Film JÉSUS", "audioScripture": "Écritures en audio", "bibleStoriesPlain": "Histoires bibliques", - "newTestament": "Nouveau Testament", - "fullBible": "Bible complète", "nParts": "{n} parties" }, "state": { diff --git a/library/i18n/locales/hi/gospel.json b/library/i18n/locales/hi/gospel.json index 07136d70..a15fd262 100644 --- a/library/i18n/locales/hi/gospel.json +++ b/library/i18n/locales/hi/gospel.json @@ -91,8 +91,6 @@ "jesusFilm": "यीशु फ़िल्म", "audioScripture": "ऑडियो पवित्र शास्त्र", "bibleStoriesPlain": "बाइबल की कहानियाँ", - "newTestament": "नया नियम", - "fullBible": "पूरी बाइबिल", "nParts": "{n} भाग" }, "state": { diff --git a/library/i18n/locales/it/gospel.json b/library/i18n/locales/it/gospel.json index 610a6d90..db71f27c 100644 --- a/library/i18n/locales/it/gospel.json +++ b/library/i18n/locales/it/gospel.json @@ -91,8 +91,6 @@ "jesusFilm": "Film GESÙ", "audioScripture": "Scritture in audio", "bibleStoriesPlain": "Storie bibliche", - "newTestament": "Nuovo Testamento", - "fullBible": "Bibbia completa", "nParts": "{n} parti" }, "state": { diff --git a/library/i18n/locales/pt/gospel.json b/library/i18n/locales/pt/gospel.json index 6dcc40f7..4c8b6cbd 100644 --- a/library/i18n/locales/pt/gospel.json +++ b/library/i18n/locales/pt/gospel.json @@ -91,8 +91,6 @@ "jesusFilm": "Filme JESUS", "audioScripture": "Escrituras em áudio", "bibleStoriesPlain": "Histórias bíblicas", - "newTestament": "Novo Testamento", - "fullBible": "Bíblia completa", "nParts": "{n} partes" }, "state": { diff --git a/library/i18n/locales/ro/gospel.json b/library/i18n/locales/ro/gospel.json index 451ae1ac..3c37ac96 100644 --- a/library/i18n/locales/ro/gospel.json +++ b/library/i18n/locales/ro/gospel.json @@ -91,8 +91,6 @@ "jesusFilm": "Filmul ISUS", "audioScripture": "Scripturi audio", "bibleStoriesPlain": "Povestiri biblice", - "newTestament": "Noul Testament", - "fullBible": "Biblia completă", "nParts": "{n} părți" }, "state": { diff --git a/library/i18n/locales/ru/gospel.json b/library/i18n/locales/ru/gospel.json index f1df2624..82bfbf41 100644 --- a/library/i18n/locales/ru/gospel.json +++ b/library/i18n/locales/ru/gospel.json @@ -91,8 +91,6 @@ "jesusFilm": "Фильм «Иисус»", "audioScripture": "Аудио с Писанием", "bibleStoriesPlain": "Библейские истории", - "newTestament": "Новый Завет", - "fullBible": "вся Библия", "nParts": "частей: {n}" }, "state": { diff --git a/library/i18n/locales/zh/gospel.json b/library/i18n/locales/zh/gospel.json index feb0c79b..2a21a642 100644 --- a/library/i18n/locales/zh/gospel.json +++ b/library/i18n/locales/zh/gospel.json @@ -91,8 +91,6 @@ "jesusFilm": "《耶稣传》", "audioScripture": "音频圣经", "bibleStoriesPlain": "圣经故事", - "newTestament": "新约", - "fullBible": "全本圣经", "nParts": "{n} 个部分" }, "state": { diff --git a/plugin-designs/plugin-studio/plugin-studio.vue b/plugin-designs/plugin-studio/plugin-studio.vue index 2bbcc353..13e02af8 100644 --- a/plugin-designs/plugin-studio/plugin-studio.vue +++ b/plugin-designs/plugin-studio/plugin-studio.vue @@ -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 = '' +const WIDGET_ICON_CLOSE = '' + export default defineComponent({ name: 'PluginStudio', // A host's attributes (style="height:…", class, id) belong on the 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:"), 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) diff --git a/plugin-designs/rudder-voice/rudder-voice.vue b/plugin-designs/rudder-voice/rudder-voice.vue index 8bd1b527..540d1e0d 100644 --- a/plugin-designs/rudder-voice/rudder-voice.vue +++ b/plugin-designs/rudder-voice/rudder-voice.vue @@ -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(() => ({