From 046e24dea1153befcd9f3f3b4883380fb9bf46eb Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:18:37 +0300 Subject: [PATCH] style: inspector cards, readiness rows, list cards, responsive columns, welcome; remove Simple mode (replaced by Disable all enhancements) Co-Authored-By: Claude Sonnet 5.5 --- app/ui/boot.js | 3 +- app/ui/catalogue/demos.js | 2 +- app/ui/composer/elements/polish.css | 21 ++++++++++- app/ui/composer/elements/settings.css | 8 ++--- app/ui/features/composer/enhance.js | 2 +- app/ui/features/inspector/project.js | 25 ++++++------- app/ui/features/inspector/scene.js | 23 ++++++------ app/ui/features/keys/index.js | 4 +-- app/ui/features/modes/index.js | 52 --------------------------- app/ui/features/settings/about.js | 4 +-- app/ui/features/timeline/index.js | 4 ++- app/ui/features/wizard/index.js | 6 ++-- app/ui/frame.css | 45 ++++++++--------------- app/ui/modules.js | 1 - app/ui/shell/frame.js | 17 ++++----- app/ui/shell/mode.js | 22 ------------ 16 files changed, 83 insertions(+), 156 deletions(-) delete mode 100644 app/ui/features/modes/index.js delete mode 100644 app/ui/shell/mode.js diff --git a/app/ui/boot.js b/app/ui/boot.js index ce426051..d8db6ad6 100644 --- a/app/ui/boot.js +++ b/app/ui/boot.js @@ -13,7 +13,6 @@ import { createMaintenance } from "./shell/maintenance.js"; import { syncSeparated } from "./shell/audio.js"; import { createKeys } from "./shell/keys.js"; import { createDock } from "./shell/dock.js"; -import { createMode } from "./shell/mode.js"; import { hostFor } from "./shell/mounts.js"; import paths from "./modules.js"; @@ -29,7 +28,7 @@ linkPipeline({ project, pipeline, onOpen: (fn) => on((what) => { if (what === "o const frame = build(document.getElementById("app")); const selection = createSelection({ project }); selection.on(() => say("select")); -const app = { editorSettings: [], timelineLanes: [], timelineView: {}, mediaMenu: [], mediaPeek: {}, sceneSections: [], clipTags: [], has: new Set(), actions: [], inputHooks: [], promptPrefix: [], lastRun: { typed: undefined, n: 0 }, runner: {}, project, pipeline, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, mode: createMode(), panel: frame.togglePanel, closePanels: frame.closePanels, dock: createDock(), maintenance: createMaintenance({ api, flush: async () => { await project.flush(); if (project.unsaved) throw new Error("Your latest edits could not be saved, so nothing was changed. Try again in a moment."); } }), keys: createKeys(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } }; +const app = { editorSettings: [], timelineLanes: [], timelineView: {}, mediaMenu: [], mediaPeek: {}, sceneSections: [], clipTags: [], has: new Set(), actions: [], inputHooks: [], promptPrefix: [], lastRun: { typed: undefined, n: 0 }, runner: {}, project, pipeline, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, dock: createDock(), maintenance: createMaintenance({ api, flush: async () => { await project.flush(); if (project.unsaved) throw new Error("Your latest edits could not be saved, so nothing was changed. Try again in a moment."); } }), keys: createKeys(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } }; const settings = createSettings({ menubar: hostFor("menubar.menus") }); app.openSettings = (id) => settings.open(id); const result = await loadFeatures(paths, { app }); diff --git a/app/ui/catalogue/demos.js b/app/ui/catalogue/demos.js index 9f5a08bf..fe318e21 100644 --- a/app/ui/catalogue/demos.js +++ b/app/ui/catalogue/demos.js @@ -300,7 +300,7 @@ export const DEMOS = { }), "overlay.blocking": { message: "Restarting ComfyUI…" }, "slideOver.md": (c) => ({ side: "right", title: "Properties", - body: c.hint.default({ text: "Simple mode slides this over the preview." }) }), + body: c.hint.default({ text: "A panel that slides in over the page." }) }), // --- gallery + wheel --- "gallery.adaptive": { id: "demo-gallery", cols: 3, items: [ diff --git a/app/ui/composer/elements/polish.css b/app/ui/composer/elements/polish.css index a80b6111..68aab6bd 100644 --- a/app/ui/composer/elements/polish.css +++ b/app/ui/composer/elements/polish.css @@ -64,7 +64,8 @@ body { -webkit-font-smoothing: antialiased; letter-spacing: -.003em; } .cx-field > .cx-label, .cx-field > label { color: var(--muted); font-size: var(--fs-5); font-weight: var(--fw-regular); } .cx-eyebrow { color: var(--muted); font-weight: var(--fw-semibold); } .cx-banner { border-radius: var(--r-4); } -.cx-chip { border-color: transparent; background: var(--elev-2); } +.cx-chip { border-color: transparent; } +.cx-chip-neutral { background: var(--elev-2); } /* inside a settings card the buttons are quiet: a tinted fill with accent text, never a row of solid amber */ .cx-settings-row .cx-btn-primary { background: var(--accent-soft); color: var(--accent-hi); } @@ -87,3 +88,21 @@ body { -webkit-font-smoothing: antialiased; letter-spacing: -.003em; } .cx-menu-item:hover:not(:disabled) .cx-menu-icon, .cx-menu-item:hover:not(:disabled) .cx-menu-hint { color: inherit; } .cx-menu-sep { margin: var(--sp-3) var(--sp-3); } .cx-menu-heading { padding: var(--sp-3) var(--sp-5) var(--sp-2); font-size: var(--fs-7); } + +/* media: tiles and the viewer are rounded, with no outline until chosen */ +.cx-cell { border-color: transparent; border-radius: var(--r-4); } +.cx-cell:hover { border-color: var(--line-hover); } +.cx-cell-badge, .cx-cell-duration { border-radius: var(--r-2); } +.cx-viewer-stage { border-radius: var(--r-4); } +.cx-viewer-media { border-radius: var(--r-3); } + +/* inspector: related fields share one rounded card */ +.fp-card { background: var(--elev-2); border-radius: var(--r-5); padding: var(--sp-6); gap: var(--sp-5); } +.fp-card .cx-input, .fp-card .cx-textarea, .fp-card .cx-select { background: var(--elev-3); } +.fp-card .cx-segmented { background: var(--elev-3); } +.cx-panel-body > .cx-eyebrow, .cx-stack > .cx-eyebrow { margin-block-start: var(--sp-4); } + +/* list views read as a grouped card, like a settings list */ +.cx-settings-content .cx-media-list { background: var(--elev-2); border-radius: var(--r-5); padding: var(--sp-3); gap: 0; } +.cx-settings-content .cx-media-row { border-radius: var(--r-3); border-color: transparent; background: transparent; } +.cx-settings-content .cx-media-row:hover { background: var(--hover); } diff --git a/app/ui/composer/elements/settings.css b/app/ui/composer/elements/settings.css index 3ba3d16f..56b00261 100644 --- a/app/ui/composer/elements/settings.css +++ b/app/ui/composer/elements/settings.css @@ -32,13 +32,13 @@ } /* a run of rows (and switches) is one card */ -.cx-settings-content .cx-stack > :is(.cx-settings-row, .cx-toggle-row) { +:is(.cx-settings-content, .cx-modal-body) .cx-stack > :is(.cx-settings-row, .cx-toggle-row) { background-color: var(--elev-2); border: 0; border-radius: var(--r-5); padding: var(--sp-5) var(--sp-7); min-block-size: 40px; display: flex; align-items: center; } -.cx-settings-content .cx-stack > .cx-toggle-row > label { inline-size: 100%; display: flex; } -.cx-settings-content .cx-stack > :is(.cx-settings-row, .cx-toggle-row):has(+ :is(.cx-settings-row, .cx-toggle-row)) { border-end-start-radius: 0; border-end-end-radius: 0; } -.cx-settings-content .cx-stack > :is(.cx-settings-row, .cx-toggle-row) + :is(.cx-settings-row, .cx-toggle-row) { +:is(.cx-settings-content, .cx-modal-body) .cx-stack > .cx-toggle-row > label { inline-size: 100%; display: flex; } +:is(.cx-settings-content, .cx-modal-body) .cx-stack > :is(.cx-settings-row, .cx-toggle-row):has(+ :is(.cx-settings-row, .cx-toggle-row)) { border-end-start-radius: 0; border-end-end-radius: 0; } +:is(.cx-settings-content, .cx-modal-body) .cx-stack > :is(.cx-settings-row, .cx-toggle-row) + :is(.cx-settings-row, .cx-toggle-row) { border-start-start-radius: 0; border-start-end-radius: 0; margin-block-start: calc(-1 * var(--cx-gap, var(--sp-4))); background-image: linear-gradient(var(--line), var(--line)); background-repeat: no-repeat; diff --git a/app/ui/features/composer/enhance.js b/app/ui/features/composer/enhance.js index 15debe5f..34b32c08 100644 --- a/app/ui/features/composer/enhance.js +++ b/app/ui/features/composer/enhance.js @@ -23,7 +23,7 @@ export function enhance(app, own) { function draw() { if (!alive) return; const sl = slot(); - if (!sl) return page.set([c.hint.default({ text: "The open pipeline has no prompt enhancer. Pick a preset that has one, or add the “FunPack Enhance Prompt” node in Models & Pipeline." })]); + if (!sl) return page.set([c.emptyState.default({ icon: "✎", title: "No prompt enhancer here", hint: "Pick a preset that has one, or add the “FunPack Enhance Prompt” node in Settings ▸ Models & Pipeline." })]); const v = sl.inputs || {}, val = (n, d) => (v[n] === undefined ? d : v[n]); const on = val("enabled", false), greedy = val("greedy", false); page.set([ diff --git a/app/ui/features/inspector/project.js b/app/ui/features/inspector/project.js index 8b0955e7..0fd7f7e2 100644 --- a/app/ui/features/inspector/project.js +++ b/app/ui/features/inspector/project.js @@ -1,6 +1,7 @@ // The Project tab: what the whole project makes. import { composer as c } from "../../composer/composer.js"; import { DRIVEN, isVideoClip, segments } from "../../shell/scenes.js"; +import { card } from "./scene.js"; const STARTS = [{ value: "i2v", label: "From an image" }, { value: "t2v", label: "From a prompt" }]; @@ -60,20 +61,20 @@ export function projectRows(p, slots, api) { const per = (open.num_frames_per_scene || 97) / (open.frame_rate || 25); return [ c.label.section({ text: "Project" }), - c.field.default({ label: "Project name", control: c.input.md({ label: "Project name", value: open.name || "", onCommit: (v) => p.rename(v) }) }), + card(c.field.default({ label: "Project name", control: c.input.md({ label: "Project name", value: open.name || "", onCommit: (v) => p.rename(v) }) })), c.label.section({ text: "Video settings" }), - c.field.row({ fields: [frames.field, fps.field] }), - c.hint.default({ text: `≈ ${per.toFixed(2)} s per shot` }), - ...(size.length ? [c.field.row({ fields: size.map((x) => x.field) })] : []), - ...custom(p, open), - c.field.default({ label: "Final render size", control: c.select.md({ label: "Final render size", options: sizes(open), value: open.export_size_from || "", - onChange: (v) => p.setField("export_size_from", v) }) }), - c.field.default({ label: "Start shots", control: c.select.md({ label: "Start shots", options: STARTS, value: open.generation_mode === "t2v" ? "t2v" : "i2v", onChange: (v) => p.setField("generation_mode", v) }) }), + card(c.field.row({ fields: [frames.field, fps.field] }), + c.hint.default({ text: `≈ ${per.toFixed(2)} s per shot` }), + size.length ? c.field.row({ fields: size.map((x) => x.field) }) : null, + ...custom(p, open), + c.field.default({ label: "Final render size", control: c.select.md({ label: "Final render size", options: sizes(open), value: open.export_size_from || "", + onChange: (v) => p.setField("export_size_from", v) }) }), + c.field.default({ label: "Start shots", control: c.select.md({ label: "Start shots", options: STARTS, value: open.generation_mode === "t2v" ? "t2v" : "i2v", onChange: (v) => p.setField("generation_mode", v) }) })), c.label.section({ text: "Prompt" }), - c.field.default({ label: "Anchor", control: c.textarea.md({ label: "Anchor", value: p.anchor, rows: 2, onInput: (v) => p.setAnchor(v) }) }), - c.field.default({ label: "Negative prompt", control: c.textarea.md({ label: "Negative prompt", value: p.negative, rows: 2, onInput: (v) => p.setNegative(v) }) }), - c.toggle.default({ label: "Postfix", hint: "Appended to every scene (a style or quality tag, say).", checked: open.postfix_enabled !== false, onChange: (v) => p.setField("postfix_enabled", v) }), - c.textarea.md({ label: "Postfix", value: p.postfix, rows: 2, disabled: open.postfix_enabled === false, onInput: (v) => p.setPostfix(v) }), + card(c.field.default({ label: "Anchor", control: c.textarea.md({ label: "Anchor", value: p.anchor, rows: 2, onInput: (v) => p.setAnchor(v) }) }), + c.field.default({ label: "Negative prompt", control: c.textarea.md({ label: "Negative prompt", value: p.negative, rows: 2, onInput: (v) => p.setNegative(v) }) }), + c.toggle.default({ label: "Postfix", hint: "Appended to every scene (a style or quality tag, say).", checked: open.postfix_enabled !== false, onChange: (v) => p.setField("postfix_enabled", v) }), + c.textarea.md({ label: "Postfix", value: p.postfix, rows: 2, disabled: open.postfix_enabled === false, onInput: (v) => p.setPostfix(v) })), c.collapsible.default({ label: "ADVANCED PROJECT SETTINGS", body: c.region.stack({ gap: "sm", children: [ c.collapsible.default({ label: "Split preview (generation prompt)", body: splitPreview(p, api) }), ] }) }), diff --git a/app/ui/features/inspector/scene.js b/app/ui/features/inspector/scene.js index 49c84a53..b56d1e44 100644 --- a/app/ui/features/inspector/scene.js +++ b/app/ui/features/inspector/scene.js @@ -36,12 +36,15 @@ function drift(sc, open) { /** The slip: where in the rendered file this clip starts and how much of it plays. Only a clip that has a render has one. */ function trimRows(p, sc, open) { if (!((open.scene_renders || {})[sc.id] || {}).media) return []; - return [c.label.section({ text: "Source trim (slip)" }), c.field.row({ fields: [ + return [c.label.section({ text: "Source trim (slip)" }), card(c.field.row({ fields: [ c.field.default({ label: "Source in (s)", control: c.number.md({ label: "Source in", value: sc.source_in || 0, min: 0, step: 0.05, onChange: (v) => p.setScene(sc.id, "source_in", v) }) }), c.field.default({ label: "Source dur (s, 0 = full)", control: c.number.md({ label: "Source duration", value: sc.source_dur != null ? sc.source_dur : 0, min: 0, step: 0.05, onChange: (v) => p.setScene(sc.id, "source_dur", v > 0 ? Math.max(0.1, v) : null) }) })] }), - c.button.sm({ label: "Reset source trim", tone: "ghost", disabled: !sc.source_in && sc.source_dur == null, onClick: () => { p.setScene(sc.id, "source_in", 0); p.setScene(sc.id, "source_dur", null); } })]; + c.button.sm({ label: "Reset source trim", tone: "ghost", disabled: !sc.source_in && sc.source_dur == null, onClick: () => { p.setScene(sc.id, "source_in", 0); p.setScene(sc.id, "source_dur", null); } }))]; } +/** A group of fields on one rounded card. */ +export const card = (...children) => { const n = c.region.stack({ gap: "md", children: children.flat().filter(Boolean) }); n.node.classList.add("fp-card"); return n; }; + export function sceneRows(p, app) { const sc = p.selected, open = p.project; if (!sc) return [c.emptyState.default({ icon: "▭", title: "No scene", hint: "Add one on the timeline." })]; @@ -50,22 +53,22 @@ export function sceneRows(p, app) { c.banner.info({ text: "Generating with FunPack Studio + Chain Sampler", action: { label: "Engine settings →", onClick: () => app.openSettings && app.openSettings("engine") } }), ...(sc.removed_from_plan ? [c.banner.info({ text: "Removed from plan — its generated clip stays on the timeline.", action: { label: "Restore to plan", onClick: () => p.edit((pr) => restoreToPlan(pr, sc.id)) } })] : []), ...(cuts ? [c.hint.default({ text: "This scene has editorial cuts — Generate regens the whole uncut scene." })] : []), - ...(isSubclip(sc) ? [c.hint.default({ text: "This is a cut: its prompt and source belong to the first part." })] : [ + ...(isSubclip(sc) ? [c.hint.default({ text: "This is a cut: its prompt and source belong to the first part." })] : [card( c.field.default({ label: "Prompt", control: c.textarea.md({ label: "Prompt", value: sc.text || "", rows: 4, onInput: (v) => p.setText(sc.id, v) }) }), c.field.default({ label: "Source", control: c.select.md({ label: "Source", options: SOURCES, value: (sc.source || {}).type || "carry", - onChange: (v) => p.setScene(sc.id, "source", { ...(sc.source || {}), type: v }) }) }), - ]), + onChange: (v) => p.setScene(sc.id, "source", { ...(sc.source || {}), type: v }) }) }))]), c.label.section({ text: "Reference media (v5 pipeline)" }), - mediaField(app, p, sc, { label: "Resolution source", hint: "Sets this scene's aspect ratio for generation — the project's own Width/Height set the actual resolution; this image's pixels are not used.", + card(mediaField(app, p, sc, { label: "Resolution source", hint: "Sets this scene's aspect ratio for generation — the project's own Width/Height set the actual resolution; this image's pixels are not used.", key: "source_image", multiple: false, kinds: ["image"], empty: "— choose resolution source —" }), - mediaField(app, p, sc, { label: "References", key: "references", multiple: true, kinds: ["image", "video", "audio"], empty: "+ Add reference" }), - c.field.row({ fields: [ + mediaField(app, p, sc, { label: "References", key: "references", multiple: true, kinds: ["image", "video", "audio"], empty: "+ Add reference" })), + c.label.section({ text: "Length" }), + card(c.field.row({ fields: [ lengthField(p, sc, "Frames", "frames_mode", "frames", effFrames(sc, open)), lengthField(p, sc, "FPS", "fps_mode", "fps", effFps(sc, open)), ] }), drift(sc, open), - c.button.sm({ label: "Generate this scene", tone: "primary", onClick: () => app.say("generate.scene") }), + c.button.md({ label: "Generate this scene", tone: "primary", onClick: () => app.say("generate.scene") })), ...trimRows(p, sc, open), - c.checkbox.default({ label: "Exclude from full generation", checked: Boolean(sc.excluded), onChange: (v) => p.setScene(sc.id, "excluded", v) }), + card(c.checkbox.default({ label: "Exclude from full generation", checked: Boolean(sc.excluded), onChange: (v) => p.setScene(sc.id, "excluded", v) })), ]; } diff --git a/app/ui/features/keys/index.js b/app/ui/features/keys/index.js index d4f29a5d..c2a5cf5c 100644 --- a/app/ui/features/keys/index.js +++ b/app/ui/features/keys/index.js @@ -5,10 +5,10 @@ import { segments } from "../../shell/scenes.js"; export default { id: "keys", mount: "menubar.menus", - needs: ["project", "selection", "keys", "mode"], + needs: ["project", "selection", "keys"], setup({ app }) { const p = app.project, sel = app.selection; - const scene = (fn) => (e) => (app.mode.now === "simple" ? false : fn(e)); + const scene = (fn) => (e) => fn(e); const frame = () => (p.project && p.project.frame_rate) || 25; const step = (dir) => scene((e) => (e && e.target && e.target.closest && e.target.closest("button, a, [role=slider], [role=tab], [role=radio], [role=option], [role=menuitem], [role=separator]") ? false : app.playhead.set(app.playhead.at + dir / frame()))); // a widget that uses the arrows keeps them const edge = (which) => { // I / O: the playhead to the picked clip's in / out point diff --git a/app/ui/features/modes/index.js b/app/ui/features/modes/index.js deleted file mode 100644 index 553f8c6d..00000000 --- a/app/ui/features/modes/index.js +++ /dev/null @@ -1,52 +0,0 @@ -// The Simple | Editor switch in the menubar, and Simple's own surface: the one-prompt bar under the monitor. -import { composer as c } from "../../composer/composer.js"; - -const switcher = { - id: "mode-switch", - mount: "menubar.mode", - needs: ["mode"], - setup({ host, app }) { - const seg = c.segmented.sm({ label: "Mode", value: app.mode.now, onChange: (v) => app.mode.set(v), - options: [{ value: "simple", label: "Simple" }, { value: "editor", label: "Editor" }] }); - host.append(seg.node); - return app.mode.on((v) => seg.setValue && seg.setValue(v)); - }, -}; - -// "Describe the shot you want…" over two buttons that slide Assets and Properties in. Only shown in Simple mode. -const bar = { - id: "simple-bar", - mount: "preview.simple", - needs: ["mode", "project", "panel"], - setup({ host, app }) { - const p = app.project; - const area = c.textarea.md({ label: "Prompt", rows: 3, placeholder: "Describe the shot you want…", value: "", - onInput: (v) => { if (p.selected) p.setText(p.selected.id, v); } }); - const row = c.toolbar.default({ items: [], trailing: [c.button.sm({ label: "Media", tone: "ghost", onClick: () => app.panel("assets") }), - c.button.sm({ label: "Advanced settings", tone: "ghost", onClick: () => app.panel("props") })] }); - host.append(area.node, row.node); - const draw = () => { - const simple = app.mode.now === "simple"; - area.node.hidden = row.node.hidden = !simple; - if (!simple) app.closePanels(); - else if (!area.node.matches(":focus")) area.setValue(p.selected ? p.selected.text || "" : ""); - }; - draw(); - const off = [app.mode.on(draw), app.on(draw)]; - return () => off.forEach((f) => f()); - }, -}; - -// A slid-over zone covers the button that opened it, so each carries its own close (Simple mode only). -const closer = (mount) => ({ - id: `panel-close:${mount}`, mount, needs: ["mode", "closePanels"], - setup({ host, app }) { - const b = c.button.sm({ label: "✕", tone: "ghost", title: "Close", onClick: () => app.closePanels() }).node; - host.append(b); - const draw = () => { b.hidden = app.mode.now !== "simple"; }; - draw(); - return app.mode.on(draw); - }, -}); - -export default [switcher, bar, closer("assets.actions"), closer("inspector.actions")]; diff --git a/app/ui/features/settings/about.js b/app/ui/features/settings/about.js index 3b78e239..977d8b14 100644 --- a/app/ui/features/settings/about.js +++ b/app/ui/features/settings/about.js @@ -33,14 +33,14 @@ export const about = (api) => function mount() { /** "Is this machine ready to generate?": the rows core/readiness.py reports, worst first. */ export const readiness = (api) => function mount() { const body = c.region.stack({ gap: "sm", label: "Ready to generate" }); - const MARK = { ok: "✓", warn: "!", fail: "✗" }, ORDER = { fail: 0, warn: 1, ok: 2 }; + const MARK = { ok: "✓", warn: "!", fail: "✗" }, TONE = { ok: "good", warn: "warn", fail: "danger" }, ORDER = { fail: 0, warn: 1, ok: 2 }; const run = async () => { body.set([c.hint.default({ text: "Checking…" })]); let rows; try { rows = (await api.readiness()).rows; } catch (err) { return body.set([c.banner.warn({ text: `Could not check: ${err.message}` }), check]); } const bad = rows.filter((r) => r.level !== "ok").length; body.set([check, bad ? c.banner.warn({ text: `${bad} thing${bad > 1 ? "s" : ""} to look at.` }) : c.banner.info({ text: "Nothing in the way." }), - ...[...rows].sort((a, b) => ORDER[a.level] - ORDER[b.level]).map((r) => c.hint.default({ text: `${MARK[r.level]} ${r.text}` }))]); + ...[...rows].sort((a, b) => ORDER[a.level] - ORDER[b.level]).map((r) => c.settingsRow.default({ label: r.text, control: c.chip[TONE[r.level]]({ label: MARK[r.level] }) }))]); }; const check = c.button.sm({ label: "Check this machine", tone: "ghost", title: "Look for what a first run on a new GPU box trips over", onClick: run }); body.set([c.hint.default({ text: "Run this on a new rental before the first generation: it checks ffmpeg, the GPU, model files, installed nodes and loaded modules." }), check]); diff --git a/app/ui/features/timeline/index.js b/app/ui/features/timeline/index.js index fa64f130..81ae649b 100644 --- a/app/ui/features/timeline/index.js +++ b/app/ui/features/timeline/index.js @@ -94,7 +94,9 @@ const zoom = { needs: ["say"], setup({ host, app }) { const b = (label, what, title) => c.button.sm({ label, tone: "ghost", title, onClick: () => app.say(what) }).node; - host.append(c.hint.default({ text: "J K L · S split · I O in/out · + − zoom" }).node, + const keys = c.hint.default({ text: "J K L · S split · I O in/out · + − zoom" }).node; + keys.classList.add("fp-keyhint"); // the first thing to go when the row runs out of room + host.append(keys, b("−", "zoom.out", "Zoom out"), b("+", "zoom.in", "Zoom in"), b("fit", "zoom.fit", "Fit the cut to the window")); }, }; diff --git a/app/ui/features/wizard/index.js b/app/ui/features/wizard/index.js index e34d23d3..f14a13df 100644 --- a/app/ui/features/wizard/index.js +++ b/app/ui/features/wizard/index.js @@ -1,10 +1,10 @@ -// Project Setup Wizard: look, how much of the app, a name and a model's starting point, then the tour offer. Opens from File ▸ Project Setup Wizard. +// Project Setup Wizard: look, a name and a model's starting point, then the tour offer. Opens from File ▸ Project Setup Wizard. import { composer as c } from "../../composer/composer.js"; export default { id: "wizard", mount: "menubar.menus", - needs: ["project", "api", "pipeline", "theme", "mode"], + needs: ["project", "api", "pipeline", "theme"], setup({ app }) { let win = null; const ctx = { name: "Untitled montage", preset: "", tour: true }; @@ -13,8 +13,6 @@ export default { const STEPS = [ { title: "Choose your look", sub: "You can change this any time in Settings ▸ Appearance.", body: () => c.segmented.md({ label: "Colour scheme", value: app.theme.get(), onChange: (v) => app.theme.apply(v), options: [{ value: "dark", label: "Dark" }, { value: "light", label: "Light" }, { value: "auto", label: "Auto" }] }) }, - { title: "How much do you want in front of you?", sub: "You can switch at any time from the control beside the FunPack wordmark.", body: () => c.radioGroup.default({ label: "Mode", value: app.mode.now, onChange: (v) => app.mode.set(v), - options: [{ value: "simple", label: "Simple — one prompt and Generate. Rating-driven steering and experimental sampling stay off." }, { value: "editor", label: "Editor — the full cutting room: timeline, ratings, every Studio and sampler setting." }] }) }, { title: "Name it, and pick a model", sub: "The model decides which nodes and which files this project needs.", body: () => c.region.stack({ gap: "sm", children: [ c.field.default({ label: "Project name", control: c.input.md({ label: "Project name", value: ctx.name, onInput: (v) => { ctx.name = v; } }) }), c.field.default({ label: "Start from", hint: "A model's own pipeline, ready to fill with files. “Keep the current pipeline” leaves Models & Pipeline as it is.", diff --git a/app/ui/frame.css b/app/ui/frame.css index 4cafaa62..a7b29513 100644 --- a/app/ui/frame.css +++ b/app/ui/frame.css @@ -1,5 +1,5 @@ /* The frame's proportions, as v4 had them: fixed side columns and a fixed-height timeline across the bottom. */ -:root { --media-w: 280px; --properties-w: 340px; --timeline-h: 320px; --pad: 10px; --pgap: 10px; } +:root { --media-w: clamp(180px, 20vw, 280px); --properties-w: clamp(270px, 28vw, 360px); --timeline-h: clamp(270px, 42vh, 340px); --pad: 10px; --pgap: 10px; } .fp-main { flex-direction: column; padding: 0 var(--pad) var(--pad); gap: var(--pgap); } .fp-row { flex: 1 1 auto; flex-direction: row; min-block-size: 0; gap: var(--pgap); } .fp-row > *, .fp-main > :nth-child(2) { min-inline-size: 0; border: var(--bw) solid var(--line); border-radius: var(--card-r); overflow: hidden; background: var(--elev-1); } @@ -18,21 +18,6 @@ /* An empty zone says so in the middle of it, with the controls kept at its foot. */ .cx-zone .cx-stack-fill > .cx-empty { flex: 1 1 auto; justify-content: center; } -/* Simple mode: one prompt and one Generate. The timeline, splitters and dock go; Assets and Properties slide over the preview. */ -.fp-row { position: relative; } -:root[data-ui-mode="simple"] .fp-main > :nth-child(2), -:root[data-ui-mode="simple"] .fp-simple-hide { display: none !important; } -:root[data-ui-mode="simple"] .fp-row > :first-child, -:root[data-ui-mode="simple"] .fp-row > :nth-child(3) { - position: absolute; top: 0; bottom: 0; z-index: 60; inline-size: min(var(--properties-w), 82vw); - background: var(--bg, var(--elev-1)); box-shadow: 0 0 40px -8px var(--shadow-ink, #000); - transition: transform .18s cubic-bezier(.22, .61, .36, 1); -} -:root[data-ui-mode="simple"] .fp-row > :first-child { left: 0; transform: translateX(-101%); } -:root[data-ui-mode="simple"] .fp-row > :nth-child(3) { right: 0; transform: translateX(101%); } -:root[data-ui-mode="simple"] .fp-row.fp-show-assets > :first-child, -:root[data-ui-mode="simple"] .fp-row.fp-show-props > :nth-child(3) { transform: none; } - /* Splitters: thin hit areas straddling the zone borders. */ .fp-main { position: relative; } .fp-split { position: absolute; z-index: 20; background: transparent; touch-action: none; } @@ -41,25 +26,24 @@ .fp-split-left { left: calc(var(--pad) + var(--media-w) + var(--pgap) / 2 - 3.5px); } .fp-split-right { right: calc(var(--pad) + var(--properties-w) + var(--pgap) / 2 - 3.5px); } .fp-split-bottom { left: 0; right: 0; block-size: 7px; bottom: calc(var(--pad) + var(--timeline-h) + var(--pgap) / 2 - 3.5px); cursor: row-resize; } -:root[data-ui-mode="simple"] .fp-split { display: none; } -/* 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; } +/* Dock: a hidden zone leaves the row (and the splitter beside it). */ +.fp-hide-assets .fp-row > :nth-child(1), +.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, .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-hide-preview .fp-row > :nth-child(3), .fp-hide-preview.fp-hide-properties .fp-row > :nth-child(1) { flex: 1 1 auto; } +.fp-hide-assets.fp-hide-preview .fp-row > :nth-child(3) { flex: 1 1 auto; } .fp-split { border: 0 !important; } /* Auto-hide timeline: a strip the height of its title bar, full height under the pointer or during a drag. */ -:root.fp-peek:not([data-ui-mode="simple"]) .fp-main { position: relative; padding-block-end: 34px; } -:root.fp-peek:not([data-ui-mode="simple"]) .fp-main > :nth-child(2) { position: absolute; inset-inline: 0; bottom: 0; block-size: 34px; min-block-size: 0; z-index: 20; overflow: hidden; } -:root.fp-peek:not([data-ui-mode="simple"]) .fp-main > :nth-child(2) .cx-panel-body { display: none; } -:root.fp-peek:not([data-ui-mode="simple"]) .fp-main > :nth-child(2):hover, -:root.fp-peek.fp-peek-open:not([data-ui-mode="simple"]) .fp-main > :nth-child(2) { block-size: var(--timeline-h); overflow: visible; } -:root.fp-peek:not([data-ui-mode="simple"]) .fp-main > :nth-child(2):hover .cx-panel-body, -:root.fp-peek.fp-peek-open:not([data-ui-mode="simple"]) .fp-main > :nth-child(2) .cx-panel-body { display: block; } +:root.fp-peek .fp-main { position: relative; padding-block-end: 34px; } +:root.fp-peek .fp-main > :nth-child(2) { position: absolute; inset-inline: 0; bottom: 0; block-size: 34px; min-block-size: 0; z-index: 20; overflow: hidden; } +:root.fp-peek .fp-main > :nth-child(2) .cx-panel-body { display: none; } +:root.fp-peek .fp-main > :nth-child(2):hover, +:root.fp-peek.fp-peek-open .fp-main > :nth-child(2) { block-size: var(--timeline-h); overflow: visible; } +:root.fp-peek .fp-main > :nth-child(2):hover .cx-panel-body, +:root.fp-peek.fp-peek-open .fp-main > :nth-child(2) .cx-panel-body { display: block; } :root.fp-peek .fp-split-bottom { display: none; } /* Welcome: the maintenance actions as a 2x2 of tiles */ @@ -68,3 +52,4 @@ .fp-tile:hover { background: var(--hover); } .fp-tile-title { font-size: var(--fs-4); font-weight: var(--fw-medium); } .fp-tile-hint { font-size: var(--fs-6); color: var(--muted); } +@media (max-width: 1150px) { .fp-keyhint { display: none; } } diff --git a/app/ui/modules.js b/app/ui/modules.js index 0459bed3..5bd86173 100644 --- a/app/ui/modules.js +++ b/app/ui/modules.js @@ -1,6 +1,5 @@ // The one list of UI features. Adding a feature is a folder under ./features and a line here. export default [ - "./features/modes/index.js", "./features/log/index.js", "./features/projects/index.js", "./features/media/index.js", diff --git a/app/ui/shell/frame.js b/app/ui/shell/frame.js index 952205b8..a3bcc90c 100644 --- a/app/ui/shell/frame.js +++ b/app/ui/shell/frame.js @@ -1,7 +1,6 @@ // The app's regions, laid out as v4 had them: a menubar, then Assets | Preview | Properties side by // side with the Timeline under all three. Every region offers mount points; no feature is named here. -// menubar.mode | menubar.menus | menubar.right the bar -// preview.simple (under the monitor, Simple mode only) +// menubar.menus | menubar.right the bar // timeline.toolbar | timeline.toolbar.end (the row over the stage; the stage itself is `timeline`) // assets.projects | assets.media (stacked in the Assets zone) // (body) | .actions (after the title) | .status (far end) for assets, preview, inspector, timeline @@ -33,13 +32,11 @@ export function build(root) { }); return composer.region.stack({ gap: "md", fill: true, children: sheets }); }; - // Preview: the monitor's stack, then (Simple mode only) the one-prompt bar under it. + // Preview: the monitor's stack. const preview = () => { const stack = composer.region.stack({ gap: "sm", fill: true }); offer("preview", stack.node); - const bar = composer.region.stack({ gap: "sm" }); - offer("preview.simple", bar.node); - return composer.region.stack({ gap: "sm", fill: true, children: [stack, bar] }); + return stack; }; // The timeline zone: a toolbar (what acts on the cut | zoom and hints), then the stage. const timeline = () => { @@ -54,13 +51,10 @@ export function build(root) { main.node.classList.add("fp-main"); const header = composer.toolbar.default({ - items: [composer.brand.default({}), group("menubar.mode"), group("menubar.menus")], + items: [composer.brand.default({}), group("menubar.menus")], trailing: [group("menubar.right")], }); const frame = composer.frame.app({ header, main }); - // In Simple mode Assets and Properties slide over the preview on request. - frame.togglePanel = (which) => { const open = row.node.classList.contains(`fp-show-${which}`); row.node.classList.remove("fp-show-assets", "fp-show-props"); if (!open) row.node.classList.add(`fp-show-${which}`); }; - frame.closePanels = () => row.node.classList.remove("fp-show-assets", "fp-show-props"); root.append(frame.node); // v4's draggable splitters: three 7px bars that set the zone sizes; sizes are remembered. const KEY = "funpack_layout", css = document.documentElement.style; @@ -79,7 +73,8 @@ export function build(root) { bar.className = `fp-split ${cls}`; bar.setAttribute("role", "separator"); let from = 0; drag(bar, { - onStart: () => { from = parseFloat(getComputedStyle(document.documentElement).getPropertyValue(prop)); }, + // the default is a clamp(), which has no number in it: measure the zone itself + onStart: () => { const zone = prop === "--media-w" ? row.node.children[0] : prop === "--properties-w" ? row.node.children[2] : main.node.children[1]; const r = zone.getBoundingClientRect(); from = axis === "dx" ? r.width : r.height; }, onMove: (m) => { saved[prop] = Math.round(Math.min(size[prop][1], Math.max(size[prop][0], from + sign * m[axis]))); apply(); }, onEnd: keep, }); diff --git a/app/ui/shell/mode.js b/app/ui/shell/mode.js deleted file mode 100644 index b99bb48b..00000000 --- a/app/ui/shell/mode.js +++ /dev/null @@ -1,22 +0,0 @@ -// Simple or Editor: how much of the screen is showing. One word, kept in this browser, written onto the page -// (`data-ui-mode`) where the stylesheet reads it. -const KEY = "funpack_ui_mode"; - -export function createMode(root = document.documentElement) { - const heard = new Set(); - const stored = () => { try { return localStorage.getItem(KEY) === "simple" ? "simple" : "editor"; } catch { return "editor"; } }; - let now = stored(); - root.dataset.uiMode = now; - return { - get now() { return now; }, - set(next) { - if (next !== "simple" && next !== "editor") return; - if (next === now) return; - now = next; - root.dataset.uiMode = now; - try { localStorage.setItem(KEY, now); } catch { /* private window: lasts until reload */ } - heard.forEach((fn) => fn(now)); - }, - on(fn) { heard.add(fn); return () => heard.delete(fn); }, - }; -}