From f8b0a4b89dd245373e5313242589a4dfcf290ed5 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:05:11 +0300 Subject: [PATCH] fix(ui): keys (Backspace, shifted +, dialog/repeat/Simple guards, +/- zoom), dock in Simple and preview-only hide, validated saved layout, honest Composer Story Co-Authored-By: Claude Sonnet 5.5 --- app/ui/features/composer/index.js | 4 ++-- app/ui/features/keys/index.js | 11 +++++++---- app/ui/features/menus/index.js | 6 +++--- app/ui/features/welcome/index.js | 5 +++-- app/ui/frame.css | 12 ++++++++---- app/ui/shell/frame.js | 8 +++++++- app/ui/shell/keys.js | 4 +++- app/ui/shell/tests/keys.test.js | 17 ++++++++++++++++- 8 files changed, 49 insertions(+), 18 deletions(-) diff --git a/app/ui/features/composer/index.js b/app/ui/features/composer/index.js index cb33be81..b0f89815 100644 --- a/app/ui/features/composer/index.js +++ b/app/ui/features/composer/index.js @@ -8,8 +8,8 @@ const later = (what) => c.emptyState.default({ icon: "◌", title: "Not built ye const story = (p) => c.region.stack({ gap: "sm", children: [ c.toolbar.default({ items: [c.select.sm({ label: "Templates", disabled: true, options: [{ value: "", label: "Templates…" }], value: "" })], trailing: [inert("Save")] }), c.toolbar.default({ items: [c.label.section({ text: "Story" })], trailing: [inert("+ Add shortcut"), inert("💡")] }), - c.textarea.md({ label: "Story", rows: 14, value: p.scenes.map((s) => s.text || "").join("\n\n") }), - c.hint.default({ text: "Edits apply to the scenes as you type, and scene edits show here. The anchor is its own field. Shortcuts expand at generation time." }), + c.textarea.md({ label: "Story", rows: 14, disabled: true, value: p.scenes.map((s) => s.text || "").join("\n\n") }), + c.hint.default({ text: "Coming soon: edit all scenes as one story here." }), c.collapsible.default({ label: "+ Variables", body: c.hint.default({ text: "$name → text, filled in at generation." }) }), ] }); diff --git a/app/ui/features/keys/index.js b/app/ui/features/keys/index.js index b1999c0c..25727c8d 100644 --- a/app/ui/features/keys/index.js +++ b/app/ui/features/keys/index.js @@ -1,18 +1,21 @@ -// v4's editing keys: undo/redo, new project, add and delete a scene. +// v4's editing keys: undo/redo, delete the selected scene, +/- zoom the timeline. Scene keys do nothing in Simple mode, where no timeline shows. import * as edits from "../../shell/edits.js"; export default { id: "keys", mount: "menubar.menus", - needs: ["project", "selection", "keys"], + needs: ["project", "selection", "keys", "mode"], setup({ app }) { const p = app.project, sel = app.selection; + const scene = (fn) => () => (app.mode.now === "simple" ? false : fn()); + const remove = scene(() => sel.ids.length ? sel.ids.forEach((id) => p.edit((pr) => edits.removeScene(pr, id))) : false); const off = [ app.keys.bind("mod+z", () => p.undo()), app.keys.bind("shift+mod+z", () => p.redo()), app.keys.bind("mod+y", () => p.redo()), - app.keys.bind("+", () => { if (!p.project) return false; const sc = p.edit((pr) => edits.addScene(pr)); if (sc) p.select(sc.id); }), - app.keys.bind("delete", () => sel.ids.length ? sel.ids.forEach((id) => p.edit((pr) => edits.removeScene(pr, id))) : false), + app.keys.bind("delete", remove), app.keys.bind("backspace", remove), + ...["+", "="].map((k) => app.keys.bind(k, scene(() => app.say("zoom.in")))), + app.keys.bind("-", scene(() => app.say("zoom.out"))), ]; return () => off.forEach((f) => f()); }, diff --git a/app/ui/features/menus/index.js b/app/ui/features/menus/index.js index 7fbb9025..09db51ef 100644 --- a/app/ui/features/menus/index.js +++ b/app/ui/features/menus/index.js @@ -37,17 +37,17 @@ export default { const menus = { File: async () => { const recent = await list().catch(() => []); - return [{ id: "new", label: "New Project", hint: "⌘N" }, soon("Project Setup Wizard…", "theme · model · tour"), { separator: true }, + return [{ id: "new", label: "New Project" }, soon("Project Setup Wizard…", "theme · model · tour"), { separator: true }, { heading: "Open recent" }, ...(recent.length ? recent.slice(0, 8).map((r) => ({ id: `open:${r.id}`, label: r.name })) : [{ id: "-", label: "No projects", disabled: true }]), { separator: true }, soon("Save Project File…", "⬇"), soon("Load Project File…"), { separator: true }, soon("Import Media…"), { id: "delete", label: "Delete Current Project", danger: true, disabled: !p.project }]; }, Edit: () => [{ id: "undo", label: "Undo", hint: "⌘Z", disabled: !p.canUndo }, { id: "redo", label: "Redo", hint: "⇧⌘Z", disabled: !p.canRedo }, { separator: true }, - { id: "add", label: "Add Scene", hint: "+", disabled: !p.project }, { id: "del", label: "Delete Scene", disabled: !sel.ids.length }, { separator: true }, + { id: "add", label: "Add Scene", disabled: !p.project }, { id: "del", label: "Delete Scene", disabled: !sel.ids.length }, { separator: true }, { id: "left", label: "Move Clip Left", hint: "timeline cut", disabled: !sel.focus }, { id: "right", label: "Move Clip Right", hint: "timeline cut", disabled: !sel.focus }, { separator: true }, { id: "exclude", label: "Toggle Exclude", disabled: !sel.focus }], View: () => [soon("Refresh Preview", ""), { id: "reset", label: "Reset Layout" }], - Help: () => [soon("Restart tour", ""), soon("Skip to FAQ", ""), soon("Exit tour", ""), { separator: true }, { id: "welcome", label: "Welcome tour…", hint: "?" }], + Help: () => [soon("Restart tour", ""), soon("Skip to FAQ", ""), soon("Exit tour", ""), { separator: true }, { id: "welcome", label: "Welcome tour…" }], }; const pick = (id) => (id.startsWith("open:") ? p.open(id.slice(5)) : actions[id] && actions[id]()); for (const [label, items] of Object.entries(menus)) { diff --git a/app/ui/features/welcome/index.js b/app/ui/features/welcome/index.js index 0d09a5d5..0162b904 100644 --- a/app/ui/features/welcome/index.js +++ b/app/ui/features/welcome/index.js @@ -29,7 +29,8 @@ export default { onClose: () => { markSeen(); win = null; }, }); }; - if (!seen()) setTimeout(open, 600); // after the first paint, so the screen it sits over is already there - return app.on((what) => { if (what === "welcome.open") open(); }); + const timer = seen() ? 0 : setTimeout(open, 600); // after the first paint, so the screen it sits over is already there + const off = app.on((what) => { if (what === "welcome.open") open(); }); + return () => { clearTimeout(timer); off(); }; }, }; diff --git a/app/ui/frame.css b/app/ui/frame.css index a854165b..c11b166a 100644 --- a/app/ui/frame.css +++ b/app/ui/frame.css @@ -43,7 +43,11 @@ .fp-split-bottom { left: 0; right: 0; block-size: 7px; bottom: calc(var(--timeline-h) - 3px); cursor: row-resize; } :root[data-ui-mode="simple"] .fp-split { display: none; } -/* Dock: a hidden zone leaves the row (and the splitter beside it). */ -.fp-hide-assets .fp-row > :first-child, .fp-hide-preview .fp-row > :nth-child(2), .fp-hide-properties .fp-row > :nth-child(3) { display: none; } -.fp-hide-assets .fp-split-left, .fp-hide-properties .fp-split-right { display: none; } -.fp-hide-assets.fp-hide-preview .fp-row > :nth-child(3), .fp-hide-preview.fp-hide-properties .fp-row > :first-child { flex: 1 1 auto; } +/* Dock: a hidden zone leaves the row (and the splitter beside it); Simple mode ignores the dock. */ +:root:not([data-ui-mode="simple"]).fp-hide-assets .fp-row > :nth-child(1), +:root:not([data-ui-mode="simple"]).fp-hide-preview .fp-row > :nth-child(2), +:root:not([data-ui-mode="simple"]).fp-hide-properties .fp-row > :nth-child(3) { display: none; } +.fp-hide-assets .fp-split-left, .fp-hide-properties .fp-split-right, .fp-hide-preview .fp-split-right { display: none; } +:root:not([data-ui-mode="simple"]).fp-hide-preview .fp-row > :nth-child(3), :root:not([data-ui-mode="simple"]).fp-hide-preview.fp-hide-properties .fp-row > :nth-child(1) { flex: 1 1 auto; } +:root:not([data-ui-mode="simple"]).fp-hide-assets.fp-hide-preview .fp-row > :nth-child(3) { flex: 1 1 auto; } +.fp-split { border: 0 !important; } diff --git a/app/ui/shell/frame.js b/app/ui/shell/frame.js index 25b1ef3c..952205b8 100644 --- a/app/ui/shell/frame.js +++ b/app/ui/shell/frame.js @@ -65,7 +65,13 @@ export function build(root) { // v4's draggable splitters: three 7px bars that set the zone sizes; sizes are remembered. const KEY = "funpack_layout", css = document.documentElement.style; const size = { "--media-w": [160, 600], "--properties-w": [200, 700], "--timeline-h": [200, 700] }; - const saved = (() => { try { return JSON.parse(localStorage.getItem(KEY)) || {}; } catch { return {}; } })(); + const saved = (() => { + try { + const raw = JSON.parse(localStorage.getItem(KEY)), out = {}; + for (const k in size) if (Number.isFinite(raw[k])) out[k] = Math.min(size[k][1], Math.max(size[k][0], raw[k])); + return out; + } catch { return {}; } // nothing, or nothing usable, remembered + })(); const apply = () => { for (const k in size) saved[k] ? css.setProperty(k, saved[k] + "px") : css.removeProperty(k); }; const keep = () => { try { localStorage.setItem(KEY, JSON.stringify(saved)); } catch { /* not remembered */ } }; const splitter = (host, cls, prop, sign, axis) => { diff --git a/app/ui/shell/keys.js b/app/ui/shell/keys.js index a7c1f0ed..6a31f95d 100644 --- a/app/ui/shell/keys.js +++ b/app/ui/shell/keys.js @@ -2,9 +2,11 @@ // Typing in a field is never intercepted. A binding that returns false lets the key through. export function createKeys(doc = document) { const table = new Map(); - const combo = (e) => [e.shiftKey && "shift", (e.metaKey || e.ctrlKey) && "mod", e.key.toLowerCase()].filter(Boolean).join("+"); + // shift names a combo only for letters and named keys: "+" is Shift+= on a US keyboard. + const combo = (e) => [e.shiftKey && (e.key.length > 1 || /[a-z]/i.test(e.key)) && "shift", (e.metaKey || e.ctrlKey) && "mod", e.key.toLowerCase()].filter(Boolean).join("+"); doc.addEventListener("keydown", (e) => { const t = e.target; + if (e.repeat || e.altKey || doc.querySelector('[role="dialog"]')) return; // no held-key floods, nothing behind a dialog or the Composer if (t && (t.isContentEditable || /^(input|textarea|select)$/i.test(t.tagName))) return; const fn = table.get(combo(e)); if (fn && fn(e) !== false) e.preventDefault(); diff --git a/app/ui/shell/tests/keys.test.js b/app/ui/shell/tests/keys.test.js index 622006f5..bdc0e5e3 100644 --- a/app/ui/shell/tests/keys.test.js +++ b/app/ui/shell/tests/keys.test.js @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { createKeys } from "../keys.js"; -const fakeDoc = () => { const d = { on: null, addEventListener(_, fn) { d.on = fn; } }; return d; }; +const fakeDoc = (dialog = false) => { const d = { on: null, addEventListener(_, fn) { d.on = fn; }, querySelector: () => dialog }; return d; }; const press = (d, key, extra = {}, target = {}) => { let stopped = false; d.on({ key, target, preventDefault: () => { stopped = true; }, ...extra }); return stopped; }; test("a bound combo runs and is swallowed; false lets the key through", () => { @@ -27,3 +27,18 @@ test("shift is part of the combo, typing in a field is never intercepted, unbind press(d, "Z", { metaKey: true, shiftKey: true }); assert.equal(n, 1); }); + +test("+ works with Shift held, and nothing fires on repeat, with Alt, or behind a dialog", () => { + const d = fakeDoc(), k = createKeys(d); + let n = 0; + k.bind("+", () => { n++; }); + press(d, "+", { shiftKey: true }); + assert.equal(n, 1); + press(d, "+", { repeat: true }); + press(d, "+", { altKey: true }); + assert.equal(n, 1); + const behind = fakeDoc(true), k2 = createKeys(behind); + k2.bind("+", () => { n++; }); + press(behind, "+"); + assert.equal(n, 1); +});