diff --git a/app/ui/boot.js b/app/ui/boot.js index b059ab1d..c20b18fa 100644 --- a/app/ui/boot.js +++ b/app/ui/boot.js @@ -6,6 +6,7 @@ import { hostFor } from "./shell/mounts.js"; import paths from "./modules.js"; const frame = build(document.getElementById("app")); -const settings = createSettings({ menubar: hostFor("menubar") }); +const settings = createSettings({ menubar: hostFor("menubar.menus") }); const result = await loadFeatures(paths); +settings.addButton(); window.FunPackUI = { frame, settings, ...result }; diff --git a/app/ui/composer/elements/button.css b/app/ui/composer/elements/button.css index 79a628f7..0f959b1a 100644 --- a/app/ui/composer/elements/button.css +++ b/app/ui/composer/elements/button.css @@ -77,3 +77,9 @@ animation: cx-spin 800ms linear infinite; } @keyframes cx-spin { to { transform: rotate(360deg); } } +/* Render: the teal one, as v4 had it -- the output side of Generate. */ +.cx-btn-render { + background: linear-gradient(var(--teal), color-mix(in srgb, var(--teal) 78%, black)); + color: var(--bg); border-color: var(--teal); font-weight: var(--fw-semibold); +} +.cx-btn-render:hover:not(:disabled) { filter: brightness(1.08); } diff --git a/app/ui/composer/elements/button.js b/app/ui/composer/elements/button.js index ee0914e9..38c7f792 100644 --- a/app/ui/composer/elements/button.js +++ b/app/ui/composer/elements/button.js @@ -9,7 +9,7 @@ import { el } from "../internals/el.js"; import { setText } from "../internals/text.js"; import { roving } from "../internals/focus.js"; -const TONES = new Set(["neutral", "primary", "danger", "ghost"]); +const TONES = new Set(["neutral", "primary", "danger", "ghost", "render"]); function base(size, { label, onClick, tone = "neutral", icon, disabled = false, busy = false, type = "button", title, pressed } = {}) { if (!TONES.has(tone)) throw new RangeError(`Unknown button tone "${tone}". Known: ${[...TONES].join(", ")}.`); diff --git a/app/ui/composer/elements/status.css b/app/ui/composer/elements/status.css index 0d729ec1..00543d65 100644 --- a/app/ui/composer/elements/status.css +++ b/app/ui/composer/elements/status.css @@ -2,7 +2,7 @@ display: inline-flex; align-items: center; gap: var(--sp-2); height: var(--ctl-h-sm); padding: 0 var(--sp-3); border: var(--bw) solid var(--line); border-radius: var(--r-pill); - font-size: var(--fs-7); color: var(--muted); white-space: nowrap; + font-size: var(--fs-6); color: var(--muted); white-space: nowrap; } .cx-chip-dot { width: 6px; height: 6px; border-radius: var(--r-pill); background: currentColor; } .cx-chip-good { color: var(--good); border-color: color-mix(in srgb, var(--good) 30%, var(--line)); } diff --git a/app/ui/composer/tokens/tokens.css b/app/ui/composer/tokens/tokens.css index 1cc852c6..f0f2bf79 100644 --- a/app/ui/composer/tokens/tokens.css +++ b/app/ui/composer/tokens/tokens.css @@ -43,9 +43,9 @@ --r-pill: 9999px; /* ── controls (v4 shipped 11 distinct heights) ──────────────────────── */ - --ctl-h-sm: 20px; --ctl-h-md: 24px; --ctl-h-lg: 28px; --ctl-h-xl: 32px; + --ctl-h-sm: 22px; --ctl-h-md: 24px; --ctl-h-lg: 28px; --ctl-h-xl: 32px; --ctl-h-icon: 18px; --ctl-h-icon-sm: 16px; - --ctl-pad-x-sm: 6px; --ctl-pad-x-md: 8px; + --ctl-pad-x-sm: 8px; --ctl-pad-x-md: 8px; --ctl-pad-x-lg: 10px; --ctl-pad-x-xl: 12px; --ctl-r: var(--r-3); --ctl-gap: var(--sp-3); diff --git a/app/ui/features/placeholders/index.js b/app/ui/features/placeholders/index.js new file mode 100644 index 00000000..aed885d2 --- /dev/null +++ b/app/ui/features/placeholders/index.js @@ -0,0 +1,43 @@ +// Stand-ins for the parts of v4's screen whose features are not ported yet: same place, same look, +// inert. A real feature that mounts at one of these points replaces its row here (delete the row). +import { composer as c } from "../../composer/composer.js"; + +const btn = (label, tone = "ghost", extra = {}) => c.button.sm({ label, tone, disabled: true, ...extra }); +const menu = (label) => c.button.sm({ label, tone: "ghost" }); // inert, but not dimmed: a menu is never greyed in v4 +const dockTab = (label) => c.button.sm({ label, tone: "neutral", pressed: true, disabled: true }); + +const items = { + "menubar.mode": () => [c.segmented.sm({ options: [{ value: "simple", label: "Simple" }, { value: "editor", label: "Editor" }], value: "editor" })], + "menubar.menus": () => ["File", "Edit", "View", "Help"].map(menu), + "menubar.right": () => [c.chip.neutral({ label: "saved" }), c.chip.good({ label: "ComfyUI live", dot: true })], + + "assets.actions": () => [], + assets: () => [c.toolbar.default({ items: [c.text.sm({ text: "Projects" })], trailing: [btn("+ New")] }), + c.hint.default({ text: "Your projects appear here." })], + + preview: () => [ + c.viewer.media({ empty: "No render yet. Use Generate in the timeline header." }), + c.toolbar.default({ items: [btn("⏹"), btn("▶"), c.text.sm({ text: "00:00:00:00" }), btn("📌 Save frame")] }), + ], + + inspector: () => [c.segmented.sm({ options: [{ value: "project", label: "Project" }, { value: "scene", label: "Scene" }], value: "scene" }), + c.hint.default({ text: "Settings of the selected scene or the project." })], + + "timeline.actions": () => [btn("▶ Generate", "primary"), btn("Selected"), btn("⧉ Render", "render"), btn("⚡ Auto Montage")], + "timeline.status": () => [btn("⏱ Sampler", "neutral"), c.text.sm({ text: "0 clips" }), + dockTab("Assets"), dockTab("Preview"), dockTab("Properties"), btn("◆ Composer", "neutral")], + timeline: () => [ + c.toolbar.default({ items: [c.text.sm({ text: "00:00:00 / 00:00:00" }), btn("+ Add"), btn("Split"), btn("Remove"), + btn("⤓ Export"), btn("Save to media bin"), btn("⊟ Separate audio"), btn("Remove audio")], + trailing: [c.text.sm({ text: "J/K/L · S split · I/O in/out · +/- zoom" })] }), + c.toolbar.default({ items: [c.text.sm({ text: "zoom" }), btn("−"), btn("+"), btn("fit")] }), + c.ruler.default({ marks: [{ label: "00:00" }, { label: "00:02" }, { label: "00:04" }], total: 3 }), + c.toolbar.default({ items: [c.text.sm({ text: "Video" }), c.hint.default({ text: "" })] }), + c.toolbar.default({ items: [c.text.sm({ text: "Original" }), c.hint.default({ text: "" })] }), + ], +}; + +export default Object.entries(items).map(([mount, build]) => ({ + id: `placeholder:${mount}`, mount, + setup: ({ host }) => build().forEach((handle) => host.append(handle.node)), +})); diff --git a/app/ui/modules.js b/app/ui/modules.js index 5b22b21c..4f11fc55 100644 --- a/app/ui/modules.js +++ b/app/ui/modules.js @@ -1,5 +1,6 @@ // The one list of UI features. Adding a feature is a folder under ./features and a line here. export default [ "./features/log/index.js", + "./features/placeholders/index.js", "./features/temp/index.js", ].map((path) => new URL(path, import.meta.url).href); diff --git a/app/ui/shell/features.js b/app/ui/shell/features.js index 4428ada2..791525bc 100644 --- a/app/ui/shell/features.js +++ b/app/ui/shell/features.js @@ -1,29 +1,31 @@ -// Loads UI features. A feature is a folder with an index.js hub: -// export default { id, mount, needs?, setup({ host }) -> teardown? } +// Loads UI features. A feature file's default export is one hub, or several: +// { id, mount, needs?, setup({ host }) -> teardown? } // `mount` names a region (see mounts.js). A feature that cannot load, names a region nobody offers, // needs a service the app lacks, or throws in setup is hidden -- nothing of it reaches the screen. import { hostFor, offered } from "./mounts.js"; +function mountOne(hub, has) { + const host = hostFor(hub.mount); + if (!host) throw new Error(`no region offers "${hub.mount}" (offered: ${offered().join(", ")})`); + const lacking = (hub.needs || []).filter((name) => !has(name)); + if (lacking.length) throw new Error(`needs ${lacking.join(", ")}`); + const before = [...host.childNodes]; + try { + return { id: hub.id, teardown: hub.setup({ host }) }; + } catch (err) { // whatever it put on the page goes with it + [...host.childNodes].filter((n) => !before.includes(n)).forEach((n) => n.remove()); + throw err; + } +} + export async function loadFeatures(paths, { load = (path) => import(path), has = () => true } = {}) { const mounted = [], hidden = []; + const hide = (id, err) => { hidden.push({ id, why: err.message }); console.warn(`[FunPack] ${id} hidden: ${err.message}`); }; for (const path of paths) { - let hub; - try { - hub = (await load(path)).default; - const host = hostFor(hub.mount); - if (!host) throw new Error(`no region offers "${hub.mount}" (offered: ${offered().join(", ")})`); - const lacking = (hub.needs || []).filter((name) => !has(name)); - if (lacking.length) throw new Error(`needs ${lacking.join(", ")}`); - const before = [...host.childNodes]; - try { - mounted.push({ id: hub.id, teardown: hub.setup({ host }) }); - } catch (err) { // whatever it put on the page goes with it - [...host.childNodes].filter((n) => !before.includes(n)).forEach((n) => n.remove()); - throw err; - } - } catch (err) { - hidden.push({ id: hub?.id || path, why: err.message }); - console.warn(`[FunPack] ${hub?.id || path} hidden: ${err.message}`); + let hubs; + try { hubs = [].concat((await load(path)).default); } catch (err) { hide(path, err); continue; } + for (const hub of hubs) { + try { mounted.push(mountOne(hub, has)); } catch (err) { hide(hub?.id || path, err); } } } return { mounted, hidden }; diff --git a/app/ui/shell/frame.js b/app/ui/shell/frame.js index 0c8dabbd..36bab4e3 100644 --- a/app/ui/shell/frame.js +++ b/app/ui/shell/frame.js @@ -1,26 +1,31 @@ // 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. Each region offers a mount point; no feature is named here. +// 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 +// (body) | .actions (after the title) | .status (far end) for assets, preview, inspector, timeline import { composer } from "../composer/composer.js"; import { offer } from "./mounts.js"; export function build(root) { - const zone = (title, mount) => { - const body = composer.region.stack({ gap: "sm", fill: true }); - offer(mount, body.node); - return composer.panel.zone({ title, body }); - }; const group = (mount) => { const bar = composer.toolbar.default({}); offer(mount, bar.node); return bar; }; + const zone = (title, mount) => { + const body = composer.region.stack({ gap: "sm", fill: true }); + offer(mount, body.node); + return composer.panel.zone({ title, body, actions: [group(`${mount}.actions`)], status: [group(`${mount}.status`)] }); + }; const row = composer.region.stack({ gap: "none", children: [zone("Assets", "assets"), zone("Preview", "preview"), zone("Properties", "inspector")] }); const main = composer.region.stack({ gap: "none", fill: true, children: [row, zone("Timeline", "timeline")] }); row.node.classList.add("fp-row"); main.node.classList.add("fp-main"); - const header = composer.toolbar.default({ items: [composer.brand.default({}), group("menubar")], trailing: [group("menubar.right")] }); + const header = composer.toolbar.default({ + items: [composer.brand.default({}), group("menubar.mode"), group("menubar.menus")], + trailing: [group("menubar.right")], + }); const frame = composer.frame.app({ header, main }); root.append(frame.node); return frame; diff --git a/app/ui/shell/settings.js b/app/ui/shell/settings.js index 83d94366..5a35a9fc 100644 --- a/app/ui/shell/settings.js +++ b/app/ui/shell/settings.js @@ -46,6 +46,7 @@ export function createSettings({ menubar }) { return current; } - menubar.append(composer.button.sm({ label: "Settings", onClick: () => open() }).node); - return { open, sections: () => [...sections] }; + // Added once the other menus are in, so it sits last as in v4. + const addButton = () => menubar.append(composer.button.sm({ label: "Settings", onClick: () => open() }).node); + return { open, addButton, sections: () => [...sections] }; }