From 4192a6f87d965b6b96395250543bf7396178d7c1 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 29 Oct 2026 11:48:52 +0000 Subject: [PATCH] Keep Authoring within its stack and theme, and cover its wiring - Keep the documented 72px / flex-grow 1 action sizing and the 360px panel width; the larger type applies to the Authoring panel only, so CCTV and Street Level buttons are unchanged. - Scope the primary and file-row styles to the panel so Cyber's shared button surface no longer overrides them. - Shorten the scene PLAY in Spanish and Russian so it does not break mid-word, and fix two translation inconsistencies. - Map the spoken name "authoring" to the panel. - Clear the has-shots marker when the controls are destroyed. - Test the idle button states through published changes, the PLAY translation context, and where the sharing row is placed. --- docs/CURRENT-STATE.md | 3 +- src/i18n/locales/es.json | 4 +- src/i18n/locales/ru.json | 2 +- src/i18n/locales/zh-Hant.json | 2 +- src/ui/sceneControls.js | 1 + src/ui/sceneControls.test.mjs | 141 ++++++++++++++++++++++++++++++++++ src/ui/styles/scenes.css | 18 ++--- src/voice/gevActions.js | 1 + 8 files changed, 157 insertions(+), 15 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0e98ce774..42865a09d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1176,7 +1176,8 @@ The panel is titled Authoring and is laid out in four labelled steps: SCENE (picker on its own row, NEW SCENE, DELETE), SHOTS (+ CAPTURE SHOT, UPDATE SHOT, the numbered shot list and a select/rename hint), PREVIEW (PLAY, STOP, NEXT) and SAVE & SHARE (EXPORT, IMPORT, RUN LOG, then the EDIT DETAILS / SHARE SCENE row). -Every action has a tooltip, and the status line is a polite live region. Outside +Every button in the panel's step rows has a tooltip, and the status line is a +polite live region. Outside playback a button that would do nothing is disabled: DELETE and CAPTURE SHOT without a scene, UPDATE SHOT without a selected shot, PLAY and NEXT on a scene with no shots. During playback everything but STOP stays disabled as before. diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 6fd845182..e9ec5a1c6 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3012,9 +3012,9 @@ "Add the current view, style and layers as the next shot": "Añade la vista, el estilo y las capas actuales como el siguiente plano", "Replace the selected shot with the current view": "Sustituye el plano seleccionado por la vista actual", "Click a shot to select it, double-click to rename.": "Haz clic en un plano para seleccionarlo y doble clic para cambiarle el nombre.", - "scene|PLAY": "REPRODUCIR", + "scene|PLAY": "REPROD.", "Play the whole scene from its first shot": "Reproduce toda la escena desde su primer plano", - "Stop playback (Esc)": "Detiene la reproducción (Esc)", + "Stop playback (Esc)": "Detén la reproducción (Esc)", "Play only the shot after the selected one": "Reproduce solo el plano posterior al seleccionado", "SAVE & SHARE": "GUARDAR Y COMPARTIR", "Download every scene as a JSON file you can import later": "Descarga todas las escenas como un archivo JSON que puedes importar más tarde", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index eacad5511..8d0ed1f24 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3081,7 +3081,7 @@ "Add the current view, style and layers as the next shot": "Добавить текущий вид, стиль и слои следующим кадром", "Replace the selected shot with the current view": "Заменить выбранный кадр текущим видом", "Click a shot to select it, double-click to rename.": "Щёлкните кадр, чтобы выбрать его, дважды — чтобы переименовать.", - "scene|PLAY": "ВОСПРОИЗВЕСТИ", + "scene|PLAY": "ПУСК", "Play the whole scene from its first shot": "Воспроизвести всю сцену с первого кадра", "Stop playback (Esc)": "Остановить воспроизведение (Esc)", "Play only the shot after the selected one": "Воспроизвести только кадр после выбранного", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 17a98338d..33316d6ed 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2862,7 +2862,7 @@ "routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多", "route proxy error": "路線代理出錯", "AUTHORING": "創作", - "Create and edit scenes": "建立和編輯場景", + "Create and edit scenes": "創建和編輯場景", "SCENE": "場景", "Scene": "場景", "NEW SCENE": "新建場景", diff --git a/src/ui/sceneControls.js b/src/ui/sceneControls.js index 3be960b9d..5976c2c05 100644 --- a/src/ui/sceneControls.js +++ b/src/ui/sceneControls.js @@ -272,6 +272,7 @@ export class SceneControls { ]) remove(); if (this.elements.shots) this.elements.shots.textContent = ''; + this.elements.panel?.classList.toggle('has-shots', false); presentSceneRuntime(this.elements.runtime, ''); this.setPlaybackActive(false); } diff --git a/src/ui/sceneControls.test.mjs b/src/ui/sceneControls.test.mjs index b150d36ed..b31a1a681 100644 --- a/src/ui/sceneControls.test.mjs +++ b/src/ui/sceneControls.test.mjs @@ -3,6 +3,8 @@ import assert from 'node:assert/strict'; import { createStateChannel } from '../app/stateChannel.js'; import { SceneControls } from './sceneControls.js'; import { SceneDirector } from '../scenes/director.js'; +import { mountSceneSharing } from './sceneSharing.js'; +import { setLocale } from '../i18n/index.js'; function fixture() { class Element extends EventTarget { @@ -466,3 +468,142 @@ test('Scene controls consume current state, preserve rows on progress, and unsub f.restore(); } }); + +test('Authoring refreshes idle buttons from published changes', () => { + const f = fixture(); + try { + f.owner.destroy(); + const channel = createStateChannel(() => f.state); + const owner = new SceneControls({ + read: () => f.state, + actions: f.actions, + elements: f.elements, + subscribe: (listener) => channel.subscribe(listener), + }); + const disabled = (name) => f.elements[name].disabled; + assert.equal(disabled('start'), false); + assert.equal(f.elements.panel.classList.contains('has-shots'), true); + + f.state.selectedShotId = null; + channel.publish({ type: 'selection-changed' }); + assert.equal(disabled('update'), true); + + f.state.scenes[0].shots = []; + channel.publish({ type: 'shot-deleted' }); + assert.equal(disabled('start'), true); + assert.equal(disabled('next'), true); + assert.equal(f.elements.panel.classList.contains('has-shots'), false); + + // A run that ends hands the buttons back with the idle rules applied. + f.state.running = true; + channel.publish({ type: 'buttons-changed' }); + assert.equal(disabled('capture'), true); + assert.equal(disabled('stop'), false); + f.state.running = false; + channel.publish({ type: 'buttons-changed' }); + assert.equal(disabled('capture'), false); + assert.equal(disabled('start'), true); + + f.state.scenes = []; + f.state.selectedSceneId = null; + channel.publish({ type: 'scene-deleted' }); + assert.equal(disabled('delete'), true); + assert.equal(disabled('capture'), true); + assert.equal(disabled('new'), false); + + f.state.scenes = [{ id: 'scene-b', title: 'B', shots: [{ id: 's' }] }]; + f.state.selectedSceneId = 'scene-b'; + f.state.selectedShotId = 's'; + channel.publish({ type: 'project-imported' }); + assert.equal(disabled('start'), false); + assert.equal(f.elements.panel.classList.contains('has-shots'), true); + + owner.destroy(); + assert.equal(f.elements.panel.classList.contains('has-shots'), false); + channel.destroy(); + } finally { + f.restore(); + } +}); + +test('Authoring PLAY uses the scene context, not the radio PLAY', (context) => { + context.after(() => setLocale('en')); + setLocale('es'); + const f = fixture(); + try { + assert.equal(f.elements.start.textContent, 'REPROD.'); + } finally { + f.restore(); + } +}); + +test('The sharing row sits under SAVE & SHARE, after the file row', () => { + const saved = globalThis.document; + const node = (className = '') => { + const element = { + className, + children: [], + dataset: {}, + parent: null, + addEventListener() {}, + removeEventListener() {}, + append(...items) { + for (const item of items) { + item.parent = element; + element.children.push(item); + } + }, + appendChild(item) { + element.append(item); + }, + after(item) { + const siblings = element.parent.children; + item.parent = element.parent; + siblings.splice(siblings.indexOf(element) + 1, 0, item); + }, + remove() { + const siblings = element.parent?.children; + if (siblings) siblings.splice(siblings.indexOf(element), 1); + }, + querySelector(selector) { + const name = selector.slice(1); + const walk = (at) => { + for (const child of at.children) { + if (child.className.split(' ').includes(name)) return child; + const found = walk(child); + if (found) return found; + } + return null; + }; + return walk(element); + }, + }; + return element; + }; + globalThis.document = { createElement: () => node() }; + try { + const panel = node(); + const inner = node('scene-panel-inner'); + const fileRow = node('scene-controls scene-file-controls'); + const status = node('scene-status'); + panel.append(inner); + inner.append(fileRow, status); + const dispose = mountSceneSharing({ edit() {}, share() {} }, panel); + const bar = inner.children[1]; + assert.equal(bar.className, 'scene-controls'); + assert.equal(inner.children[2], status); + assert.ok(bar.children.every((button) => button.title)); + dispose(); + assert.deepEqual(inner.children, [fileRow, status]); + + // Without the file row, the bar still lands inside the panel. + const plain = node(); + const plainInner = node('scene-panel-inner'); + plain.append(plainInner); + mountSceneSharing({ edit() {}, share() {} }, plain)(); + mountSceneSharing({ edit() {}, share() {} }, plain); + assert.equal(plainInner.children.length, 1); + } finally { + globalThis.document = saved; + } +}); diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css index 975f37a57..f2bec04d9 100644 --- a/src/ui/styles/scenes.css +++ b/src/ui/styles/scenes.css @@ -1,6 +1,6 @@ /* ── Scene Director Panel ───────────────────── */ #scene-panel { - --panel-expanded-width: 372px; + --panel-expanded-width: 360px; /* Secondary text in Authoring: dimmer than labels, but legible on the globe. */ --scene-quiet: rgba(232, 234, 237, 0.62); position: fixed; @@ -179,6 +179,9 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn { .scene-btn { flex: 1; min-width: 72px; +} + +#scene-panel .scene-btn { font-size: 10px; padding: 8px 10px; } @@ -206,21 +209,16 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn { margin-top: 2px; } -/* One primary action per step: CAPTURE SHOT and PLAY. */ -.scene-btn-primary:not(:disabled) { +/* One primary action per step: CAPTURE SHOT and PLAY. The panel id keeps + this above Cyber's shared button surface. */ +#scene-panel .scene-btn-primary:not(:disabled) { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 55%, transparent); background: color-mix(in srgb, var(--accent) 10%, transparent); } -#scene-capture-btn { - flex: 2 1 0; -} - /* The file row is housekeeping; it stays quieter than the steps above. */ -.scene-file-controls .scene-btn { - flex: 1 1 0; - min-width: 0; +#scene-panel .scene-file-controls .scene-btn { font-size: 9px; background: transparent; } diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 0a43f02f1..f0afc9527 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -98,6 +98,7 @@ const PANEL_ALIASES = new Map([ ['global context', 'global-context-panel'], ['right context', 'global-context-panel'], ['context right panel', 'global-context-panel'], + ['authoring', 'scene-panel'], ['scenes', 'scene-panel'], ['scene', 'scene-panel'], ['post processing', 'pp-toggles'],