feat(onboarding): first-run wizard for both frontends

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 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-08-12 14:28:56 +03:00
co-authored by Claude Opus 5
parent c5b2e6e48f
commit f28e57fbe3
7 changed files with 846 additions and 9 deletions
+3 -1
View File
@@ -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.
+2
View File
@@ -105,6 +105,8 @@
<script src="easy_shortcuts_settings.js"></script>
<script src="autocomplete.js"></script>
<script src="project_menu.js"></script>
<script src="pipeline_setup.js"></script>
<script src="onboarding.js"></script>
<script src="easy_gallery.js"></script>
<script src="easy_variables.js"></script>
<script src="log.js"></script>
+1
View File
@@ -138,6 +138,7 @@
<script src="slot_picker.js"></script>
<script src="welcome.js"></script>
<script src="pipeline_setup.js"></script>
<script src="onboarding.js"></script>
<script src="inspector.js"></script>
<script src="rating_picker.js"></script>
<script src="waveform.js"></script>
+588
View File
@@ -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: '<circle cx="24" cy="24" r="15" fill="none" stroke="currentColor" stroke-width="2.4"/>'
+ '<path d="M24 9a15 15 0 0 0 0 30z" fill="currentColor"/>',
project: '<rect x="7" y="11" width="34" height="26" rx="4" fill="none" stroke="currentColor" stroke-width="2.4"/>'
+ '<path d="M7 19h34" stroke="currentColor" stroke-width="2.4"/><circle cx="13" cy="15" r="1.6" fill="currentColor"/>',
prereqs: '<path d="M24 6v22" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"/>'
+ '<path d="M15 21l9 9 9-9" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>'
+ '<path d="M9 36h30" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"/>',
gentype: '<rect x="5" y="12" width="26" height="24" rx="4" fill="none" stroke="currentColor" stroke-width="2.4"/>'
+ '<path d="M31 21l12-7v20l-12-7z" fill="currentColor"/>',
models: '<path d="M24 5 42 15v18L24 43 6 33V15z" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linejoin="round"/>'
+ '<path d="M6 15l18 10 18-10M24 25v18" fill="none" stroke="currentColor" stroke-width="2.4"/>',
extras: '<circle cx="24" cy="24" r="5" fill="currentColor"/>'
+ '<path d="M24 4v8M24 36v8M4 24h8M36 24h8M10 10l6 6M32 32l6 6M38 10l-6 6M16 32l-6 6" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/>',
done: '<circle cx="24" cy="24" r="17" fill="none" stroke="currentColor" stroke-width="2.4"/>'
+ '<path d="M15 24.5l6.5 6.5L33 19" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>',
};
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 };
})();
+45 -7
View File
@@ -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,
};
})();
+5 -1
View File
@@ -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 = {
+202
View File
@@ -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; }
}