From f28e57fbe33fe4dd053c084104e73aeebdc9e3d3 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:28:56 +0300 Subject: [PATCH] feat(onboarding): first-run wizard for both frontends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shape follows the macOS Setup Assistant: full screen, no chrome, one centred column per step — glyph, headline, one short paragraph, controls — with the primary button in a bar pinned to the bottom so it never moves between steps, and Back as a chevron in the top-left. Type follows Apple's macOS text styles; --font-system resolves to SF Pro on a Mac. Splash (Welcome to FunPack / Begin / Load existing, Return advances) then: theme, name + model family, missing node packs, t2v-or-i2v, models, optional links/shortcuts/splits with Skip for now, ready. Steps a host runs are declared in one table — Easy Gen has no timeline, so it never offers prompt splits. Installing node packs restarts ComfyUI, so the wizard saves its position and resumes there after the reload instead of starting over. Returning users still get the project picker, not the wizard. pipeline_setup gains applyFamily/markHandled so the wizard can drive it without its modal reopening over the finished editor, and no longer assumes the Editor's store — Easy Gen keeps model config on project.models and has no loadModels. Co-Authored-By: Claude Opus 5 --- easy_gen/frontend/app.js | 4 +- easy_gen/frontend/index.html | 2 + movie_editor/frontend/index.html | 1 + movie_editor/frontend/onboarding.js | 588 ++++++++++++++++++++++++ movie_editor/frontend/pipeline_setup.js | 52 ++- movie_editor/frontend/store.js | 6 +- movie_editor/frontend/styles.css | 202 ++++++++ 7 files changed, 846 insertions(+), 9 deletions(-) create mode 100644 movie_editor/frontend/onboarding.js diff --git a/easy_gen/frontend/app.js b/easy_gen/frontend/app.js index 06b52da9..23fd7743 100644 --- a/easy_gen/frontend/app.js +++ b/easy_gen/frontend/app.js @@ -274,7 +274,9 @@ if (lastId) { try { await S.loadProject(lastId); loaded = true; } catch (_) { /* fall through to picker */ } } - if (!loaded) window.ProjectMenu.open({ dismissable: false }); + // First run (or a wizard interrupted by an install restart) gets the setup flow; + // everyone else gets the project picker they already know. + if (!loaded && !window.Onboarding?.maybeOpen()) window.ProjectMenu.open({ dismissable: false }); // After the project is in place, so a run targeting it re-attaches fully (result and // all) instead of falling back to the queue-watch path. Not awaited — it runs for as // long as the generation does. diff --git a/easy_gen/frontend/index.html b/easy_gen/frontend/index.html index 5f718d1e..a7d8ad73 100644 --- a/easy_gen/frontend/index.html +++ b/easy_gen/frontend/index.html @@ -105,6 +105,8 @@ + + diff --git a/movie_editor/frontend/index.html b/movie_editor/frontend/index.html index 78812a8a..84562601 100644 --- a/movie_editor/frontend/index.html +++ b/movie_editor/frontend/index.html @@ -138,6 +138,7 @@ + diff --git a/movie_editor/frontend/onboarding.js b/movie_editor/frontend/onboarding.js new file mode 100644 index 00000000..b06b6f64 --- /dev/null +++ b/movie_editor/frontend/onboarding.js @@ -0,0 +1,588 @@ +// First-run wizard, shared by the Cutting Room and Easy Gen. +// +// Shape is lifted from the macOS Setup Assistant: full screen, no window chrome, one +// vertically centred column per step, a glyph over a large headline over a short +// paragraph, controls in the middle, and a fixed action bar at the bottom whose primary +// button is always the way forward. Back is a chevron in the top-left; there are no step +// dots, because a linear flow that tells you how far you have to go invites you to bail. +// +// The steps a host runs are declared in HOSTS below, not branched on inside each step — +// Easy Gen has no timeline, so prompt splits are not a thing it can offer, and that +// belongs in one table rather than scattered through the screens. +(function () { + const { el, clear } = window.dom; + const S = () => window.Store; + const API = () => window.MovieEditorAPI; + const LS_DONE = "funpack_onboarded"; + + let root = null; // the full-screen overlay + let stage = null; // the animated content column + let idx = 0; + let steps = []; + let ctx = null; // per-run state: project, family, deps, generation type + + const isEasyGen = () => window.FunPackAppName === "Easy Gen"; + + const HOSTS = { + editor: { + product: "Cutting Room", + lead: "Multi-scene video on a real timeline.", + steps: ["theme", "project", "prereqs", "gentype", "models", "extras", "done"], + extras: ["links", "shortcuts", "splits"], + newProject: (name) => S().newProject(name), + }, + easy: { + product: "Easy Gen", + lead: "One prompt, one button, one video.", + steps: ["theme", "project", "prereqs", "gentype", "models", "extras", "done"], + extras: ["links", "shortcuts"], + newProject: (name) => S().createProject(name), + }, + }; + const host = () => (isEasyGen() ? HOSTS.easy : HOSTS.editor); + + // ── chrome ──────────────────────────────────────────────────────────────── + + function glyph(paths, cls) { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", "0 0 48 48"); + svg.setAttribute("class", "oo-glyph" + (cls ? " " + cls : "")); + svg.setAttribute("aria-hidden", "true"); + svg.innerHTML = paths; + return svg; + } + + const GLYPHS = { + theme: '' + + '', + project: '' + + '', + prereqs: '' + + '' + + '', + gentype: '' + + '', + models: '' + + '', + extras: '' + + '', + done: '' + + '', + }; + + function head(icon, title, sub) { + const h = el("div", "oo-head"); + if (icon) h.append(glyph(GLYPHS[icon], "oo-glyph-" + icon)); + h.append(el("h1", "oo-title", title)); + if (sub) h.append(el("p", "oo-sub", sub)); + return h; + } + + // The action bar is fixed to the bottom of the screen on every step, so the primary + // button never moves as the content above it changes height. + function actions({ primary, onPrimary, secondary, onSecondary, disabled }) { + const bar = el("div", "oo-actions"); + const btn = el("button", "oo-btn oo-btn-primary" + (disabled ? " disabled" : ""), primary || "Continue"); + btn.type = "button"; + if (!disabled) btn.onclick = onPrimary || next; + bar.append(btn); + if (secondary) { + const s = el("button", "oo-btn oo-btn-quiet", secondary); + s.type = "button"; + s.onclick = onSecondary || next; + bar.append(s); + } + return bar; + } + + function busy(text) { + const b = el("div", "oo-busy"); + b.append(el("span", "oo-spinner")); + b.append(el("span", null, text)); + return b; + } + + // ── step registry ───────────────────────────────────────────────────────── + // Each returns a DOM node; `enter` may be async and runs before the render. + + const STEPS = { + // ── colour scheme ─────────────────────────────────────────────────────── + theme: { + render() { + const box = el("div", "oo-step"); + box.append(head("theme", "Choose your look", + "You can change this any time in Settings ▸ Appearance.")); + const { grid } = window.AppearanceSettings.buildPicker(); + grid.classList.add("oo-theme-cards"); + box.append(grid); + box.append(actions({})); + return box; + }, + }, + + // ── project name + model family ───────────────────────────────────────── + project: { + async enter() { + // Families come from the backend so the wizard can't drift from what the + // pipeline actually supports, including the "not released yet" flags. + try { ctx.deps = await API().pipelineDeps(null); } catch (_) { ctx.deps = null; } + }, + render() { + const box = el("div", "oo-step oo-step-wide"); + box.append(head("project", "Name it, and pick a model", + "The model decides which nodes and which files this project needs, so it is asked first.")); + + const nameRow = el("div", "oo-field"); + nameRow.append(el("label", "oo-label", "Project name")); + const input = el("input", "oo-input"); + input.type = "text"; + input.value = ctx.name || (isEasyGen() ? "Untitled" : "Untitled montage"); + input.placeholder = "Project name"; + input.oninput = () => { ctx.name = input.value; }; + nameRow.append(input); + box.append(nameRow); + + const families = (ctx.deps?.families || []).slice(); + const cards = el("div", "oo-cards"); + const paint = () => { + [...cards.children].forEach((c) => c.classList.toggle("active", c.dataset.key === ctx.family)); + bar.replaceWith(bar = mkBar()); + }; + const card = (key, title, note, sub) => { + const c = el("button", "oo-card"); + c.type = "button"; + c.dataset.key = key; + const t = el("div", "oo-card-title", title); + if (note) t.append(el("span", "oo-card-note", note)); + c.append(t); + c.append(el("div", "oo-card-sub", sub || "")); + c.onclick = () => { ctx.family = key; paint(); }; + return c; + }; + families.forEach((f) => cards.append( + card(f.key, f.label, f.released ? "" : "not released yet", f.summary))); + cards.append(card("__own__", "My own pipeline", "", + "Disable the built-in graph. Generation runs only the nodes you wire yourself in Models.")); + box.append(cards); + + let bar = mkBar(); + box.append(bar); + function mkBar() { + return actions({ + primary: "Continue", + disabled: !ctx.family, + onPrimary: createProject, + }); + } + if (ctx.family) paint(); + setTimeout(() => input.focus(), 60); + return box; + }, + }, + + // ── missing node packs ────────────────────────────────────────────────── + prereqs: { + async enter() { + ctx.deps = null; + try { ctx.deps = await API().pipelineDeps(ctx.project?.id || null); } catch (_) {} + }, + // Nothing to install and nothing to warn about — don't show a screen that only + // says "all good" and asks you to press Continue. + skip() { + if (ctx.family === "__own__") return true; + const d = ctx.deps; + if (!d) return true; + const r = d.readiness || {}; + return !d.needs_setup && !(d.missing_packs || []).length && !r.note && r.released !== false; + }, + render() { + const d = ctx.deps || {}; + const r = d.readiness || {}; + const packs = d.missing_packs || []; + const box = el("div", "oo-step oo-step-wide"); + box.append(head("prereqs", packs.length ? "A few nodes are missing" : "About this model", + packs.length + ? "This model's pipeline needs custom node packs that aren't in your ComfyUI yet." + : "Worth knowing before you download anything.")); + + const panel = el("div", "oo-panel"); + if (r.note) panel.append(el("p", "oo-note", r.note)); + if (r.source_url) { + const a = el("a", "oo-link", r.source_title || r.source_url); + a.href = r.source_url; a.target = "_blank"; a.rel = "noopener"; + panel.append(a); + } + if (packs.length) { + panel.append(el("div", "oo-list-label", "Node packs to install")); + const ul = el("ul", "oo-list"); + packs.forEach((p) => { + const li = el("li", null, p.title); + if (p.missing_classes?.length) li.append(el("span", "oo-list-sub", p.missing_classes.join(", "))); + ul.append(li); + }); + panel.append(ul); + } + if ((r.models || []).length) { + panel.append(el("div", "oo-list-label", + r.released ? "Model files this pipeline uses" : "Model files to download once published")); + const ul = el("ul", "oo-list"); + r.models.forEach((m) => { + const li = el("li", null, m.label); + li.append(el("span", "oo-list-sub", "models/" + m.folder + (m.hint ? " — " + m.hint : ""))); + ul.append(li); + }); + panel.append(ul); + } + if (panel.children.length) box.append(panel); + + // Installing restarts ComfyUI, which reloads this page — the wizard resumes from + // its saved position rather than starting over. See restore(). + let primary = "Continue", onPrimary = next, secondary = null, onSecondary = null; + if (packs.length && d.needs_manager_install) { + primary = "Install ComfyUI-Manager"; + onPrimary = () => { save(); window.PipelineSetup.installManager(); }; + secondary = "Skip for now"; + } else if (packs.length && d.needs_manager_restart) { + primary = "Restart ComfyUI"; + onPrimary = () => { save(); window.PipelineSetup.restartComfy(); }; + secondary = "Skip for now"; + } else if (packs.length && d.manager_available) { + primary = "Install missing nodes"; + onPrimary = () => { save(); window.PipelineSetup.installPacks(d); }; + secondary = "Skip for now"; + } + box.append(actions({ primary, onPrimary, secondary, onSecondary })); + return box; + }, + }, + + // ── text-to-video vs image-to-video ───────────────────────────────────── + gentype: { + render() { + const box = el("div", "oo-step oo-step-wide"); + box.append(head("gentype", "How do you want to start a shot?", + "This only sets what the editor asks you for. You can use both in the same project.")); + + const cards = el("div", "oo-cards oo-cards-2"); + let bar; + const paint = () => { + [...cards.children].forEach((c) => c.classList.toggle("active", c.dataset.key === ctx.genType)); + const nb = mkBar(); bar.replaceWith(nb); bar = nb; + }; + const card = (key, title, sub) => { + const c = el("button", "oo-card"); + c.type = "button"; c.dataset.key = key; + c.append(el("div", "oo-card-title", title)); + c.append(el("div", "oo-card-sub", sub)); + c.onclick = () => { ctx.genType = key; paint(); }; + return c; + }; + cards.append( + card("t2v", "From a prompt", + "Text to video. No image is needed anywhere — anchors and guides stay out of your way."), + card("i2v", "From an image", + "Image to video. Each shot starts from a still, and scenes can carry the previous frame forward."), + ); + box.append(cards); + bar = mkBar(); + box.append(bar); + function mkBar() { + return actions({ disabled: !ctx.genType, onPrimary: saveGenType }); + } + if (ctx.genType) paint(); + return box; + }, + }, + + // ── model files ───────────────────────────────────────────────────────── + models: { + async enter() { + try { await S().loadModels?.(); } catch (_) {} + }, + render() { + const box = el("div", "oo-step"); + const own = ctx.family === "__own__"; + box.append(head("models", own ? "Wire your pipeline" : "Point it at your models", + own + ? "Nothing is generated until a final IMAGE reaches the global video output." + : "Pick the checkpoint, text encoder and VAE files this model uses. Models opens over this screen.")); + + const p = el("p", "oo-body"); + p.textContent = ctx.genType === "t2v" + ? "Set for text-to-video, so no image loader is required." + : "Set for image-to-video, so a still is expected at the start of a shot."; + box.append(p); + + box.append(actions({ + primary: "Open Models…", + onPrimary: () => window.ModelsModal.open(), + secondary: "Continue", + onSecondary: next, + })); + return box; + }, + }, + + // ── optional extras ───────────────────────────────────────────────────── + extras: { + render() { + const wanted = host().extras; + const box = el("div", "oo-step oo-step-wide"); + box.append(head("extras", "Optional, and easy to add later", + "None of this is needed to generate. Skip it and come back when you want it.")); + + const ROWS = { + links: { + title: "Pipeline links", + sub: "Drive several node inputs from one control, or tie an input to a project value.", + open: () => window.SettingsWindow.open("models"), + }, + shortcuts: { + title: "Prompt shortcuts", + sub: "Short triggers that expand into full phrases while you type.", + open: () => window.SettingsWindow.open("shortcuts"), + }, + splits: { + title: "Prompt splits", + sub: "Markers that cut one written prompt into separate scenes.", + open: () => window.Composer?.open?.(), + }, + }; + const list = el("div", "oo-rows"); + wanted.forEach((k) => { + const r = ROWS[k]; + if (!r) return; + const row = el("div", "oo-row"); + const main = el("div", "oo-row-main"); + main.append(el("div", "oo-row-title", r.title)); + main.append(el("div", "oo-row-sub", r.sub)); + row.append(main); + const b = el("button", "oo-btn oo-btn-small", "Set up"); + b.type = "button"; + b.onclick = () => { save(); close(); r.open(); }; + row.append(b); + list.append(row); + }); + box.append(list); + box.append(actions({ primary: "Skip for now", onPrimary: next })); + return box; + }, + }, + + // ── done ──────────────────────────────────────────────────────────────── + done: { + render() { + const box = el("div", "oo-step"); + box.append(head("done", "You're ready to generate", + isEasyGen() + ? "Write a prompt at the bottom and press Generate." + : "Write in the Composer, then press Generate in the timeline header.")); + box.append(actions({ primary: "Let's go", onPrimary: finish })); + return box; + }, + }, + }; + + // ── the splash, which is not a step: it has no Back and no progress ─────── + + function splash() { + const box = el("div", "oo-splash"); + const mark = el("div", "oo-splash-mark", "◉"); + box.append(mark); + box.append(el("h1", "oo-splash-title", "Welcome to FunPack")); + box.append(el("p", "oo-splash-lead", host().lead)); + + const begin = el("button", "oo-btn oo-btn-primary oo-btn-hero", "Begin"); + begin.type = "button"; + begin.onclick = () => { idx = 0; renderStep(); }; + box.append(begin); + + const load = el("button", "oo-btn oo-btn-quiet", "Load an existing project"); + load.type = "button"; + load.onclick = openExisting; + box.append(load); + + box.append(window.FunPackGit.maintenanceRow("oo-maint")); + + // Return advances, the way the Setup Assistant's splash does. + const onKey = (e) => { + if (e.key === "Enter" && root && root.querySelector(".oo-splash")) { + e.preventDefault(); + begin.click(); + } + }; + document.addEventListener("keydown", onKey); + box._cleanup = () => document.removeEventListener("keydown", onKey); + setTimeout(() => begin.focus(), 80); + return box; + } + + // ── actions ─────────────────────────────────────────────────────────────── + + async function createProject() { + const name = (ctx.name || "").trim() || (isEasyGen() ? "Untitled" : "Untitled montage"); + const bar = root.querySelector(".oo-actions"); + const again = !!ctx.project; // stepped Back to this screen and pressed Continue again + if (bar) clear(bar).append(busy(again ? "Saving…" : "Creating project…")); + try { + if (again) { + // Don't leave a trail of abandoned projects behind a user who changes their mind. + if (S().get().project?.name !== name) S().patchProject({ name }); + } else { + await host().newProject(name); + } + ctx.project = S().get().project; + if (ctx.family === "__own__") await window.PipelineSetup.useOwnPipeline(); + else await window.PipelineSetup.applyFamily(ctx.family); + } catch (e) { + if (bar) { clear(bar); bar.append(el("div", "oo-error", "Could not create the project: " + (e.message || e))); } + return; + } + next(); + } + + function saveGenType() { + try { S().setEditorSetting?.("generationType", ctx.genType); } catch (_) {} + next(); + } + + function openExisting() { + close(); + if (isEasyGen()) window.ProjectMenu.open({ dismissable: true }); + else window.WelcomePage.open(); + } + + // ── navigation ──────────────────────────────────────────────────────────── + + async function go(delta) { + let i = idx + delta; + while (i >= 0 && i < steps.length) { + const st = STEPS[steps[i]]; + if (st.enter) await st.enter(); + if (!st.skip || !st.skip()) break; + i += delta; // stepping over a skipped screen keeps Back symmetrical + } + if (i >= steps.length) { finish(); return; } + idx = Math.max(0, i); + renderStep(); + } + + const next = () => go(1); + const back = () => go(-1); + + function renderStep() { + const name = steps[idx]; + const st = STEPS[name]; + paint(st.render(), name); + save(); + } + + function paint(node, name) { + if (stage?._child?._cleanup) stage._child._cleanup(); + clear(stage); + root.querySelector(".oo-actions")?.remove(); + stage.dataset.step = name || "splash"; + stage._child = node; + stage.append(node); + // The action bar is position:fixed, and the step's entry animation leaves a resolved + // (identity) transform behind, which would make the step a containing block and trap + // the bar inside it. Steps still declare their own bar; it just gets hoisted out. + const bar = node.querySelector(":scope > .oo-actions"); + if (bar) root.append(bar); + root.classList.toggle("oo-has-actions", !!bar); + // Re-trigger the entry animation on every step, not just the first. + stage.classList.remove("oo-in"); + void stage.offsetWidth; + stage.classList.add("oo-in"); + const backBtn = root.querySelector(".oo-back"); + backBtn.hidden = !name || idx <= 0; + } + + // ── persistence: an install restarts ComfyUI and reloads the page ───────── + + function save() { + try { + localStorage.setItem("funpack_wizard", JSON.stringify({ + idx, ctx: { name: ctx.name, family: ctx.family, genType: ctx.genType, projectId: ctx.project?.id || null }, + })); + } catch (_) {} + } + + function restore() { + try { + const raw = localStorage.getItem("funpack_wizard"); + if (!raw) return null; + return JSON.parse(raw); + } catch (_) { return null; } + } + + function clearSaved() { + try { localStorage.removeItem("funpack_wizard"); } catch (_) {} + } + + function finish() { + try { localStorage.setItem(LS_DONE, "1"); } catch (_) {} + clearSaved(); + // The wizard IS the pipeline setup — without this its modal reopens over the editor + // and asks for the family and the node packs all over again. + window.PipelineSetup?.markHandled?.(); + close(); + } + + // ── lifecycle ───────────────────────────────────────────────────────────── + + function close() { + if (stage?._child?._cleanup) stage._child._cleanup(); + root?.remove(); + root = null; + stage = null; + } + + function open({ resume } = {}) { + close(); + steps = host().steps; + // project stays null until the wizard creates one: seeding it from whatever happens + // to be open would make the project step rename that project instead of making a new one. + ctx = { name: "", family: null, genType: null, project: null, deps: null }; + + root = el("div", "oo-root"); + const backBtn = el("button", "oo-back", "‹"); + backBtn.type = "button"; + backBtn.title = "Back"; + backBtn.hidden = true; + backBtn.onclick = back; + root.append(backBtn); + + stage = el("div", "oo-stage"); + root.append(stage); + document.body.append(root); + + const saved = resume ? restore() : null; + if (saved) { + Object.assign(ctx, saved.ctx || {}); + idx = Math.min(Math.max(saved.idx || 0, 0), steps.length - 1); + // The project survived the restart; the in-memory handle did not. + const pid = saved.ctx?.projectId; + const load = pid ? S().loadProject(pid).catch(() => {}) : Promise.resolve(); + load.then(() => { ctx.project = S().get().project; renderStep(); }); + return; + } + idx = 0; + paint(splash(), null); + } + + function done() { + try { return localStorage.getItem(LS_DONE) === "1"; } catch (_) { return false; } + } + + // Called by each host's boot instead of its old welcome screen. + function maybeOpen() { + if (window.__FUNPACK_TOUR__) return false; + if (restore()) { open({ resume: true }); return true; } + if (done()) return false; + open(); + return true; + } + + window.Onboarding = { open, close, maybeOpen, done, isOpen: () => !!root }; +})(); diff --git a/movie_editor/frontend/pipeline_setup.js b/movie_editor/frontend/pipeline_setup.js index c46ee305..aa0bf890 100644 --- a/movie_editor/frontend/pipeline_setup.js +++ b/movie_editor/frontend/pipeline_setup.js @@ -31,12 +31,23 @@ return !m.disable_core; } + // The Editor keeps the live model config at state.models and reloads it from the + // server; Easy Gen has neither, and reads it off state.project.models (see + // PipelineCaps.models). Write back to whichever this host actually has. + async function refreshModels(saved) { + if (typeof S.loadModels === "function") { await S.loadModels(); return; } + const p = S.get().project; + if (p) p.models = JSON.parse(JSON.stringify(saved)); + if (typeof S.notify === "function") S.notify(); + else S.set?.({}); // Easy Gen's store notifies through set() + } + async function useOwnPipeline() { if (!S.get().project) return; - const models = JSON.parse(JSON.stringify(S.get().models || { slots: [] })); + const models = JSON.parse(JSON.stringify(S.get().models || S.get().project?.models || { slots: [] })); models.disable_core = true; await API.saveModels(S.get().project.id, models); - await S.loadModels(); + await refreshModels(models); setDismissed(); closeModal(); } @@ -183,12 +194,16 @@ // The family decides which nodes and which model files the project needs, so it is the // FIRST question — asking it after an install would have installed the wrong thing. - async function chooseFamily(key) { - if (!S.get().project) return; - const models = JSON.parse(JSON.stringify(S.get().models || { slots: [] })); + // Persist the family and bring the project onto its frame geometry. Split out from + // chooseFamily so the onboarding wizard can pick a family without the modal reopening + // itself on top of the wizard. + async function applyFamily(key) { + if (!S.get().project) return ""; + geometryNote = ""; // otherwise a previous pick's note is returned again + const models = JSON.parse(JSON.stringify(S.get().models || S.get().project?.models || { slots: [] })); models.model_family = key; await API.saveModels(S.get().project.id, models); - await S.loadModels(); + await refreshModels(models); // Frame geometry belongs to the model, not to taste: the new family may generate on a // different frame grid (LTX 8k+1 / H3 17k+5) and at a fixed rate (H3 is always 24 fps). // Bring the project onto them now, while the user is looking at the choice that caused @@ -211,6 +226,12 @@ + " (grid " + grid.label + ")."; } } + return geometryNote; + } + + async function chooseFamily(key) { + if (!S.get().project) return; + await applyFamily(key); let deps; try { deps = await API.pipelineDeps(S.get().project?.id); } catch (_) { closeModal(); return; } if (!deps?.needs_setup) { closeModal(); return; } @@ -365,8 +386,16 @@ document.body.append(overlay); } + // The onboarding wizard asks the same questions in its own chrome, so this modal must + // not open behind it — or, worse, on top of the editor the moment the wizard finishes. + function markHandled() { + setDismissed(); + closeModal(); + } + async function maybePrompt() { if (window.__FUNPACK_TOUR__) return; + if (window.Onboarding?.isOpen?.()) return; if (dismissed()) return; const st = S.get(); if (!st.project || !builtInPipelineActive(st)) return; @@ -385,5 +414,14 @@ openModal(deps); } - window.PipelineSetup = { maybePrompt, open, close: closeModal }; + window.PipelineSetup = { + maybePrompt, open, close: closeModal, + // Exposed for the onboarding wizard, which renders the prerequisite step in its + // own full-screen chrome but must not re-implement the install/poll/restart + // machinery below. + applyFamily, useOwnPipeline, markHandled, + installPacks: startInstall, + installManager: startManagerInstall, + restartComfy: restartComfyOnly, + }; })(); diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 8c140870..eddf12f2 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -4303,7 +4303,11 @@ } } catch (_) { /* queue unreachable — boot normally */ } notify(); - if (!resumed && window.WelcomePage) window.WelcomePage.open(); + // First run (or a wizard interrupted by an install restart) gets the setup flow; + // everyone else gets the project picker they already know. + if (resumed) return; + if (window.Onboarding?.maybeOpen()) return; + if (window.WelcomePage) window.WelcomePage.open(); } window.Store = { diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 10d423e2..051e5299 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -2132,3 +2132,205 @@ body.tour-mode { overflow: hidden; } .maint-btn-label { font-weight: 600; font-size: 13px; } .maint-btn-hint { font-size: 11px; color: var(--faint); line-height: 1.35; } @media (max-width: 620px) { .maint-row { grid-template-columns: 1fr; } } + +/* ════════════════════════════════════════════════════════════════════════ + ONBOARDING WIZARD — macOS Setup Assistant shape + Full screen, no chrome. One centred column per step: glyph, headline, + short paragraph, controls, and an action bar pinned to the bottom so the + primary button never moves between steps. + + Type follows Apple's macOS text styles (HIG "Specifications"): Body 13/16, + Callout 12/15, Title 3 15/20, Title 1 22/26, Large Title 26/32 — with a + display scale above them, because Setup Assistant is a full-screen context + and not an inspector. --font-system resolves to SF Pro on a Mac and falls + back to the platform UI face everywhere else. + ════════════════════════════════════════════════════════════════════════ */ +:root { + --font-system: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", + system-ui, "Helvetica Neue", sans-serif; + --oo-col: 560px; /* the centred content column */ + --oo-col-wide: 760px; /* steps that hold cards or lists */ +} + +.oo-root { + position: fixed; inset: 0; z-index: 800; + display: flex; align-items: center; justify-content: center; + padding: 72px 32px; + background: + radial-gradient(1100px 700px at 50% -15%, color-mix(in srgb, var(--accent) 9%, transparent) 0%, transparent 62%), + var(--ink-0); + font-family: var(--font-system); + -webkit-font-smoothing: antialiased; + overflow-y: auto; +} + +/* Back is a chevron in the top-left corner, the way the Setup Assistant does it — + never a button in the action bar, where it would compete with Continue. */ +.oo-back { + position: absolute; top: 22px; left: 26px; z-index: 2; + width: 32px; height: 32px; padding: 0 0 3px; + border: 0; border-radius: 50%; background: transparent; + color: var(--muted); font-size: 26px; line-height: 1; cursor: pointer; + transition: background .12s, color .12s; +} +.oo-back:hover { background: var(--ink-2); color: var(--text); } +.oo-back[hidden] { display: none; } + +/* Only steps that carry an action bar reserve room for it; the splash has none. */ +.oo-root.oo-has-actions { padding-bottom: 140px; } +.oo-stage { width: 100%; display: flex; justify-content: center; } +.oo-stage.oo-in .oo-step, .oo-stage.oo-in .oo-splash { animation: oo-rise .34s cubic-bezier(.22,.61,.36,1) both; } +@keyframes oo-rise { from { opacity: 0; transform: translateY(10px); } } + +.oo-step { + width: 100%; max-width: var(--oo-col); + display: flex; flex-direction: column; align-items: center; text-align: center; +} +.oo-step-wide { max-width: var(--oo-col-wide); } + +/* ── head: glyph, headline, one short paragraph ─────────────────────────── */ +.oo-head { display: flex; flex-direction: column; align-items: center; margin-bottom: 34px; } +.oo-glyph { width: 76px; height: 76px; color: var(--accent); margin-bottom: 22px; } +.oo-title { + margin: 0; font-size: 34px; line-height: 40px; font-weight: 700; + letter-spacing: -.4px; color: var(--text); +} +.oo-sub { + margin: 12px 0 0; max-width: 46ch; + font-size: 15px; line-height: 21px; color: var(--muted); +} +.oo-body { margin: 0 0 18px; font-size: 13px; line-height: 16px; color: var(--muted); } + +/* ── action bar, pinned ─────────────────────────────────────────────────── */ +.oo-actions { + position: fixed; left: 0; right: 0; bottom: 0; z-index: 3; + display: flex; flex-direction: column; align-items: center; gap: 6px; + padding: 22px 32px 30px; + background: linear-gradient(to top, var(--ink-0) 62%, transparent); + pointer-events: none; +} +.oo-actions > * { pointer-events: auto; } + +.oo-btn { + font-family: var(--font-system); font-size: 13px; line-height: 16px; font-weight: 500; + border-radius: 8px; border: 1px solid transparent; cursor: pointer; + padding: 8px 18px; background: transparent; color: var(--text); + transition: background .12s, border-color .12s, opacity .12s, transform .06s; +} +.oo-btn:active:not(.disabled) { transform: scale(.985); } +.oo-btn-primary { + min-width: 220px; padding: 11px 26px; + font-size: 14px; font-weight: 600; + background: var(--accent); border-color: var(--accent); color: var(--on-accent); + box-shadow: 0 1px 3px color-mix(in srgb, var(--shadow-ink) 30%, transparent); +} +.oo-btn-primary:hover:not(.disabled) { background: var(--accent-hi); border-color: var(--accent-hi); } +.oo-btn-primary.disabled { opacity: .38; cursor: default; } +.oo-btn-hero { min-width: 260px; padding: 13px 30px; font-size: 15px; margin-top: 8px; } +.oo-btn-quiet { color: var(--accent); background: transparent; } +.oo-btn-quiet:hover { background: var(--accent-soft); } +.oo-btn-small { padding: 5px 13px; border-color: var(--line); background: var(--ink-1); } +.oo-btn-small:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--line)); } + +.oo-busy { display: flex; align-items: center; gap: 10px; font-size: 13px; color: var(--muted); } +.oo-spinner { + width: 15px; height: 15px; border-radius: 50%; flex: none; + border: 2px solid var(--line); border-top-color: var(--accent); + animation: oo-spin .8s linear infinite; +} +@keyframes oo-spin { to { transform: rotate(360deg); } } +.oo-error { font-size: 13px; line-height: 17px; color: var(--danger); max-width: 46ch; text-align: center; } + +/* ── splash ─────────────────────────────────────────────────────────────── */ +.oo-splash { + width: 100%; max-width: 620px; + display: flex; flex-direction: column; align-items: center; text-align: center; +} +.oo-splash-mark { + font-size: 34px; color: var(--accent); margin-bottom: 26px; + filter: drop-shadow(0 0 22px var(--accent-soft)); +} +.oo-splash-title { + margin: 0; font-size: 56px; line-height: 62px; font-weight: 700; + letter-spacing: -1.4px; color: var(--text); +} +.oo-splash-lead { margin: 14px 0 40px; font-size: 17px; line-height: 22px; color: var(--muted); } +.oo-splash .oo-btn-quiet { margin-top: 4px; } +.oo-maint { margin-top: 48px; width: 100%; max-width: var(--oo-col-wide); } + +/* ── fields ─────────────────────────────────────────────────────────────── */ +.oo-field { width: 100%; max-width: 420px; text-align: left; margin-bottom: 30px; } +.oo-label { + display: block; margin-bottom: 6px; + font-size: 12px; line-height: 15px; font-weight: 500; color: var(--muted); +} +.oo-input { + width: 100%; padding: 9px 12px; + font-family: var(--font-system); font-size: 14px; line-height: 18px; color: var(--text); + background: var(--ink-1); border: 1px solid var(--line); border-radius: 8px; +} +.oo-input:focus { + outline: none; border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +/* ── choice cards ───────────────────────────────────────────────────────── */ +.oo-cards { + width: 100%; display: grid; gap: 12px; margin-bottom: 8px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} +.oo-cards-2 { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); } +.oo-card { + display: flex; flex-direction: column; gap: 6px; text-align: left; + padding: 16px 17px; border-radius: 12px; + border: 1px solid var(--line); background: var(--ink-1); + font-family: var(--font-system); cursor: pointer; + transition: border-color .12s, box-shadow .12s, transform .08s, background .12s; +} +.oo-card:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--line)); transform: translateY(-1px); } +.oo-card.active { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent), 0 10px 26px -18px var(--shadow-ink); + background: color-mix(in srgb, var(--accent) 5%, var(--ink-1)); +} +.oo-card-title { font-size: 15px; line-height: 20px; font-weight: 600; color: var(--text); } +.oo-card-note { + margin-left: 8px; padding: 1px 7px; border-radius: 20px; vertical-align: 2px; + font-size: 10px; line-height: 15px; font-weight: 500; + color: var(--warn); background: color-mix(in srgb, var(--warn) 14%, transparent); +} +.oo-card-sub { font-size: 12px; line-height: 16px; color: var(--muted); } + +.oo-theme-cards { width: 100%; margin-bottom: 8px; } + +/* ── panels and lists ───────────────────────────────────────────────────── */ +.oo-panel { + width: 100%; text-align: left; padding: 18px 20px; margin-bottom: 8px; + border: 1px solid var(--line); border-radius: 12px; background: var(--ink-1); +} +.oo-note { margin: 0 0 12px; font-size: 13px; line-height: 18px; color: var(--text); } +.oo-link { font-size: 12px; line-height: 16px; color: var(--accent); } +.oo-list-label { + margin: 16px 0 7px; font-size: 11px; line-height: 14px; font-weight: 600; + letter-spacing: .4px; text-transform: uppercase; color: var(--faint); +} +.oo-panel > .oo-list-label:first-child { margin-top: 0; } +.oo-list { margin: 0; padding-left: 18px; } +.oo-list li { font-size: 13px; line-height: 18px; color: var(--text); margin-bottom: 5px; } +.oo-list-sub { display: block; font-size: 11px; line-height: 15px; color: var(--faint); font-family: var(--font-mono); } + +.oo-rows { width: 100%; display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px; } +.oo-row { + display: flex; align-items: center; gap: 16px; text-align: left; + padding: 13px 16px; border: 1px solid var(--line); border-radius: 12px; background: var(--ink-1); +} +.oo-row-main { flex: 1; min-width: 0; } +.oo-row-title { font-size: 13.5px; line-height: 18px; font-weight: 600; color: var(--text); } +.oo-row-sub { font-size: 12px; line-height: 16px; color: var(--muted); margin-top: 2px; } + +@media (max-width: 700px) { + .oo-root { padding: 60px 20px 128px; } + .oo-splash-title { font-size: 40px; line-height: 46px; letter-spacing: -.8px; } + .oo-title { font-size: 27px; line-height: 33px; } + .oo-cards { grid-template-columns: 1fr; } +}