diff --git a/captutor/README.md b/captutor/README.md index 78479a07b1..03fdb81251 100644 --- a/captutor/README.md +++ b/captutor/README.md @@ -98,6 +98,12 @@ returns ElevenLabs' per-character alignment, so every line's exact duration — every word's exact position inside it — is known *before* the camera rolls. The UI then performs to that clock. +New screenplays set `actionCuePolicy: "required"`, declare each beat's spoken +`cues`, and call `await nextCue()` before its visible action. Captutor validates +every phrase before voice synthesis, blocks an action that starts before its +first cue, and fails a take with unused cues. Older screenplays remain compatible +while they are migrated. + Two consequences worth knowing: - **Captions are free and exact.** No whisper pass, no forced aligner, and none diff --git a/captutor/bin/storyboard-receipt.mjs b/captutor/bin/storyboard-receipt.mjs index b5045019f6..5e7ed4f945 100755 --- a/captutor/bin/storyboard-receipt.mjs +++ b/captutor/bin/storyboard-receipt.mjs @@ -143,9 +143,13 @@ const validations = [ ["Delivery geometry", videoStream?.width === expected[0] && videoStream?.height === expected[1], `${videoStream?.width}×${videoStream?.height}; expected ${expected.join("×")}`], ...(acceptance.minimumDurationSec ? [[ - "Introduction length", duration >= acceptance.minimumDurationSec, + "Minimum length", duration >= acceptance.minimumDurationSec, `${duration.toFixed(2)} s; minimum ${acceptance.minimumDurationSec} s`, ]] : []), + ...(acceptance.maximumDurationSec ? [[ + "Maximum length", duration <= acceptance.maximumDurationSec, + `${duration.toFixed(2)} s; maximum ${acceptance.maximumDurationSec} s`, + ]] : []), ...(acceptance.requireOpeningCard ? [[ "Opening title card", Boolean(opening?.result?.filmed), opening ? `${opening.result?.durationMs || opening.durationSec * 1000} ms; filmed=${Boolean(opening.result?.filmed)}` : "missing opening card", diff --git a/captutor/captutor.mjs b/captutor/captutor.mjs index b54359a568..66ce721cf1 100644 --- a/captutor/captutor.mjs +++ b/captutor/captutor.mjs @@ -37,6 +37,9 @@ import { dirname, join, resolve, basename } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { narrate } from "./lib/narrate.mjs"; +import { + createBeatCue, createBeatCuePlan, validateActionCuePlan, +} from "./lib/cue-timing.mjs"; import { attach, BrowserCrashError } from "./lib/cdp.mjs"; import { clickOn, dillydallyAtPoint, dragBetween, pointAt, startNativeCursor, @@ -277,6 +280,8 @@ function say(value, locale) { } async function cmdNarrate(sp, workDir, locale) { + // Validate action-to-word choreography before synthesis can spend money. + validateActionCuePlan(sp, { locale }); console.log(`\n♪ narrating ${sp.beats.length} beats (${sp.voice || "jeffrey"} · ${LANGUAGES[locale]?.native || locale})`); const localized = sp.beats.map((b) => ({ ...b, say: say(b.say, locale) })); // The voice cache is keyed by LOCALE, not by format. The narration for a reel @@ -409,6 +414,8 @@ async function cmdRender(sp, workDir, locale, format, attempt = 1) { const themedEffectOptions = (opts = {}) => ({ ...(sp.effectTheme || {}), ...opts }); let traceSince = null; let bakeTimeSequence = 0; + let activeCue = null; + let activeCuePlan = null; const storyboardEvents = []; const trace = (kind, details = {}) => { if (traceSince == null) return; @@ -459,6 +466,7 @@ async function cmdRender(sp, workDir, locale, format, attempt = 1) { }; }; const perform = async (kind, details, action) => { + activeCuePlan?.assertActionReady(kind); const startedAt = now(); const before = await uiSnapshot(); let frameAudit = null; @@ -526,6 +534,14 @@ async function cmdRender(sp, workDir, locale, format, attempt = 1) { card:localizeCard(card), options, }, () => presentSignboard(cdp, localizeCard(card), options)), check: (name, evidence = {}) => trace("check", { name, evidence }), + cue: (phrase, options) => { + if (!activeCue) throw new Error("cue() is only available while a narrated beat is running"); + return activeCue(phrase, options); + }, + nextCue: () => { + if (!activeCuePlan) throw new Error("nextCue() is only available while a narrated beat is running"); + return activeCuePlan.next(); + }, // A model wait is real capture evidence, but its inert middle is not useful // teaching time. Mark the exact async boundary; composition keeps a short // live lead, applies the canonical bake-time fold, then returns on the @@ -781,6 +797,18 @@ async function cmdRender(sp, workDir, locale, format, attempt = 1) { for (const beat of beats) { activeBeat = beat.index; const startedAt = now(); + activeCue = createBeatCue({ + beat, + startedAt, + now, + sleep, + onCue:(details) => trace("cue", details), + }); + activeCuePlan = createBeatCuePlan({ + beat, + cue:activeCue, + required:sp.actionCuePolicy === "required" && beat.actionCues !== false, + }); director.publish(directorBeatState(beats, beat.index, startedAt * 1000)); director.playVoice(beat.mp3); const offsetSec = startedAt - since; @@ -789,12 +817,15 @@ async function cmdRender(sp, workDir, locale, format, attempt = 1) { ` ${String(beat.index + 1).padStart(2)}. @${offsetSec.toFixed(1)}s ${beat.say.slice(0, 52)}\n`); if (beat.do) await beat.do(ctx); + activeCuePlan.assertComplete(); // Hold the shot for at least as long as the line takes to say. If the action // already outlasted it, we do NOT claw the time back — the next beat is // stamped where it truly starts, so the voice stays glued to the picture. const remain = beat.durationSec + (beat.holdMs ?? 350) / 1000 - (now() - startedAt); if (remain > 0) await sleep(remain * 1000); + activeCue = null; + activeCuePlan = null; result.push({ ...beat, offsetSec }); } if (sp.closingCard) { diff --git a/captutor/lib/cue-timing.mjs b/captutor/lib/cue-timing.mjs new file mode 100644 index 0000000000..e68a772be6 --- /dev/null +++ b/captutor/lib/cue-timing.mjs @@ -0,0 +1,125 @@ +// Word-aligned stage directions for narrated beats. + +const normalize = (value) => String(value || "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, ""); + +const normalizedWords = (value) => String(value || "") + .trim() + .split(/\s+/) + .map(normalize) + .filter(Boolean); + +function localizedNarration(value, locale) { + if (typeof value === "string") return value; + return value?.[locale] || ""; +} + +function cueDeclaration(value) { + if (typeof value === "string") return { phrase:value }; + if (value && typeof value === "object" && typeof value.phrase === "string") return value; + throw new Error(`invalid action cue ${JSON.stringify(value)}`); +} + +export function validateActionCuePlan(screenplay, { locale = "en" } = {}) { + if (screenplay.actionCuePolicy !== "required") return screenplay; + for (const [index, beat] of screenplay.beats.entries()) { + if (typeof beat.do !== "function" || beat.actionCues === false) continue; + if (!Array.isArray(beat.cues) || beat.cues.length === 0) { + throw new Error(`${screenplay.slug} beat ${index + 1} performs an action without a word cue`); + } + const spoken = normalizedWords(localizedNarration(beat.say, locale)); + for (const raw of beat.cues) { + const declaration = cueDeclaration(raw); + const wanted = normalizedWords(declaration.phrase); + const found = wanted.length > 0 && spoken.some((_, start) => + wanted.every((word, offset) => spoken[start + offset] === word)); + if (!found) { + throw new Error( + `${screenplay.slug} beat ${index + 1} cue ${JSON.stringify(declaration.phrase)} ` + + `is not present in the ${locale} narration`, + ); + } + } + } + return screenplay; +} + +export function cueTimeMs(words, phrase, { + occurrence = 1, + anchor = "start", +} = {}) { + const wanted = normalizedWords(phrase); + if (!wanted.length) throw new Error("cue phrase must contain a word"); + if (!Number.isInteger(occurrence) || occurrence < 1) { + throw new Error("cue occurrence must be a positive integer"); + } + if (anchor !== "start" && anchor !== "end") { + throw new Error(`unsupported cue anchor ${JSON.stringify(anchor)}`); + } + + const spoken = (words || []).map((word) => normalize(word.text)); + let seen = 0; + for (let i = 0; i <= spoken.length - wanted.length; i += 1) { + if (!wanted.every((word, offset) => spoken[i + offset] === word)) continue; + seen += 1; + if (seen !== occurrence) continue; + const matched = anchor === "end" ? words[i + wanted.length - 1] : words[i]; + const value = anchor === "end" ? matched.toMs : matched.fromMs; + if (!Number.isFinite(value)) throw new Error(`cue ${JSON.stringify(phrase)} has no ${anchor} time`); + return value; + } + throw new Error(`cue phrase ${JSON.stringify(phrase)} was not found in the narration`); +} + +export function createBeatCue({ beat, startedAt, now, sleep, onCue = () => {} }) { + return async (phrase, options = {}) => { + const targetMs = cueTimeMs(beat.words, phrase, options); + const leadMs = Number(options.leadMs || 0); + if (!Number.isFinite(leadMs) || leadMs < 0) { + throw new Error("cue leadMs must be a non-negative number"); + } + const elapsedMs = Math.max(0, (now() - startedAt) * 1000); + const waitMs = Math.max(0, targetMs - leadMs - elapsedMs); + if (waitMs > 0) await sleep(waitMs); + const result = { + phrase, + anchor:options.anchor || "start", + occurrence:options.occurrence || 1, + targetMs, + leadMs, + waitMs:+waitMs.toFixed(3), + }; + onCue(result); + return result; + }; +} + +export function createBeatCuePlan({ beat, cue, required = false }) { + const declarations = (beat.cues || []).map(cueDeclaration); + let consumed = 0; + return { + async next() { + const declaration = declarations[consumed]; + if (!declaration) { + if (required) throw new Error(`beat ${beat.index + 1} has no remaining action cue`); + return null; + } + consumed += 1; + const { phrase, ...options } = declaration; + return cue(phrase, options); + }, + assertActionReady(kind) { + if (required && consumed === 0) { + throw new Error(`beat ${beat.index + 1} attempted ${kind} before nextCue()`); + } + }, + assertComplete() { + if (required && consumed !== declarations.length) { + throw new Error( + `beat ${beat.index + 1} consumed ${consumed} of ${declarations.length} action cues`, + ); + } + }, + }; +} diff --git a/captutor/screenplays/apps-quickstart.mjs b/captutor/screenplays/apps-quickstart.mjs index 419959e694..18510f10a5 100644 --- a/captutor/screenplays/apps-quickstart.mjs +++ b/captutor/screenplays/apps-quickstart.mjs @@ -1,110 +1,377 @@ -// apps-quickstart — DRAFT, generated from apps/docs/content/docs/apps/quickstart.mdx +// apps-quickstart — from an empty canvas to a published app, one prompt. // -// The narration below is the docs page's own prose. The blocking is not written -// yet: every `do:` is an empty stub. Fill them in, then: +// Authored 2026-08-03 from the docs page's own prose (apps/quickstart.mdx), +// with every claim and selector verified against fuser staging @ 41c4f4d17: +// • Add a Node opens the node search (CanvasEmptyBanner.tsx:64) +// • "Start an app" invitation (AppPreviewInvitation.tsx:42-56) +// • composer textarea[aria-label=Prompt] (AppFocusPromptComposer.tsx:402) +// • Generate button aria-label (AppFocusPromptComposer.tsx:401-406) +// • Free = app-node-free, the DEFAULT (code-gen/src/models.ts:366,399-407) +// • iframe[title="App preview"] exists ONLY once deployed +// (AppNodePreviewSurface.tsx:291-305) +// • Publish button / Live pill / QR (ShareButton.tsx:284-312, +// AppNodeFocusLayout.tsx:2272-2276) +// • Unpublish lives in the More-actions overflow menu, and only while live +// (AppNodeFocusLayout.tsx:2185-2196) // -// node captutor.mjs narrate apps-quickstart # hear the pacing before you film -// node captutor.mjs render apps-quickstart +// BILLING. The App node's default model is the free tier (`app-node-free`) — +// this lesson never switches models, and beat "free_model_default" HARD-FAILS +// the take if the chip reads anything but "Free" before Generate is pressed. +// No Fuser generation credits are spent. // -// Re-running from-docs OVERWRITES this file — copy it aside once you have done -// the blocking, or teach from-docs to preserve it. +// THE BUILD WAIT. A real Fuser build (plan → code → deploy) runs on camera. +// `bakeTime` marks the boundary so the composed cut keeps a few honest live +// seconds and folds the inert middle. The focus idle-close timer is not a +// hazard solo: it only fires with other collaborators in the flow +// (FocusView.tsx:244-254 → useScreenIdleTimeout shouldFire). +// +// TEARDOWN (off camera, after the recorder stops): unpublish the exact App +// Asset through the signed-in mutation, close focus, and delete the project +// created by this take — matched by IDs captured during the take, never "the +// newest card". +// Teardown must not throw: a failed cleanup should not kill an accepted take. + +import { + fuserEffectTheme, + learnFuserBrandChrome, +} from "../themes/fuser.mjs"; + +const WORKSPACE = "https://app.fuser.studio/w/me"; + +const PROMPT = + "Build a minimalist Pomodoro timer with a circular progress ring, " + + "start/pause/reset, and a clean light UI."; + +// Focus-mode selectors (desktop branch — the 1190pt docs window is ≥1180). +const PROMPT_BOX = 'textarea[aria-label="Prompt"]'; +const GENERATE = 'button[aria-label="Generate"]'; +const STOP = 'button[aria-label="Stop"]'; +const START_APP = 'button[aria-label="Start an app. Click to describe and generate"]'; +const PREVIEW_IFRAME = 'iframe[title="App preview"]'; +// The on-canvas node and focus bar both keep a Publish button mounted. Choose +// the copy that actually owns its center point; the hidden canvas copy sits +// behind the portal-mounted preview iframe and otherwise steals querySelector. +const PUBLISH = `js=[...document.querySelectorAll('button[aria-label="Publish app"]')] + .find((button) => { + const rect = button.getBoundingClientRect(); + const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2); + return rect.width > 0 && rect.height > 0 && (hit === button || button.contains(hit)); + })`; +const QR_BUTTON = 'button[aria-label="Show QR code"]'; +const CLOSE_FOCUS = 'button[aria-label="Close"]'; + +// The model chip has no aria-label; its accessible name is the visible model +// name (ComposerModelChip.tsx:46). Match the exact text so a non-free default +// can never be mistaken for the chip. +const FREE_CHIP = + `js=[...document.querySelectorAll('button')].find(b => (b.textContent || '').trim() === 'Free')`; + +// Starter prompt pills are randomized per mount with no testid — select the +// first pill structurally: a rounded-full button inside the centered flex-wrap +// row that AppFocusInitialPrompt renders above the composer. +const STARTER_PILL = `js=(() => { + const rows = [...document.querySelectorAll('div')].filter((d) => + d.className.includes('flex-wrap') && d.className.includes('justify-center')); + for (const row of rows) { + const pill = [...row.querySelectorAll('button')] + .find((b) => b.className.includes('rounded-full')); + if (pill) return pill; + } + return null; +})()`; + +// The Live status pill is a plain span (FocusStatusPill.tsx) — no role. +const LIVE_PILL = `js=[...document.querySelectorAll('span.rounded-full')] + .find((s) => (s.textContent || '').trim() === 'Live')`; + +// "Preview is live" = the iframe exists (it only mounts once deployed) AND its +// wrapper has been positioned over a measured slot (display flips from none). +const PREVIEW_VISIBLE = `(() => { + const frame = document.querySelector('iframe[title="App preview"]'); + if (!frame) return false; + const wrapper = frame.parentElement; + return !!wrapper && getComputedStyle(wrapper).display !== 'none'; +})()`; + +// The run is over when the composer footer swaps back from the activity strip +// to the composer — i.e. Generate reappears (AppFocusTranscriptDrawer.tsx:190). +const RUN_FINISHED = `(() => { + const done = !!document.querySelector('button[aria-label="Generate"]'); + const stop = !!document.querySelector('button[aria-label="Stop"]'); + return done && !stop; +})()`; + +// The /flow/ this take created, captured right after project creation so +// teardown deletes exactly this project and nothing else. +let createdFlowPath = null; +let createdAppAssetId = null; + +function appAssetIdFromPreview(previewSrc) { + try { + const token = new URL(previewSrc).searchParams.get("__fuser_preview_token"); + const payload = JSON.parse(Buffer.from(token.split(".")[0], "base64url")); + return /^[0-9a-f-]{36}$/.test(payload.appAssetId) + ? payload.appAssetId + : null; + } catch { + return null; + } +} export default { slug: "apps-quickstart", - title: "Quickstart", voice: "jeffrey", - window: "Chrome", - match: "localhost:3200", + window: "Fuser", + desktopFrame: true, + match: "fuser.studio", + billable: false, // app-node-free is the default model; the take hard-fails if not fps: 60, + theme: "system", // follow Stage Mode's Light appearance; do not force product chrome + title: "Build and publish your first app", + shortTitle: "First app", + subtitle: "Fuser Apps · one prompt, a few clicks", + effectTheme: fuserEffectTheme, + brandChrome: learnFuserBrandChrome, + chapters: [ + { title:"Overview", shortTitle:"Overview", startSec:0, beatIndex:0, color:"#b39ad0", trackColor:"#dedcdf", wallpaperColor:"#755a97" }, + { title:"Blank canvas", shortTitle:"Blank canvas", startSec:10.652, beatIndex:1, color:"#91aed1", trackColor:"#d4d2d5", wallpaperColor:"#56799f" }, + { title:"Add the App node", shortTitle:"Add App", startSec:17.181, beatIndex:2, color:"#89bab4", trackColor:"#dedcdf", wallpaperColor:"#4f8b85" }, + { title:"Focus and prompt", shortTitle:"Prompt", startSec:32.510, beatIndex:4, color:"#95bc9a", trackColor:"#d4d2d5", wallpaperColor:"#5f8c66" }, + { title:"Free model", shortTitle:"Free model", startSec:57.387, beatIndex:6, color:"#d1b77f", trackColor:"#dedcdf", wallpaperColor:"#9a7838" }, + { title:"Generate and preview", shortTitle:"Generate", startSec:63.682, beatIndex:7, color:"#d49b8f", trackColor:"#d4d2d5", wallpaperColor:"#a46054" }, + { title:"Refine", shortTitle:"Refine", startSec:83.573, beatIndex:10, color:"#c68fb0", trackColor:"#dedcdf", wallpaperColor:"#914f77" }, + { title:"Publish and share", shortTitle:"Publish", startSec:94.791, beatIndex:11, color:"#a794ca", trackColor:"#d4d2d5", wallpaperColor:"#6f5a98" }, + { title:"Finish", shortTitle:"Finish", startSec:107.680, beatIndex:13, color:"#b49bc9", trackColor:"#dedcdf", wallpaperColor:"#7d6593" }, + ], + openingCard: null, + closingCard: { + title: "Now go make apps!", + durationMs: 3200, + transition: "genie", + }, + acceptance: { + minimumDurationSec: 55, + resolution: [2560, 1440], + requireEndingCard: true, + requireBrandChrome: true, + loudnessLufs: [-18, -14], + requiredChecks: [ + "app_node_added", "focus_mode_entered", "free_model_default", + "build_started", "build_completed_preview_live", + "app_published_live", "returned_to_canvas", + ], + }, - // Sign in / seed / open the right project here — setup runs BEFORE recording, - // so none of it lands in the tutorial. - setup: async ({ cdp }) => { - await cdp.nav("http://localhost:3200/"); + setup: async ({ cdp, locale, setLocale, s }) => { + await setLocale(cdp, locale, WORKSPACE); + // Give the Recent Projects grid room to fetch under Stage Mode's + // concurrent GPU load rather than racing the 20s default. + await cdp.waitFor(`document.querySelector('${s.blankProject}')`, { timeoutMs: 45000 }); + await cdp.eval("window.scrollTo(0, 0)"); }, beats: [ { say: "This walkthrough takes you from an empty canvas to a published app — one prompt and a few clicks.", - // TODO blocking — see vault/fuser/skills/drive-ui.md for the selector map. - do: async ({ cdp, click, point, type }) => {}, + do: async ({ point }) => point("body", { moveMs: 620 }), }, - // ── Add an App node - // on screen (from the page's own screenshot): An empty App node with the "Start an app" invitation { - say: "Press SHIFT A (or open the Add a Node menu), search for App, and add it. A fresh node shows a glowing Start an app invitation.", - // TODO blocking — see vault/fuser/skills/drive-ui.md for the selector map. - do: async ({ cdp, click, point, type }) => {}, + say: "Start from a blank project. You land straight on the canvas — the infinite space where you build.", + do: async ({ click, cdp, s }) => { + await click(s.blankProject); + await cdp.waitFor("location.pathname.startsWith('/flow/')"); + await cdp.waitFor("document.querySelector('.react-flow')"); + createdFlowPath = await cdp.eval("location.pathname"); + }, }, - // ── Add an App node { - say: "Click the node to open focus mode — the full-screen workspace where you'll build.", - // TODO blocking — see vault/fuser/skills/drive-ui.md for the selector map. - do: async ({ cdp, click, point, type }) => {}, + say: "Open Add a Node and search for App. The App node turns a plain description into a real, working app.", + do: async ({ click, cdp, type, s }) => { + await click(s.addNode); + await cdp.waitFor(`document.querySelector('${s.nodeSearch}')`); + await type(s.nodeSearch, s.appNodeQuery); + }, }, - // ── Describe what to build - // on screen (from the page's own screenshot): Focus mode with starter prompts above the composer { - say: "Focus mode opens with a composer and a few starter prompts above it. Type your own idea, or click a starter to drop it in.", - // TODO blocking — see vault/fuser/skills/drive-ui.md for the selector map. - do: async ({ cdp, click, point, type }) => {}, + say: "Click App, and a fresh node lands with a glowing Start an app invitation.", + do: async ({ cdp, click, check, s }) => { + // Use the visible picker result so the recorded action matches the + // lesson. React Aria exposes each result as an exact named option. + const appResult = `js=[...document.querySelectorAll('[role="option"]')] + .find((option) => option.getAttribute('aria-label') === ${JSON.stringify(s.appNodeQuery)})`; + await click(appResult); + await cdp.waitFor("document.querySelectorAll('.react-flow__node').length > 0"); + await cdp.waitFor(`document.querySelector(${JSON.stringify(START_APP)})`); + // react-flow fits the view when the node lands (~31% zoom); bring it + // back to 100% so the node is legible on camera. + await click(s.zoomButton); + await click(s.zoom100); + check("app_node_added", { node: "AppNode", invitation: "Start an app" }); + }, }, - // ── Describe what to build { - say: "Keep the first prompt concrete but simple — you'll refine it afterward. For example: \"Build a minimalist Pomodoro timer with a circular progress ring, start/pause/reset, and a clean dark UI.\"", - // TODO blocking — see vault/fuser/skills/drive-ui.md for the selector map. - do: async ({ cdp, click, point, type }) => {}, + say: "Click it to open focus mode — the full-screen workspace where you'll build.", + do: async ({ click, cdp, check }) => { + await click(START_APP); + await cdp.waitFor(`document.querySelector(${JSON.stringify(PROMPT_BOX)})`); + check("focus_mode_entered", { composer: "Prompt" }); + }, }, - // ── Describe what to build { - say: "Press CMD Enter (or click Generate) to start the build.", - // TODO blocking — see vault/fuser/skills/drive-ui.md for the selector map. - do: async ({ cdp, click, point, type }) => {}, + say: "Focus mode opens with a composer, and a few starter prompts above it. Type your own idea, or click a starter to drop it in.", + do: async ({ point, outline }) => { + await point(STARTER_PILL, { moveMs: 560 }); + await outline(STARTER_PILL, { feather: 20, durationMs: 2400 }); + }, }, - // ── Describe what to build { - say: "The model chip reads Free — the default, no-credit model. Leave it as-is for your first app. See Generation models for when to switch.", - // TODO blocking — see vault/fuser/skills/drive-ui.md for the selector map. - do: async ({ cdp, click, point, type }) => {}, + say: "Keep the first prompt concrete but simple — you'll refine it afterward. For example: a minimalist Pomodoro timer with a circular progress ring and a clean light interface.", + do: async ({ type }) => { + await type(PROMPT_BOX, PROMPT); + }, }, - // ── Watch it build - // on screen (from the page's own screenshot): The app building, with a live activity feed and Stop button { - say: "The agent narrates as it works: planning, writing code, then deploying a preview. A live transcript fills the chat panel on the left, and a Stop button lets you cancel a run in progress.", - // TODO blocking — see vault/fuser/skills/drive-ui.md for the selector map. - do: async ({ cdp, click, point, type }) => {}, + say: "The model chip reads Free — the default, no-credit model. Leave it as-is for your first app.", + do: async ({ cdp, spotlight, check }) => { + // HARD GATE: if the default model is not the free tier, this take must + // die here rather than press Generate and spend credits. + const chipText = await cdp.eval( + `[...document.querySelectorAll('button')].map(b => (b.textContent || '').trim()).includes('Free')`, + ); + if (!chipText) throw new Error("model chip does not read 'Free' — refusing to generate"); + await spotlight(FREE_CHIP, { + label: "Free model", dim: 0.32, ring: true, feather: 24, durationMs: 3000, + }); + check("free_model_default", { model: "app-node-free", chip: "Free" }); + }, + }, + { + say: "Click Generate to start the build. Fuser shows each step as it works — planning, writing code, then deploying a preview. A Stop button lets you cancel the run.", + do: async ({ cue, click, cdp, point, check }) => { + // Land the click on the spoken word, not at the start of the sentence. + // The old immediate action made the pointer reach Stop while captions + // still said "start the build," which read as an instruction to cancel. + await cue("Generate", { leadMs:650 }); + await click(GENERATE, { moveMs:520, orbitMs:360 }); + // Proof the run started: the footer swaps to the activity strip. + await cdp.waitFor( + `!!document.querySelector(${JSON.stringify(STOP)}) || + !!document.querySelector('[role="progressbar"][aria-label="Generation progress"]')`, + { timeoutMs: 30000 }, + ); + check("build_started", { model: "app-node-free" }); + await cue("Stop button", { leadMs:620 }); + await point(STOP, { moveMs:620, offsetY:-8 }); + }, }, - // ── Preview and refine - // on screen (from the page's own screenshot): A finished app rendered in focus mode beside the chat transcript { say: "When the build finishes, your app renders live in the preview. Try it out.", - // TODO blocking — see vault/fuser/skills/drive-ui.md for the selector map. - do: async ({ cdp, click, point, type }) => {}, + do: async (ctx) => { + const { bakeTime, cdp, check, point, sleep } = ctx; + await bakeTime(() => cdp.waitFor( + `${PREVIEW_VISIBLE} && ${RUN_FINISHED}`, + { timeoutMs: 480000, everyMs: 500 }, + ), { id: "app-build", label: "Building your app" }); + const previewSrc = await cdp.eval( + `document.querySelector(${JSON.stringify(PREVIEW_IFRAME)})?.src || ''`, + ); + createdAppAssetId = appAssetIdFromPreview(previewSrc); + check("build_completed_preview_live", { previewSrc }); + await point(PREVIEW_IFRAME, { moveMs: 720 }); + await sleep(1400); + }, }, - // ── Preview and refine { - say: "To change anything, describe it in the composer — \"make the accent color teal\", \"add a long-break mode\" — and the agent applies it. You can also edit content, styles, and code directly.", - // TODO blocking — see vault/fuser/skills/drive-ui.md for the selector map. - do: async ({ cdp, click, point, type }) => {}, + say: "To refine it, describe the change in the composer — make the accent teal or add a long-break mode. Fuser's App node handles it. You can also edit the code directly.", + do: async ({ point, outline, sleep }) => { + await point(PROMPT_BOX, { moveMs: 560 }); + await outline(PROMPT_BOX, { feather: 22, durationMs: 2600 }); + await sleep(600); + }, }, - // ── Publish - // on screen (from the page's own screenshot): A published app showing the Live status and share panel { - say: "When you're happy, click Publish. Your app is promoted to a public URL that anyone with the link can open — no login required. The status pill flips to Live, and a share panel hands you the link and a QR code.", - // TODO blocking — see vault/fuser/skills/drive-ui.md for the selector map. - do: async ({ cdp, click, point, type }) => {}, + say: "When you're happy, click Publish.", + do: async ({ cue, click, cdp }) => { + await cue("Publish", { leadMs:650 }); + await click(PUBLISH, { moveMs:520, orbitMs:360 }); + // End this beat only after publishing resolves. The next line therefore + // begins on the first truthful Live frame instead of predicting it. + await cdp.waitFor(`!!(${LIVE_PILL.slice(3)})`, { timeoutMs: 60000 }); + }, }, - // ── Publish { - say: "That's the whole loop. Everything else in this section goes deeper on each step.", - // TODO blocking — see vault/fuser/skills/drive-ui.md for the selector map. - do: async ({ cdp, click, point, type }) => {}, + say: "The status pill now reads Live. Your app has a public URL anyone can open without logging in. Click the QR button to hand the app to a phone.", + do: async ({ cue, click, cdp, spotlight, check }) => { + await cue("status pill", { leadMs:620 }); + await spotlight(LIVE_PILL, { + label: "Live", dim: 0.34, ring: true, feather: 22, durationMs: 1800, + }); + await cue("QR button", { leadMs:650 }); + await click(QR_BUTTON, { moveMs:520, orbitMs:360 }); + await cdp.waitFor( + `!!document.querySelector('[data-rac][data-placement] img, [data-rac][data-placement] svg')`, + { timeoutMs: 15000 }, + ); + check("app_published_live", { pill: "Live", share: "qr" }); + }, }, - // ── What's next { - say: "Prompts, crafting, generation activity, and one-click error fixes. Direct edits to content, design, media, and code. Version history, rollback, and what publishing actually does. Links, QR codes, discovery, and access control.", - // TODO blocking — see vault/fuser/skills/drive-ui.md for the selector map. - do: async ({ cdp, click, point, type }) => {}, + say: "That's the whole loop — from empty canvas to a live app. Everything else in this section goes deeper on each step.", + do: async ({ cdp, click, point, check }) => { + // Toggle the QR popover closed with its own trigger — NOT Escape: + // FocusView owns a window-level capturing Esc that would close all of + // focus mode out from under the popover (FocusView.tsx:170-195). + await click(QR_BUTTON); + await cdp.waitFor( + `!document.querySelector('[data-rac][data-placement] img, [data-rac][data-placement] svg, [data-rac][data-placement] canvas')`, + { timeoutMs: 10000 }, + ); + await click(CLOSE_FOCUS); + await cdp.waitFor("document.querySelector('.react-flow__pane')"); + await point(".react-flow__node", { moveMs: 720 }); + check("returned_to_canvas", { deployedNode: true }); + }, }, ], + + // Off camera, after the recorder stops. Never throw: a failed cleanup must + // not kill an accepted take — log and move on. + teardown: async ({ cdp, click }) => { + const step = async (label, fn) => { + try { await fn(); } + catch (error) { console.error(` teardown: ${label} failed — ${error.message}`); } + }; + await step("unpublish", async () => { + if (!createdAppAssetId) return; + const status = await cdp.eval( + `fetch("https://api.fuser.studio/api/v1/trpc/appAsset.unshare", {` + + `method:"POST", credentials:"include", headers:{"content-type":"application/json"},` + + `body:${JSON.stringify(JSON.stringify({ json: { appAssetId: createdAppAssetId } }))}` + + `}).then(r => r.status)`, + ); + if (status !== 200) throw new Error(`appAsset.unshare returned ${status}`); + }); + await step("close focus", async () => { + if (await cdp.eval(`!!document.querySelector(${JSON.stringify(CLOSE_FOCUS)})`)) { + await click(CLOSE_FOCUS); + } + }); + await step("delete project", async () => { + if (!createdFlowPath) return; + // The flow slug is the project id. Call the same signed-in mutation as + // DeleteProjectDialog, keyed by the exact project created by this take. + // This avoids RAC menu animation and collection-key races in teardown. + const projectId = createdFlowPath.split("/").pop(); + if (!/^[0-9a-f-]{36}$/.test(projectId)) { + throw new Error(`suspicious project id: ${projectId}`); + } + const status = await cdp.eval( + `fetch("https://api.fuser.studio/api/v1/trpc/project.deleteProject", {` + + `method:"POST", credentials:"include", headers:{"content-type":"application/json"},` + + `body:${JSON.stringify(JSON.stringify({ json: { id: projectId } }))}` + + `}).then(r => r.status)`, + ); + if (status !== 200) throw new Error(`deleteProject returned ${status}`); + }); + }, }; diff --git a/captutor/screenplays/making-cartoons.mjs b/captutor/screenplays/making-cartoons.mjs new file mode 100644 index 0000000000..e7eda9913d --- /dev/null +++ b/captutor/screenplays/making-cartoons.mjs @@ -0,0 +1,102 @@ +// making-cartoons — generate a two-character cartoon still, then animate the +// same cast through an explicit image-to-video connection. +// +// The automation is the proven talking-taco path; this variant owns the +// editorial contract: about three minutes, one visible two-character cast, and +// enough prompting rationale to teach a repeatable cartoon workflow. +import { createImageToVideoScreenplay } from "./talking-taco.mjs"; + +const CHARACTER_PROMPT = + "Two original cartoon characters standing together: a cheerful moon-headed " + + "skateboarder in a cobalt jacket and a tiny cloud dog with orange rain boots. " + + "Full-body view, clear contrasting silhouettes, expressive faces, simple " + + "flat 2D animation shapes, limited color palette, clean pale background, no " + + "text, no logos, model sheet quality"; + +const MOTION_PROMPT = + "The moon-headed skateboarder rolls slowly forward and waves while the tiny " + + "cloud dog bounds alongside, its orange boots landing with soft squash and " + + "stretch. Preserve both character designs, faces, colors, and proportions. " + + "Gentle side view, simple looping action, locked camera, no cuts, no text"; + +const tutorial = createImageToVideoScreenplay({ + slug:"making-cartoons", + title:"Making Cartoons in Fuser", + subtitle:"Design a two-character cast, then animate the same image", + closingTitle:"Two characters. One connected image-to-video cartoon.", + imagePrompt:CHARACTER_PROMPT, + motionPrompt:MOTION_PROMPT, +}); + +const narration = [ + "In the next three minutes, we'll make a tiny cartoon in Fuser: first a still image of two original characters, then a moving scene generated from that exact image.", + "Start with a fresh project. Keeping the canvas empty makes the construction easy to read: one node will design the cast, and one node will animate it.", + "Open the node picker and search for Gemini Image. This is our character-design step, where a written description becomes the visual reference for everything that follows.", + "Choose Gemini Image. Fuser places the node on the canvas with its prompt, model controls, and image output together, so the source of the character art stays visible.", + "Describe the cast before the action. Name each character, give each one a distinct silhouette and color, request full bodies, and keep the background simple enough for animation.", + "Generate the image. Fuser sends the prompt to the selected model while keeping the prompt and result in the same node, so the design decision remains attached to its evidence.", + "Here is our cast: a moon-headed skateboarder and a cloud dog. Check that both characters are readable at a glance, separated from the background, and shown head to toe.", + "Now add Kling 3.0 Video. This second node will not redesign the cast; it will receive the finished character image and use it as the opening visual reference.", + "Place the video node beside the image node. A left-to-right layout makes the recipe legible even without narration: character design first, animation second.", + "Set the clip to three seconds for this first test. A short generation is enough to judge whether the motion preserves the characters before you spend time on a longer shot.", + "Connect the generated image output to Kling's Image input. This line is the provenance of the cartoon: the moving scene must begin with the character art we just approved.", + "Write motion, not appearance. The skateboarder rolls and waves; the cloud dog bounds with squash and stretch. Ask for the same faces, colors, proportions, and a locked camera.", + "Generate the video. Fuser now combines two instructions: the connected image defines who the characters are, while the motion prompt defines what they do and how the camera behaves.", + "The result keeps both characters in one shot and turns the still design into a small performance. Watch for identity drift, extra limbs, camera cuts, or a background that changes unexpectedly.", + "That is the reusable cartoon workflow: design a clear cast image, connect it as evidence, describe only the motion, generate a short test, then refine either node without losing the chain.", +]; + +const actionCues = [ + "next three minutes", + "fresh project", + "Open the node picker", + "Choose Gemini Image", + "Describe the cast", + "Generate the image", + "Here is our cast", + "Now add Kling 3.0 Video", + "Place the video node", + "Set the clip", + "Connect the generated image output", + "Write motion", + "Generate the video", + "The result", + "That is the reusable cartoon workflow", +]; + +if (narration.length !== tutorial.beats.length) { + throw new Error(`making-cartoons narration has ${narration.length} lines for ${tutorial.beats.length} beats`); +} + +tutorial.beats = tutorial.beats.map((beat, index) => ({ + ...beat, + say:narration[index], + cues:[{ phrase:actionCues[index], leadMs:520 }], + do:async (ctx) => { + await ctx.nextCue(); + return beat.do(ctx); + }, +})); + +tutorial.actionCuePolicy = "required"; +tutorial.openingCard = { + title:"Making Cartoons in Fuser", + showMark:false, + durationMs:2800, + transition:"slide", +}; +tutorial.targetDurationSec = 180; +tutorial.chapters = [ + { title:"Plan the cast", shortTitle:"Plan", beatIndex:0, color:"#a794ca", wallpaperColor:"#6f5a98" }, + { title:"Generate characters", shortTitle:"Characters", beatIndex:2, color:"#89bab4", wallpaperColor:"#4f8b85" }, + { title:"Inspect the image", shortTitle:"Inspect", beatIndex:6, color:"#d1b77f", wallpaperColor:"#9a7838" }, + { title:"Animate the cast", shortTitle:"Animate", beatIndex:7, color:"#d49b8f", wallpaperColor:"#a46054" }, + { title:"Review the cartoon", shortTitle:"Review", beatIndex:13, color:"#c68fb0", wallpaperColor:"#914f77" }, +]; +tutorial.acceptance = { + ...tutorial.acceptance, + minimumDurationSec:165, + maximumDurationSec:195, +}; + +export default tutorial; diff --git a/captutor/screenplays/talking-taco.mjs b/captutor/screenplays/talking-taco.mjs index 3dc7dccf69..634025181d 100644 --- a/captutor/screenplays/talking-taco.mjs +++ b/captutor/screenplays/talking-taco.mjs @@ -201,8 +201,16 @@ const creditsExpr = `[...document.querySelectorAll('button')] .map((button) => (button.innerText || '').replace(/\\s+/g, ' ').trim()) .find((text) => /✦/.test(text) && /\\d/.test(text)) || ''`; -export default { - slug: "talking-taco", +export function createImageToVideoScreenplay({ + slug = "talking-taco", + title = "Learn Fuser by making a Talking Taco", + subtitle = "A fresh project → a generated image → a generated video", + closingTitle = "One prompt became an image. That image became a video.", + imagePrompt = TACO_IMAGE_PROMPT, + motionPrompt = TACO_MOTION_PROMPT, +} = {}) { +return { + slug, voice: "jeffrey", window: "Fuser", desktopFrame: true, @@ -216,8 +224,8 @@ export default { brandChrome: fuserBrandChrome, billable: true, fps: 60, - title: "Learn Fuser by making a Talking Taco", - subtitle: "A fresh project → a generated image → a generated video", + title, + subtitle, openingCard: { title: "Learn Fuser", showMark: false, @@ -225,7 +233,7 @@ export default { transition: "slide", }, closingCard: { - title: "One prompt became an image. That image became a video.", + title: closingTitle, durationMs: 2400, transition: "genie", }, @@ -347,7 +355,7 @@ export default { say: "Describe the image you want. Let's make a talking taco: bold, cheerful, mouth wide open mid-laugh.", do: async (ctx) => { const { cdp, check } = ctx; - await typePrompt(ctx, GEMINI_PROMPT, TACO_IMAGE_PROMPT); + await typePrompt(ctx, GEMINI_PROMPT, imagePrompt); check("image_prompt_entered", { text: await cdp.eval(`document.querySelector(${JSON.stringify(GEMINI_PROMPT)}).value`) }); }, }, @@ -458,7 +466,7 @@ export default { say: "Add a short motion prompt describing how the taco should move and talk.", do: async (ctx) => { const { cdp, check } = ctx; - await typePrompt(ctx, KLING_PROMPT, TACO_MOTION_PROMPT); + await typePrompt(ctx, KLING_PROMPT, motionPrompt); check("video_prompt_entered", { text: await cdp.eval(`document.querySelector(${JSON.stringify(KLING_PROMPT)}).value`) }); }, }, @@ -544,3 +552,6 @@ export default { }, ], }; +} + +export default createImageToVideoScreenplay(); diff --git a/captutor/test/cue-timing.test.mjs b/captutor/test/cue-timing.test.mjs new file mode 100644 index 0000000000..3674371a92 --- /dev/null +++ b/captutor/test/cue-timing.test.mjs @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + createBeatCue, createBeatCuePlan, cueTimeMs, validateActionCuePlan, +} from "../lib/cue-timing.mjs"; + +const words = [ + { text:"Click", fromMs:100, toMs:300 }, + { text:"Publish.", fromMs:320, toMs:760 }, + { text:"The", fromMs:900, toMs:1030 }, + { text:"pill", fromMs:1050, toMs:1300 }, + { text:"flips", fromMs:1320, toMs:1600 }, + { text:"to", fromMs:1620, toMs:1710 }, + { text:"Live.", fromMs:1730, toMs:2040 }, +]; + +test("cueTimeMs matches normalized phrases at either edge", () => { + assert.equal(cueTimeMs(words, "Publish"), 320); + assert.equal(cueTimeMs(words, "pill flips to Live", { anchor:"end" }), 2040); +}); + +test("createBeatCue subtracts movement lead time from the word cue", async () => { + let clock = 10; + const waits = []; + const seen = []; + const cue = createBeatCue({ + beat:{ words }, + startedAt:10, + now:() => clock, + sleep:async (ms) => { waits.push(ms); clock += ms / 1000; }, + onCue:(result) => seen.push(result), + }); + await cue("Live", { leadMs:500 }); + assert.deepEqual(waits, [1230]); + assert.equal(seen[0].targetMs, 1730); +}); + +test("missing cues fail before an unrelated action can run", () => { + assert.throws(() => cueTimeMs(words, "Generate"), /was not found/); +}); + +test("required action cue plans fail before narration synthesis", () => { + const screenplay = { + slug:"new-tutorial", + actionCuePolicy:"required", + beats:[{ say:"Click Publish.", do:async () => {} }], + }; + assert.throws(() => validateActionCuePlan(screenplay), /without a word cue/); + screenplay.beats[0].cues = [{ phrase:"Generate" }]; + assert.throws(() => validateActionCuePlan(screenplay), /is not present/); + screenplay.beats[0].cues = [{ phrase:"Click Publish" }]; + assert.equal(validateActionCuePlan(screenplay), screenplay); +}); + +test("required cue plans block early actions and unconsumed declarations", async () => { + const called = []; + const plan = createBeatCuePlan({ + beat:{ index:2, cues:["Publish", { phrase:"Live", leadMs:400 }] }, + cue:async (phrase, options) => called.push({ phrase, options }), + required:true, + }); + assert.throws(() => plan.assertActionReady("click"), /before nextCue/); + await plan.next(); + plan.assertActionReady("click"); + assert.throws(() => plan.assertComplete(), /1 of 2/); + await plan.next(); + plan.assertComplete(); + assert.deepEqual(called, [ + { phrase:"Publish", options:{} }, + { phrase:"Live", options:{ leadMs:400 } }, + ]); +}); diff --git a/captutor/test/storyboard-receipt.test.mjs b/captutor/test/storyboard-receipt.test.mjs index 4c696dbeaa..b912e398b3 100644 --- a/captutor/test/storyboard-receipt.test.mjs +++ b/captutor/test/storyboard-receipt.test.mjs @@ -26,7 +26,10 @@ test("builds an accepted PDF from screenplay-owned QA checks", () => { screenplay:"receipt-smoke", locale:"en", format:"docs", theme:"light", title:"Receipt smoke", subtitle:"Trace-derived frame evidence", media:{ file:"take.mp4", width:320, height:180, durationSec:1.5, bytes:1 }, - acceptance:{ resolution:[320, 180], minimumDurationSec:1, requiredChecks:["route_complete"] }, + acceptance:{ + resolution:[320, 180], minimumDurationSec:1, maximumDurationSec:2, + requiredChecks:["route_complete"], + }, beats:[{ index:0, offsetSec:0, durationSec:1.2, narration:"Route the output.", logic:"The exact upstream output reaches the downstream node.", cursorIntent:"clear", diff --git a/captutor/test/tutorial-screenplays.test.mjs b/captutor/test/tutorial-screenplays.test.mjs index 405d2fb571..8158c0e39c 100644 --- a/captutor/test/tutorial-screenplays.test.mjs +++ b/captutor/test/tutorial-screenplays.test.mjs @@ -5,6 +5,8 @@ import basic from "../screenplays/basic-tutorial.mjs"; import imageGeneration from "../screenplays/image-generation-workflow.mjs"; import imageNode from "../screenplays/image-node-tutorial.mjs"; import editorSettings from "../screenplays/editor-settings-tour.mjs"; +import appsQuickstart from "../screenplays/apps-quickstart.mjs"; +import makingCartoons from "../screenplays/making-cartoons.mjs"; const LOCALES = ["en", "es", "fr"]; @@ -44,3 +46,33 @@ test("image-generation-workflow has complete English and Chinese narration", () assert.match(imageGeneration.beats.map((beat) => beat.say.en).join(" "), /image-to-image generation/i); assert.match(imageGeneration.beats.map((beat) => beat.say["zh-CN"]).join(" "), /图生图/); }); + +test("apps quickstart gives every chapter a distinct compact color identity", () => { + assert.equal(appsQuickstart.shortTitle, "First app"); + assert.equal(new Set(appsQuickstart.chapters.map((chapter) => chapter.color)).size, + appsQuickstart.chapters.length); + assert.equal(new Set(appsQuickstart.chapters.map((chapter) => chapter.wallpaperColor)).size, + appsQuickstart.chapters.length); + for (const chapter of appsQuickstart.chapters) { + assert.ok(chapter.shortTitle.length <= 12); + assert.notEqual(chapter.color, chapter.wallpaperColor); + } +}); + +test("making-cartoons owns a three-minute two-character image-to-video lesson", () => { + assert.equal(makingCartoons.title, "Making Cartoons in Fuser"); + assert.equal(makingCartoons.targetDurationSec, 180); + assert.equal(makingCartoons.actionCuePolicy, "required"); + assert.equal(makingCartoons.acceptance.minimumDurationSec, 165); + assert.equal(makingCartoons.acceptance.maximumDurationSec, 195); + assert.equal(makingCartoons.beats.length, 15); + const script = makingCartoons.beats.map((beat) => beat.say).join(" "); + assert.match(script, /moon-headed skateboarder/i); + assert.match(script, /cloud dog/i); + assert.match(script, /connect the generated image output/i); + assert.match(script, /same faces, colors, proportions/i); + assert.ok(script.split(/\s+/).length >= 320); + for (const beat of makingCartoons.beats) { + assert.equal(beat.cues.length, 1); + } +}); diff --git a/captutor/themes/fuser.mjs b/captutor/themes/fuser.mjs index 9b825ae637..1b218c6e2b 100644 --- a/captutor/themes/fuser.mjs +++ b/captutor/themes/fuser.mjs @@ -22,7 +22,7 @@ export const fuserEffectTheme = Object.freeze({ scrollIntoView: false, }); -export default Object.freeze({ +const fuserBrandChrome = Object.freeze({ id: "fuser", markAsset: join(HERE, "..", "assets", "fuser-mark.svg"), label: "Fuser", @@ -56,3 +56,44 @@ export default Object.freeze({ vertical: { edgeFraction: 0.032, markSideFraction:0.021, labelPxFraction:0.029 }, }, }); + +export const learnFuserBrandChrome = Object.freeze({ + ...fuserBrandChrome, + id: "learn-fuser", + markAsset: join(HERE, "..", "assets", "fuser-metaballs-1.png"), + markVideoAsset: join(HERE, "..", "assets", "fuser-metaballs-spin.mov"), + markPreserveColor: true, + markOrientation: "side", + markPerspective: null, + driftFraction: 0, + driftPx: 0, + bobFraction: 0, + bobPx: 0, + label: "Learn Fuser", + labelAsset: null, + labelAssetCrop: null, + labelCharacterCuts: null, + characterMotion: null, + leftMarkCenterY: 0.44, + leftLabelCenterY: 0.65, + rightMarkCenterY: 0.56, + rightLabelCenterY: 0.35, + labelStrokeFraction: 0, + labelShadow: false, + labelColor: "#4f3d59", + sectionLabel: true, + unifiedSectionStamp: true, + sectionLabelOpacity: 0.96, + formats: { + ...fuserBrandChrome.formats, + docs: { + ...fuserBrandChrome.formats.docs, + markSideFraction:0.052, + labelPxFraction:0.021, + sectionLabelPxFraction:0.021, + sectionLabelGapFraction:0, + }, + }, +}); + +export default fuserBrandChrome;