diff --git a/packages/studio/src/hooks/gsapDragCommit.test.ts b/packages/studio/src/hooks/gsapDragCommit.test.ts index fab857dd22..cc0cd4479c 100644 --- a/packages/studio/src/hooks/gsapDragCommit.test.ts +++ b/packages/studio/src/hooks/gsapDragCommit.test.ts @@ -2,12 +2,12 @@ import { describe, expect, it, beforeEach } from "vitest"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { - commitGsapPositionFromDrag, commitStaticGsapPosition, commitStaticGsapRotation, parkPlayheadOnKeyframe, type GsapDragCommitCallbacks, } from "./gsapDragCommit"; +import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit"; import { usePlayerStore } from "../player/store/playerStore"; // Minimal selection whose element has no drag-baseline attributes (origX/Y = 0). diff --git a/scripts/test-skills-fresh.sh b/scripts/test-skills-fresh.sh index 214753b44d..3f26746005 100755 --- a/scripts/test-skills-fresh.sh +++ b/scripts/test-skills-fresh.sh @@ -41,7 +41,7 @@ # 5. Installs the full skills tree from the LOCAL repo via `npx skills add # --agent `, then prunes the internal _meta/ authoring skills so the # installed set matches what an end user gets. -# 6. Verifies the router + 6 workflows + 6 domain skills landed. +# 6. Verifies the router + 10 workflows + 6 domain skills landed. # 7. Prints the command to start the agent + example prompts to try. # # Iterate after editing: @@ -234,7 +234,9 @@ fi say "Verifying skill installation..." ROUTER="hyperframes" -WORKFLOWS=(product-launch-video faceless-explainer footage-recut pr-to-video general-video remotion-to-hyperframes motion-graphics) +WORKFLOWS=(product-launch-video website-to-video faceless-explainer embedded-captions \ + graphic-overlays pr-to-video motion-graphics general-video \ + remotion-to-hyperframes slideshow) DOMAIN=(hyperframes-core hyperframes-creative hyperframes-animation hyperframes-cli hyperframes-media hyperframes-registry) MISSING=() @@ -272,6 +274,8 @@ echo "Then type any request you want to test — the agent routes it to a workfl echo " • \"make a product launch video for https://your-site.com/\" → product-launch-video (exercises capture)" echo " • \"explain how transformers work as a faceless explainer video\" → faceless-explainer" echo " • \"make a video from this PR: owner/repo#123\" → pr-to-video" -echo " • \"recut this footage ./clip.mp4 with info-card overlays\" → footage-recut" +echo " • \"add lower-thirds / overlay cards to ./clip.mp4\" → graphic-overlays" +echo " • \"add captions/subtitles to ./clip.mp4\" → embedded-captions" +echo " • \"turn https://your-site.com/ into a site tour video\" → website-to-video" echo " • \"a logo reveal / title card / data montage\" → general-video" echo "" diff --git a/skills/faceless-explainer/SKILL.md b/skills/faceless-explainer/SKILL.md index e10741fcc4..1e801fc4fd 100644 --- a/skills/faceless-explainer/SKILL.md +++ b/skills/faceless-explainer/SKILL.md @@ -1,395 +1,194 @@ --- name: faceless-explainer -description: faceless-explainer video workflow - arbitrary text (article / notes / topic / brief) -> narrator_scripts.json + audio (voice + BGM) + section_plan.md -> typography / abstract-graphics / diagram / data-viz video. Typical length up to ~3 min (sweet spot ~30-90s); a genuinely longer piece is general-video, not this workflow. Generates its OWN narration (TTS) — it does not sync to a user-supplied / pre-recorded voiceover (that is general-video). No website capture, no real product screenshots. If the text names a product / its site to promote, that is /product-launch-video; when product-vs-topic is unclear, start at /hyperframes. -metadata: { "tags": "orchestrator, pipeline, faceless-explainer, text-to-video" } +description: "turn arbitrary text — an article, notes, a topic, a brief — into a faceless explainer video, up to ~3 min (sweet spot 30-90s), where every visual is invented (typography, abstract graphics, diagrams, data-viz) rather than captured. There is no URL, no website capture, and no real assets. Use this skill for topic explainers, concept breakdowns, how-tos, listicles, and narrative explainers. Do not use it for a product launch/promo (use /product-launch-video), a tour of a real website (use /website-to-video), a GitHub PR (use /pr-to-video), captions on existing footage (use /embedded-captions), or a short unnarrated motion graphic (use /motion-graphics). If the intent is unclear, route through /hyperframes first." --- -# faceless-explainer - dispatch entry +# Faceless Explainer to HyperFrames -Input is **arbitrary text** (article / notes / topic / brief). Output is a **faceless explainer** video: no captured website, no product screenshots — every visual is invented by the LLM (typography / abstract graphics / diagram / data-viz), chosen per scene by content. The style preset is **auto-selected per input** by the scriptwriting agent (Step 2) from the 5 shipped presets (`block-frame` / `capsule` / `claude` / `pin-and-paper` / `scatterbrain`; default `pin-and-paper` when nothing clearly fits). +Use this skill to turn a body of text into an explainer video: pick a design system, plan a teaching story, and build it frame by frame in HyperFrames. **Faceless** means every visual is invented downstream — there is no capture step and no real asset inventory. -> **Confirm the route before Step 0.** This skill explains a **topic / concept** with **no product and no site to capture**. If the text actually **markets a product / names its site** → `/product-launch-video`; there's a **URL to turn into a video** → `/website-to-video`; a **GitHub PR** → `/pr-to-video`; **existing footage** to caption / package → `/embedded-captions` · `/graphic-overlays`. **Out of scope**: timing visuals to a **user-supplied / pre-recorded voiceover** (faceless generates its own TTS → `/general-video`), or live / at-render-time data. Unsure product-vs-topic, or routed here on a vague request? **Read `/hyperframes` first.** +> **Confirm the route before Step 0.** You are the orchestrator. Run each step, verify its gate, and only then continue. This skill is for **explaining a topic from text, with no product and no website to capture**. Route other intents elsewhere: a product launch/promo → `/product-launch-video`; a tour of a real site → `/website-to-video`; a GitHub PR → `/pr-to-video`; captions on existing footage → `/embedded-captions`; a short unnarrated motion graphic → `/motion-graphics`. If the user says only "make a video" or the route is uncertain, read `/hyperframes` first. -All artifacts go to `PROJECT_DIR = videos//` (created in Step 0); all paths below are relative to it. Dispatch is harness-portable: before the first subagent dispatch, read `/../hyperframes-core/references/subagent-dispatch.md` once — it maps the dispatch verbs (parallel fan-out / background / wait) to your harness's primitives; a concurrency cap below N means waves of the cap size, never fewer workers. **This file is a binding runbook, not background reading**: execute the steps in order and produce every phase artifact with its designated script or agent role — do not substitute a freestyle pipeline, and do not skip a pause step because the request seems clear. A step you cannot perform → stop and report. +You are the orchestrator. Work in `videos//`. Run steps in order and pass each gate before continuing. User-gated steps are Step 0, Step 3, and Step 6. Do every step yourself except Step 5, where you dispatch one sub-agent per frame. Do not put design or motion rules here; those live in the frame-worker sub-agent, `hyperframes-creative`, and `hyperframes-animation`. -| Phase | Execution | Primary artifact | Detailed flow | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | ----------------------------------------- | -| init | Bash | `hyperframes.json` | Step 0 | -| scaffold | Bash (no agent) | `capture/extracted/tokens.json` + `visible-text.txt` | Step 1 | -| scriptwriting | subagent | `narrator_scripts.json` (incl. chosen `stylePreset` + `orientation`) | Step 2 / `agents/scriptwriting.md` | -| design-system | Bash (no agent, deterministic — style = `narrator_scripts.stylePreset`) | `design-system/design.html` + `chunks/` | Step 2b | -| audio | `audio.mjs` in Bash | `audio_meta.json` | `phases/audio/guide.md` | -| visual-design | subagent | `section_plan.md` | `agents/visual-design.md` | -| prep | `prep.mjs` in Bash | `group_spec.json` | `scripts/prep.mjs` | -| captions (deterministic) | `captions.mjs group` -> `captions.mjs html` in Bash (no subagent) | `caption_groups.json` + `compositions/captions.html` | `scripts/captions.mjs` | -| scenes | N x subagent (parallel) | `compositions/scene_*.html` or `compositions/group_w*.html` | `agents/hyperframes-scene.md` | -| finalize (Phase 4c) | Bash prelude (wait-bgm + assemble + inject/verify-transitions + hoist-videos + sfx-verify + preflight) -> finalize subagent (fix brief findings in place + one lean contact-sheet look + render) | `renders/video.mp4` | Step 7 / `agents/hyperframes-finalize.md` | +Workflow: Step 0 setup → `hyperframes.json`; Step 1 brief → `capture/extracted/`; Step 2 design system → `frame.md`; Step 3 storyboard/script → `STORYBOARD.md` and `SCRIPT.md`; Step 3.1 audio → `audio_meta.json`; Step 4 visual design → enriched `STORYBOARD.md`; Step 5 frames → `compositions/frames/NN-*.html` and `index.html`; Step 6 final render → `renders/video.mp4`. -## Prerequisites +--- -macOS Apple Silicon or Linux x64. System tools: `brew install python@3.11 node ffmpeg` (use Homebrew Python, **not** `/usr/bin/python3`, or `pip install` is blocked by PEP 668); then `npx hyperframes doctor` once (downloads Chrome). The rendered overlap gate (`scripts/check-overlap.mjs`, run in worker self-checks and preflight) reuses that same cached Chrome — it never downloads a browser; its only dep is the `puppeteer-core` npm module, ensured once before scene fan-out (Step 5.5, `--ensure-deps`, ~5s, no full `puppeteer` install). Optional cloud keys (else local fallbacks) — inject in Step 0.5: +## Step 0: Setup and Brief -| Key | Used for | Default / fallback | -| ---------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------- | -| `HEYGEN_API_KEY` (or `hyperframes auth login`) | TTS (cloud, word-level timestamps) | voice: auto (first English starfish voice; override `--voice`) | -| `ELEVENLABS_API_KEY` | TTS (cloud; needs `pip install elevenlabs`) | voice `21m00Tcm4TlvDq8ikWAM` (Rachel) | -| neither, and not logged in | TTS | local Kokoro, voice `am_michael` (non-English: pass `--voice`) | -| `GEMINI_API_KEY` / `GOOGLE_API_KEY` (aliases) | Lyria BGM | unset -> local MusicGen (first run downloads ~300 MB) | +Goal: Lock the core video brief and create the HyperFrames project if needed. -## Flow +Initialize only if `hyperframes.json` is missing. Name `` from the topic in kebab-case, such as `compound-interest-explained`; never use workspace name or timestamp. -### Step 0.0 - Confirm the brief (ALWAYS ask one round, then build) +`npx hyperframes init "videos/" --non-interactive --skip-skills --example=blank` -Before Step 0, **always pause and ask the brief in one message, then wait for the user — never skip this, even for a request that looks complete.** Lead with a recommended default for each field and pre-fill anything the user already gave (confirm it rather than re-asking blindly): the **topic / angle** (the one idea), **length** (default ~60-90s), and — if `/hyperframes` did not already set them — **aspect** (default 16:9; 9:16 for vertical) and **language**. Style is **not** asked here — the scriptwriting agent auto-picks the preset from the input in Step 2. Proceed to Step 0 only after the user replies; a "go" / "use the defaults" is a valid reply that accepts every default. +**Gate:** `hyperframes.json` exists, and angle, length, aspect ratio, and language are locked. -### Step 0 - Initialize the video project +--- -cwd is the agent workspace root (e.g. `/tmp/explainer-video-...`). Write all video artifacts under `PROJECT_DIR = videos//`. +## Step 1: Brief (no capture) -``: use the directory the user gave (e.g. `Use ./videos/refactoring-explainer`), else a short kebab-case name derived from the input topic (`-explainer` / `-howto`). **Not** the workspace basename or a timestamp. +Goal: Fold the user's text into the project as the source of information. There is **no website capture and no real assets** — this is a faceless explainer. -Only when `$PROJECT_DIR/hyperframes.json` is absent: +Save the user's full input verbatim, then create the synthetic capture package by hand: -```bash -PROJECT_DIR="${LAUNCH_VIDEO_DIR:-videos/}" -mkdir -p "$(dirname "$PROJECT_DIR")" -npx hyperframes init "$PROJECT_DIR" --non-interactive --skip-skills --example=blank -``` +- `capture/extracted/visible-text.txt` — the full article / notes / topic / brief, verbatim. This is the source of **information**, not a story template (Step 3 reshapes it). +- `capture/extracted/tokens.json` — `{ "title": "", "description": "", "colors": [], "fonts": [] }`. Fill `title`/`description` from the brief. Leave `colors`/`fonts` empty unless the user explicitly gave brand colors or fonts — then add them (the design preset supplies a complete palette regardless). -> `hyperframes init` drops a generic `AGENTS.md` / `CLAUDE.md` into `$PROJECT_DIR`; **leave them in place** — they are agent scaffolding for whoever opens the finished project later. This skill (not those files) is the source of truth for the workflow, so do not treat their generic guidance as run-time constraints. +Do **not** run `npx hyperframes capture` (there is no URL). Do not create `asset-descriptions.md` or populate `capture/assets/` — faceless visuals are invented in Steps 4-5, not captured. The one exception: if the user supplied a real image, place it under `public/` and note it for Step 3. -**Constraints:** never run `hyperframes init` / generate `AGENTS.md` / `CLAUDE.md` in the workspace root; never nest another `hyperframes/` inside `PROJECT_DIR`; every Bash command (master + subagents) is a `(cd "$PROJECT_DIR" && ...)` subshell — never bare `cd`. +**Gate:** `capture/extracted/visible-text.txt` and `capture/extracted/tokens.json` exist; you can state the explainer's topic and audience in one clear sentence. -### Step 0.5 - API key guidance +--- -Skip if `$PROJECT_DIR/.env` exists or `context.log` is non-empty (= not the first run). Otherwise **first detect what's available** (HeyGen TTS on if `$HEYGEN_API_KEY` / `$HYPERFRAMES_API_KEY` set or `~/.heygen/credentials` exists from `hyperframes auth login`; ElevenLabs / Gemini only if their env keys set), then **always pause and offer the menu — wait for the user; do not proceed on your own even when a workable config is detected** (the user may want to add a key like Gemini). State what's detected, then: paste keys (→ Write `$PROJECT_DIR/.env`, one `KEY=value` per line, overwrite same-name) / "go" (proceed with what's configured — env, `.env`, or `hyperframes auth login`) / "skip" (proceed with local fallbacks for anything unconfigured). Then proceed to Step 1. +## Step 2: Design System -### Step 1 - Scaffold (Bash, NO agent, NO capture) +Goal: Choose one shipped frame preset; a script turns it into this video's `frame.md` + caption skin. -There is no website capture. Synthesize the minimal on-disk package the copied backend (`build-design --capture`, `prep --capture`) expects, directly from the user's text. `capture/` holds synthetic tokens + the input text (NOT a scrape); `capture/assets/` stays empty (faceless). With `colors:[]`, build-design uses the pin-and-paper native palette; if the user supplied brand colors, fill `colors[]` (`colors[0]` becomes the brand primary). +You make the one judgment call — **which preset**. Read `../hyperframes-creative/references/design-spec.md` and browse `../hyperframes-creative/frame-presets/`; pick the preset whose look best fits the topic, tone, and audience. Then run: ```bash -(cd "$PROJECT_DIR" && mkdir -p capture/extracted capture/assets) -(cd "$PROJECT_DIR" && cat > capture/extracted/tokens.json <<'JSON' -{ "title": "", "description": "<one-line>", "colors": [], "fonts": [], "headings": [], "sections": [], "ctas": [], "svgs": [], "cssVariables": {} } -JSON -) -(cd "$PROJECT_DIR" && printf '%s\n' "<full input text / article / notes / brief>" > capture/extracted/visible-text.txt) -``` - -Validation: - -```bash -[ -s "$PROJECT_DIR/capture/extracted/tokens.json" ] && \ -[ -s "$PROJECT_DIR/capture/extracted/visible-text.txt" ] && \ -[ -d "$PROJECT_DIR/capture/assets" ] && echo ok || echo missing -``` - -If any is missing, report and stop. - -### Step 2 - Scriptwriting (subagent — also picks the style preset) - -Dispatch one subagent. prompt = full contents of `agents/scriptwriting.md` + the `## Dispatch context` below, passed through verbatim: - -``` -SKILL_DIR: <absolute path> -PROJECT_DIR: <video project root> -Schema validator: <SKILL_DIR>/scripts/validate-narrator.mjs -Input text: ./capture/extracted/visible-text.txt # The source article / notes / brief — the agent reads this first -Style preset: pick one from the menu in the guide and emit it as the top-level `stylePreset` (default `pin-and-paper` when unsure); match the narration register to the chosen preset -Orientation: <landscape | portrait | square> # From the Step 0.0 aspect (16:9→landscape, 9:16→portrait, 1:1→square; default landscape). Emit it VERBATIM as the top-level `orientation` field — this is dictated, not a creative choice; it sets the canvas (portrait→1080×1920) for the whole pipeline. -Script style: Keep each scene's script concise — 1-2 sentences, no more than 20 words +node <SKILL_DIR>/scripts/build-frame.mjs --preset <name> --hyperframes . ``` -> Fill the `Orientation:` line from the aspect confirmed in Step 0.0 (default `landscape`). prep reads `narrator_scripts.orientation` → stamps `group_spec.width/height`; without it the video stays 16:9. +The script does the rest deterministically: copies the preset's `FRAME.md` → `frame.md` and **remixes** it onto any brand tokens in `capture/extracted/tokens.json` (brand colors mapped onto the preset's color keys by role; the preset's display + body fonts swapped for the brand's), copies the preset's `caption-skin.html` verbatim, and self-validates (exits 1 on a broken mapping). Proceed as soon as it exits 0 — no hand-editing of the spec. -The agent picks an explainer **structure** for `narrativeArchetype` (`concept-explainer` / `how-to-process` / `listicle` / `story-explainer`, or `"<outer> with <inner>"`), picks a top-level **`stylePreset`** from the 5 shipped presets (consumed by Step 2b), echoes the dispatched **`orientation`** as a top-level field (consumed by Step 5 prep → canvas size), and emits `narrator_scripts.json` (it runs the validator before returning). `continuity` drives worker grouping: `continue` = same worker as the previous scene (a run of **up to 3** scenes, cap=3); `break` = new worker; scene 1 is always `break`. `intent` / `sharedMotif` are soft hints. `assetCandidates` is `[]` on essentially every scene (faceless). +A faceless explainer usually has **no brand colors/fonts** (`tokens.json` colors/fonts empty) → the script keeps the preset's own palette, a complete shippable design. Only when the user named brand colors/fonts add them to `tokens.json` before running, and only adjust `frame.md` by hand afterward if a mapping truly needs it. -### Step 2b - Design system (Bash, NO agent, deterministic — style chosen by Step 2) +**Gate:** `build-frame.mjs` exited 0 — `frame.md` exists from a named preset, and (when the preset ships one) `caption-skin.html` is at the project root. -Read the agent's `stylePreset` from `narrator_scripts.json` (default `pin-and-paper` if absent), then run three deterministic commands to produce a fully-styled `design.html` + chunks against the synthetic input: +--- -```bash -STYLE=$(cd "$PROJECT_DIR" && node -e 'try{const p=require("./narrator_scripts.json").stylePreset;process.stdout.write((p&&String(p).trim())||"pin-and-paper")}catch{process.stdout.write("pin-and-paper")}') -(cd "$PROJECT_DIR" && node <SKILL_DIR>/phases/design-system/scripts/build-design.mjs ./design-system --no-emit --style "$STYLE") -(cd "$PROJECT_DIR" && node <SKILL_DIR>/phases/design-system/scripts/build-design.mjs ./design-system --style "$STYLE") -(cd "$PROJECT_DIR" && node <SKILL_DIR>/phases/design-system/scripts/emit-chunks.mjs ./design-system) -``` +## Step 3: Storyboard and Script -`stylePreset` must be one of the 5 shipped presets (`block-frame` / `capsule` / `claude` / `pin-and-paper` / `scatterbrain`); an unknown name makes `build-design.mjs` exit 1 — fall back to `pin-and-paper` and rerun. This step depends only on `narrator_scripts.json`, so it may run in parallel with Step 3 audio; both must finish before Step 4 visual-design. +Goal: Turn the text into an approved frame-by-frame teaching plan. -Validation: +Read `references/story-design.md`, `../hyperframes-core/references/storyboard-format.md`, and `../hyperframes-core/references/script-format.md`. Use them to write `STORYBOARD.md` and, when narration is needed, `SCRIPT.md`. -```bash -[ -s "$PROJECT_DIR/design-system/inference.json" ] && \ -[ -s "$PROJECT_DIR/design-system/design.html" ] && \ -[ -s "$PROJECT_DIR/design-system/chunks/index.json" ] && echo ok || echo missing -``` +Use `story-design.md` for the explainer structure (concept / how-to / listicle / story), hook strategy, clarity techniques, emotional beats, the type-enum mapping, and `VO_MODE`. The video's sequence comes from **narrative design, not the input text's paragraph order** — reorder, merge, omit, compress. Faceless visuals are invented downstream, so frames do **not** carry an asset inventory: leave `asset_candidates` empty unless the user supplied a real `public/<basename>` image. Use the exact required fields from the storyboard and script references. -If any is missing, read the build-design / emit-chunks stderr, fix the invocation, and rerun (deterministic, finishes in seconds). +After drafting, show a frame-by-frame summary. In that same message ask the user two things: (a) to approve or request changes, and (b) whether they want a live preview of the storyboard scaffold (`npx hyperframes preview`) — open it only on a yes. Iterate until approved, and carry the preview choice to Step 6. -### Step 3 - Audio +**Gate:** `STORYBOARD.md` exists, every frame has the required narrative fields, `SCRIPT.md` exists when narration is needed, and the user approved the frame-by-frame plan. -After `narrator_scripts.json` exists: - -```bash -(cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/audio.mjs \ - --narrator-scripts ./narrator_scripts.json \ - --hyperframes . \ - --out ./audio_meta.json \ - --lyria-recipe <SKILL_DIR>/phases/audio/lyria-recipe.py) -``` +--- -BGM generation runs detached in the background when keys/deps allow, otherwise is silently skipped. Flags + BGM mechanics: top of `audio.mjs`. +## Step 3.1: Audio -- exit 0 -> voice + transcribe complete (BGM may still be rendering; `audio_meta.json` records `bgm_log` / `bgm_pid`), continue. -- exit 1 -> zero scenes produced voice; report and stop. +Goal: Generate narration, word timings, music, and audio metadata from the approved script. -### Step 4 - Visual design +Start audio after Step 3 approval. Run it in the background, then continue to Step 4. -After `design-system/chunks/index.json`, `narrator_scripts.json`, and `audio_meta.json` exist, concatenate all inputs into one dispatch packet (contracts first, static references middle, work items last): +`node <SKILL_DIR>/scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json &` -```bash -# Dispatch packets live in $PROJECT_DIR/.dispatch/ (transient; safe to delete after the run). -# NEVER use a fixed /tmp path: it persists across runs/projects, so a failed write silently -# reuses another project's stale packet and contaminates every worker. -mkdir -p "$PROJECT_DIR/.dispatch" -DP="$PROJECT_DIR/.dispatch/vd-dispatch.txt" -{ - echo "## Design chunks" - (cd "$PROJECT_DIR" && cat design-system/chunks/index.json \ - design-system/chunks/composition-hints.md design-system/chunks/voice.md \ - design-system/chunks/tokens.css design-system/chunks/easings.js 2>/dev/null) - echo "## Effects catalog"; cat <SKILL_DIR>/phases/visual-design/effects-catalog.md - echo "## Design rules"; cat <SKILL_DIR>/phases/visual-design/rules/{typography,color-system,composition,motion-language}.md - echo "## SFX library"; cat <SKILL_DIR>/assets/sfx/manifest.json - echo "## Narrator scripts"; (cd "$PROJECT_DIR" && cat narrator_scripts.json) - echo "## Audio meta"; (cd "$PROJECT_DIR" && cat audio_meta.json 2>/dev/null) # Optional; overrides Duration if drift >10% -} > "$DP" -# Guard: a partially-failed build must fail LOUDLY here, not downstream in the subagent -grep -q '^## Narrator scripts' "$DP" || { echo "FATAL: vd-dispatch.txt incomplete — rebuild before dispatching"; } - -# Captions planning hint (put it in the Captions: line of the dispatch below) -(cd "$PROJECT_DIR" && node -e 'try{const m=require("./audio_meta.json");process.stdout.write(Object.values(m.scenes||{}).some(s=>s.wordsPath)?"enabled":"disabled")}catch{process.stdout.write("enabled")}') -``` +The audio script handles narration, word timings, BGM lookup from HeyGen's music library, and timing metadata. BGM mood comes from the storyboard's `music:` field. This uses the HeyGen Audio API for retrieval, not generation, and the same `~/.heygen` credential as TTS. For provider details, read `../hyperframes-media/references/tts.md`. -Then dispatch the visual-design subagent. prompt = full contents of `agents/visual-design.md` + the `## Dispatch context` below, verbatim: +If there is no narration and no `SCRIPT.md`, skip voice generation. BGM may still run if the storyboard has a music mood. -``` -SKILL_DIR: <absolute path> -PROJECT_DIR: <video project root> -Schema validator: <SKILL_DIR>/scripts/validate-section.mjs -Canvas: <width>×<height> # default 1920×1080 (16:9 landscape); 1080×1920 (9:16 portrait) or 1080×1080 (1:1 square) if requested upstream (narrator_scripts.orientation/dimensions). Plan layouts for THIS aspect ratio — see composition.md "Portrait & square". -Captions: <enabled | disabled> # Planning hint from the node -e above: enabled => leave the bottom ~17% of canvas height as caption territory in prose -Dispatch packet: <PROJECT_DIR>/.dispatch/vd-dispatch.txt # Step 0 reads it once for all inputs -Visuals: faceless — every scene is typography / abstract graphics / diagram / data-viz invented from the script. assetCandidates is [] for most or all scenes; plan visuals from text, not from captured assets. -``` +**Gate:** audio job has started, or the project is marked silent. -Output is `section_plan.md`. `type-roles.md` and component HTML bodies are not in the packet (worker responsibilities). The `Captions:` line is an optimistic hint; the authoritative gate is `group_spec.captions_enabled` from Step 5. +--- -### Step 5 - prep (deterministic script, NO subagent) +## Step 4: Frame Visual Design -After `section_plan.md` exists: +Goal: Add the visual direction, layout intent, and motion choices to each storyboard frame. -```bash -(cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/prep.mjs \ - --section-plan ./section_plan.md \ - --narrator-scripts ./narrator_scripts.json \ - $( [ -f audio_meta.json ] && echo "--audio-meta ./audio_meta.json" ) \ - --rules-dir <SKILL_DIR>/../hyperframes-animation/rules \ - --capture ./capture \ - --design-system ./design-system \ - --hyperframes . \ - --sfx-lib <SKILL_DIR>/assets/sfx \ - --out ./group_spec.json) -``` +Edit `STORYBOARD.md` in place. Do not create another storyboard. Use `frame.md` as the source of truth for color, type, layout feel, and style. -Merges all upstream artifacts into `group_spec.json` (parse `section_plan` anchors, validate effect/component ids, group by `Continuity` with cap=3, build `visual_clips[]` where a multi-scene continue worker becomes one `group_wN.html`, compute Tier-B `transitions[]` between different visual clips, copy assets/fonts/SFX). `capture/assets/` is empty, so asset-copy is a no-op (faceless). Internal logic: header of `prep.mjs`. +Read `references/visual-design.md`, `references/composition.md`, `references/motion-language.md`, and `../hyperframes-animation/`. Use `visual-design.md` for required frame fields and the required `## Video direction` block. Use `composition.md` for layout, hierarchy, focal points, and the invented-visual treatment. Use `motion-language.md` and `../hyperframes-animation/` for valid effects and blueprint IDs. Do not invent effect names or blueprint IDs. -- exit 0 -> read stdout (scenes / groups / total duration / per-group) and append to `context.log`. -- exit 1 -> stderr names the failing scene + anchor (usually a malformed anchor or unknown effect/transition id); return to Step 4 and re-dispatch visual-design. +For every frame, add required visual and motion fields, including `effects` and `focal` and/or `roles`. Because the explainer is faceless, `focal`/`roles` describe **invented visual elements** (a hero word, a diagram node, a data-viz series), not captured assets. Add one video-wide `## Video direction` block for overall visual direction, motion style, pacing, and design rules. -### Step 5.5 + Step 6 - Captions (deterministic) + scene worker fan-out +Do not change story, script, `transition_in`, or the source text. Do not write HTML in this step. There is **no asset-staging step** — faceless visuals are built by the workers in Step 5. If the user supplied a real `public/<basename>` image, reference it by path in the relevant frame's `focal`/`roles`; otherwise nothing to stage. -**Captions: two deterministic scripts (no subagent), after prep exits 0 and before fan-out:** +**Gate:** every frame has `effects` plus `focal` and/or `roles`; `## Video direction` exists. -```bash -(cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/captions.mjs group \ - --group-spec ./group_spec.json --hyperframes . \ - --tokens design-system/chunks/tokens.css --out ./caption_groups.json) - -(cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/captions.mjs html \ - --hyperframes . --groups ./caption_groups.json \ - --tokens design-system/chunks/tokens.css \ - --inference design-system/inference.json \ - --out compositions/captions.html) -``` +--- -exit 0 = normal. If either prints `captions: skipped (<reason>)`, skip the whole chain: no `captions.html`, assemble won't mount track 12. Skin selection / self-check: top of `captions.mjs html`; for offline, pass `--skin-file`. **Do not** run `npx hyperframes lint` on `captions.html`. +## Step 5: Build Frames -Then ensure the overlap-gate dep **once, from the workspace root** (NOT inside `PROJECT_DIR` — the module must land in the workspace `node_modules/` where every worker and preflight can resolve it): +Goal: Build every storyboard frame as an HTML composition and assemble the playable video. -```bash -node <SKILL_DIR>/scripts/check-overlap.mjs --ensure-deps -# Installs puppeteer-core (module only, no browser download) if not already resolvable; Chrome is -# reused from the hyperframes browser cache. Workers must NOT install it themselves (parallel npm race). -``` +Wait for Step 3.1 audio to finish if audio was started. Then sync durations and fetch SFX; skip both if silent. -Then read `group_spec.json.groups[]` for worker count N. Each worker's self-check runs two scoped machine gates before returning — `captions.mjs keepout --scene` (when captions enabled) and `check-overlap.mjs --scene` (always) — so layout violations are fixed at the source instead of surfacing at preflight. Build the shared header once, then per-worker packets (`film direction` / `tokens` / `easings` / `voice` are identical for every worker): +`node <SKILL_DIR>/scripts/audio.mjs sync-durations --audio-meta ./audio_meta.json --storyboard ./STORYBOARD.md` -```bash -# Same rule as Step 4: packets go in $PROJECT_DIR/.dispatch/, never a fixed /tmp path -# (a stale /tmp file from a previous project survives a failed write and silently -# poisons every worker with the wrong design system). -# `## Film direction` = the film-level invariants from group_spec.film_direction -# (palette system / motion defaults + budget / ambient system / negative list); -# each scene's creative_brief carries only scene-specific deltas on top of it. -mkdir -p "$PROJECT_DIR/.dispatch/scene-dispatch" -{ - echo "## Film direction" - (cd "$PROJECT_DIR" && node -p 'JSON.parse(require("fs").readFileSync("group_spec.json","utf8")).film_direction || ""') - echo "## Tokens/easings/voice" - (cd "$PROJECT_DIR" && cat design-system/chunks/tokens.css design-system/chunks/easings.js design-system/chunks/voice.md 2>/dev/null) -} > "$PROJECT_DIR/.dispatch/scene-shared.txt" -# Guard BEFORE fan-out: the project's own brand token must be present; a contaminated -# packet here costs a full re-author round across every affected worker. -grep -q -- '--brand-primary' "$PROJECT_DIR/.dispatch/scene-shared.txt" || \ - { echo "FATAL: scene-shared.txt incomplete/stale — rebuild before dispatching workers"; } -# Then per worker: shared header + that worker's Scenes YAML -> $PROJECT_DIR/.dispatch/scene-dispatch/w<N>.txt -``` +`node <SKILL_DIR>/scripts/audio.mjs fetch-sfx --storyboard ./STORYBOARD.md --hyperframes .` -Start **N scene workers in parallel** (concurrent background dispatches; a harness concurrency cap below N means waves of the cap size until every worker has run — never fewer workers). prompt = full contents of `agents/hyperframes-scene.md` + `## Dispatch context`, verbatim. Top-level fields: `SKILL_DIR` / `PROJECT_DIR` / `Worker ID` / `Composition width` + `Composition height` (= `group_spec.width` / `group_spec.height`) / `Captions: <enabled|disabled>` (= `group_spec.captions_enabled`) / `Dispatch packet: <PROJECT_DIR>/.dispatch/scene-dispatch/w<N>.txt`, plus the shared header body (`## Film direction` + `## Tokens/easings/voice`) + a `Scenes:` list. +Duration sync is mechanical: real voice duration wins; silent frames keep estimates; never hand-edit synced durations. -For the worker top-level context, copy from `group_spec.json.groups[i]`: `worker_id`, `composition_id`, `composition_file`, `duration_s`, `scene_ids`; and from the top of `group_spec.json`: `width`, `height` (the worker authors + self-checks the root at these dims — landscape 1920×1080 unless portrait/square was requested upstream). **When `Captions: enabled`, also pass `Caption band top y` = `height − round(height × 0.1667)` and `Foreground max y` = `Caption band top y − 20`** (landscape → 900 / 880; portrait → 1600 / 1580) — constraint #13 keep-out is computed from these, not hardcoded. Copy every field in the **`Scenes:` list verbatim from `group_spec.json.groups[i].scenes[<sid>]`** (only that worker's 1-3 logical scenes): `scene_id` / `local_start_s` / `effects` / `rule_paths` / `assetCandidates` / `estimatedDuration_s` / `voicePath` / `design_chunks` (absolute paths to the whole component library — the worker chooses by visual judgment) / `creative_brief`. A 2-3 scene worker writes one `group_wN.html` with true shared DOM across the segments. +Before dispatch, read `sub-agents/frame-worker.md` and `../hyperframes-core/references/subagent-dispatch.md`. Dispatch one sub-agent per frame, in parallel if possible; otherwise run workers in waves. Each worker gets exactly one frame. -`assetCandidates` is `[]` for most or all scenes — the worker invents the visual from `creative_brief` + design chunks; there are no captured assets to place. `design_chunks: null` (chunks missing) → worker falls back to reading `./design-system/design.html` fully; should not happen in the normal path. +Each worker context must include `PROJECT_DIR`, `frame_id`, canvas size, caption status and keep-out band if captions are enabled, and `ANIM_DIR` as the absolute path to `../hyperframes-animation/`. Each worker reads `frame.md`, its own `## Frame N` block from `STORYBOARD.md`, and the recipe body for each cited effect or blueprint ID. Each worker writes only `compositions/frames/NN-*.html`. Workers must never edit `STORYBOARD.md`. -After all workers + captions return, run preflight (scans `group_spec.visual_clips[]`; does NOT check `captions.html`): +As each worker returns, the orchestrator marks that frame as `animated` in `STORYBOARD.md`. -```bash -(cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/check-compositions.mjs \ - --hyperframes . \ - --group-spec ./group_spec.json) -``` +After audio timings exist, build captions in the background and assemble the index: -- exit 0 -> all compositions pass, continue to Step 7. -- exit 1 -> stderr names the violating scene + rule category; return to Step 6 and re-dispatch the affected worker (do not Edit in the master — fix upstream). +`node <SKILL_DIR>/scripts/captions.mjs build --storyboard ./STORYBOARD.md --audio-meta ./audio_meta.json --hyperframes . --out ./caption_groups.json &` -### Step 7 - Assembly prelude + preflight gate + finalize +`node <SKILL_DIR>/scripts/assemble-index.mjs --storyboard ./STORYBOARD.md --hyperframes .` -After Step 6 exits 0: a deterministic Bash prelude (wait-bgm + assemble + inject/verify-transitions + **hoist-videos** + sfx-verify + preflight), then one **finalize subagent** that fixes the brief's findings in place, takes ONE lean contact-sheet look, and renders. Principle: deterministic prelude is all Bash; findings go to finalize (not back to workers); worker re-dispatch is reserved for recomposition. `compositions/scene_N.html` / `group_wN.html` are worker source files; editing them edits the source. +`captions.mjs` uses the project's `caption-skin.html` (copied in Step 2) as the caption look, injecting brand tokens from `frame.md`; with no skin present it renders the built-in default pill. `captions: skipped (<reason>)` is valid. Continue without captions when explicitly skipped. -**(1) BGM wait + assembly (Bash):** +**Gate:** every frame is marked `animated`, `index.html` exists, and captions are built or explicitly skipped. -```bash -(cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/wait-bgm.mjs \ - --audio-meta ./audio_meta.json \ - --hyperframes . \ - --timeout-ms 120000 \ - --interval-ms 2000) -(cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/assemble-index.mjs --group-spec ./group_spec.json --hyperframes .) -(cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/transitions.mjs inject --group-spec ./group_spec.json --hyperframes .) -(cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/transitions.mjs verify --group-spec ./group_spec.json --index ./index.html) -(cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/hoist-videos.mjs --group-spec ./group_spec.json --hyperframes .) -(cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/verify-output.mjs sfx --group-spec ./group_spec.json --index ./index.html) -``` +--- -`inject` only changes the `index.html` shell `data-start`/`data-duration`/`data-track-index`, never visual roots. **`hoist-videos` reads each composition's poster `data-video-src` declarations, measures the poster's rendered rect headless, and mounts the real `<video class="clip">` at the index.html host root with global timing clamped clear of transitions** — the ONLY legal way footage plays, since the runtime never decodes a `<video>` nested in a scene. Internal logic: header of each script. +## Step 6: Finalize -- assemble exit 1 -> names a visual composition (root `data-duration` != group_spec, or file missing) = worker contract break → return to Step 6, re-dispatch that worker, rerun this step. -- inject/verify-transitions exit 1 -> injector bug (prep already validated `transitions[]`) → report, don't roll back workers. -- hoist-videos exit 1 -> a `data-video-src` declaration is invalid (missing file / bad numbers / window too small after transition clamping / poster not measurable) — stderr names the scene + declaration; `Edit` the visual source file (or re-dispatch its worker for a real relayout), then rerun this step. exit 2 -> browser unavailable; run `node <SKILL_DIR>/scripts/check-overlap.mjs --ensure-deps` from the workspace root, then rerun. exit 0 prints one line per hoisted video (src, global window, track, rect). -- sfx-verify exit 1 -> assembler bug → report. +Goal: Verify the assembled video, get user approval, and render the final MP4. -**(2) Preflight gate (Bash):** +Inject transitions, run checks, pause for review, then render. -```bash -(cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/preflight-finalize.mjs --group-spec ./group_spec.json --hyperframes .) -``` +`node <SKILL_DIR>/scripts/transitions.mjs inject --storyboard ./STORYBOARD.md --hyperframes .` -preflight does everything the agent does not need to judge and writes it all into `finalize_brief.json`: warms a pinned `npx hyperframes@<version>` cache, runs lint/validate/inspect with that version (**inspect runs STRICT — no `--tolerance` flag, CLI default**; by-design transient overflow from 3D morph / tilt / zoom peaks is declared per-element with `data-layout-allow-overflow`, never absorbed numerically — any re-run of inspect elsewhere must also be plain or verdicts disagree) and captures tails + summary counts, computes the snapshot timeline, runs **`check-overlap.mjs`** (the single-rule rendered overlap gate: every scene loaded headless, timeline seeked to 0.4/0.7/0.92 of duration, all non-background paint atoms flattened onto one plane with z-index ignored, pairwise-intersected; persistent overlap = a finding finalize must fix; `status: unavailable` blocks at exit 2 — the gate never soft-skips), and when `captions_enabled` runs `captions.mjs keepout` static check for "foreground lower edge y <= 900" (the bbox math folds in CSS transforms AND `margin-top`/`margin-bottom`). **Keep-out violations include ready-to-apply Edit strings** (`edit_old`/`edit_new`) and **overlap violations carry both selectors + both rects + the overlap rect** — finalize consumes both directly and fixes them in place. Brief fields (`preflight_clean` / `gates_clean` / `gates.*` / `bgm.*` / `overlap.*` / `caption_keepout.*` / `anomalies[]` / `snapshot_times_s[]` / `npx_prefix` / `scenes[]` / `internal_seams[]`) and algorithm details are documented at the top of `preflight-finalize.mjs`. Only contrast and cramped-container remain eye-owned (finalize's one contact-sheet scan); collision / panel-bleed are machine-owned by the overlap gate. +`node <SKILL_DIR>/scripts/transitions.mjs verify --storyboard ./STORYBOARD.md --index ./index.html` -**Exit codes (orchestrator must read them)**: +`npx hyperframes lint` -- **exit 0** -> dispatch finalize — **clean or not**. Findings (gate errors / `overlap.violations[]` / `caption_keepout.violations[]`) ride in the brief and finalize fixes them in place as its first work step. Do NOT diagnose them yourself, do NOT hand-Edit visual source files, do NOT re-dispatch workers for them. -- **exit 2** -> ONLY when the overlap gate could not run (`overlap.status: "unavailable"` — puppeteer-core / Chrome missing). Environment problem with a deterministic remedy: run `node <SKILL_DIR>/scripts/check-overlap.mjs --ensure-deps` from the workspace root (and `npx hyperframes doctor` if it names Chrome), then rerun preflight — do not proceed unmeasured. -- **exit 1** -> preflight itself crashed (bad invocation / missing group_spec) → fix the invocation. +`npx hyperframes validate` -**Worker re-dispatch (Repair Mode) is the EXCEPTION path now, not a preflight branch:** it triggers only when **finalize STOPs** because a scene needs recomposition (content fundamentally wrong / real relayout / animation broken beyond a couple of edits). Then: re-dispatch that scene's owning worker (a group worker owns every logical scene in its `group_wN.html` — dispatch it once with all its findings) with the full `agents/hyperframes-scene.md` + normal dispatch context + a `## Repair context` block carrying finalize's verbatim findings, `npx_prefix` from the brief, `Inspect at: <t1,t2,t3>` (that scene's `midpoint_s` + extras from `brief.scenes[]`), and `Captions: enabled|disabled`; the worker Edits in place and self-verifies (scoped plain `inspect --at` + `check-overlap.mjs --scene` + keepout) per the contract's Repair Mode section. After it returns, rerun (1)+(2) and re-dispatch finalize. If the same finding survives two rounds, STOP and surface it to the user. +`npx hyperframes inspect --strict-layout` -Scan `anomalies[]` even on exit 0 (loud non-blocking warnings; currently rare — read each entry's `message` and decide whether it changes the dispatch). +`npx hyperframes snapshot --at <frame-midpoints>` -**(3) Dispatch finalize subagent (fix brief findings in place -> ONE lean contact-sheet look -> render)**. prompt = full contents of `agents/hyperframes-finalize.md` + `## Dispatch context`: +If a command fails, surface stderr and stop. Do not pile on recovery commands. If a gate names a frame, fix `compositions/frames/NN-*.html` with the cheapest safe fix: edit the frame HTML for a local issue; re-dispatch the frame worker only when the whole shot must be rebuilt. -``` -SKILL_DIR: <absolute path> -PROJECT_DIR: <video project root> -Render quality: high # Or draft / standard -Finalize brief: <PROJECT_DIR>/finalize_brief.json # Preflight has already written it; agent reads once to get findings + npx_prefix + scene timings -Film direction: | # = group_spec.film_direction (film-level invariants the briefs assume) - <verbatim> -Visual clips: # One line per group_spec.visual_clips[] entry - - { id, file, kind, worker_id, scene_ids, start_s, duration_s } -Scenes: # One line per logical scene, copied verbatim from group_spec.json - - { scene_id, start_s, estimatedDuration_s, effects: [...], creative_brief: | - <Phase 3 prose for this scene> } -``` +After checks pass, pause for user review. The video is assembled, viewable, and editable in Studio. Manage preview only once across Step 3 and Step 6: open it if the user asked earlier, offer it if they declined earlier, and do not ask again if they are already reviewing in Studio. -`index.html` is already assembled (transitions injected, videos hoisted); all gates have already run. Finalize's flow: **fix every brief finding in place first** (gate `output_tail` -> Edit + rerun only that gate; `overlap.violations[]` -> Edit per the given selectors/rects + scoped `check-overlap --scene` verify; `caption_keepout.violations[]` -> apply `edit_old`/`edit_new` mechanically), then **ONE snapshot call at scene midpoints + group-internal seam mids, one read of the contact sheet** (looking only for blank/black panels, cut or unreadable text, crushed interiors, broken internal seams — escalate single frames only on suspicion), then **render + verify-render**. No per-frame QA walkthrough. **Finalize must never change a visual root `data-duration`** (= `visual_clips[].duration_s`, fixed upstream; changing it makes assemble fatal — timing is only fixable by returning to Step 6). +Preview: `npx hyperframes preview` -- finalize reports the mp4 (verify-render passed) + gate status + findings fixed + lean-pass summary + files repaired in place -> complete. -- finalize STOP (only when a scene needs full recomposition) -> return to Step 6, re-dispatch that worker, rerun (1)+(2), re-dispatch finalize. This is an exception path, not the default. +Render only after user approval: -### Completion report +`npx hyperframes render --quality high --output renders/video.mp4` -Summarize per phase: input title / topic, preset (auto-picked by scriptwriting from the 5 shipped presets), explainer structure, scene count / total duration, worker grouping, transitions, gate status (lint / validate / inspect strict / overlap), hoisted videos (count + tracks), findings fixed in place, lean pass (tiles scanned, escalations), visual files repaired in place, final mp4 path + bytes + duration. +Do not rerun `lint`, `validate`, `inspect`, or `snapshot` after rendering unless the user asks. -**Offer a live preview — never auto-open one.** The deliverable is the mp4 above. A browser preview is optional and **must not be started until the user asks for it**. Do NOT run `hyperframes preview` / `play` during any earlier phase: a preview opened mid-run shows half-edited compositions and dies when that phase's own snapshot/render server is torn down, which confuses more than it helps. End the report with a single offer line, e.g.: +**Gate:** `lint`, `validate`, and `inspect` passed before render; user approved at the review pause; `renders/video.mp4` exists. Final reply states MP4 path and final duration. -> Optional: I can open a live preview so you can scrub frame-by-frame, change playback speed, or get a shareable link — say the word and I'll start it. +--- -Only **after** the user asks, start a long-lived dev server (it serves the final on-disk files and stays up until stopped), then report the actual URL with the real port + project name: +## Quick Reference -```bash -(cd "$PROJECT_DIR" && npx hyperframes preview) # Studio UI, e.g. http://localhost:3002/#project/<project-name> -# or a lightweight shareable player link instead: -(cd "$PROJECT_DIR" && npx hyperframes play) # plain http://localhost:<port> -``` +**Formats:** landscape `1920x1080` by default; portrait `1080x1920`; square `1080x1080`. Set the format once in the storyboard frontmatter. -Flags (custom port, external browser) live in the `hyperframes-cli` skill (`references/preview-render.md`). +**Faceless deltas vs a captured-asset workflow:** no Step 1 capture (synthetic `tokens.json` + `visible-text.txt`); no `asset-descriptions.md` and no `capture/assets/`; no asset-staging in Step 4; `asset_candidates` empty by default; every visual is invented by the Step 5 workers (typography / abstract graphics / diagrams / data-viz). A user-supplied `public/<basename>` image is the only real asset path. ---- +**Background scripts:** the workflow ships only these under `scripts/`: `build-frame` for adopting + brand-remixing a frame preset into `frame.md` (+ caption skin); `audio` for TTS, transcription, BGM, SFX, and duration syncing; `captions`; `transitions` for inject and verify; and `assemble-index`. Everything else is the `hyperframes` CLI. -## Resume table - -Read `$PROJECT_DIR/context.log` and resume from: - -| State | Continue from | -| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| log missing or empty | Full pipeline | -| `capture/extracted/tokens.json` **or** `visible-text.txt` missing | Step 1 (scaffold) | -| scaffold done, `narrator_scripts.json` missing | Step 2 (scriptwriting). If the user supplied a final `narrator_scripts.json`, place it in `$PROJECT_DIR/` to skip this state (add a top-level `stylePreset`, or Step 2b defaults to `pin-and-paper`) | -| `narrator_scripts.json` exists, `design-system/chunks/index.json` missing | Step 2b (design-system; `--style` = `narrator_scripts.stylePreset`, default `pin-and-paper`) | -| `narrator_scripts.json` exists, `audio_meta.json` missing | Step 3 (audio) | -| `audio_meta.json` exists, `section_plan.md` missing | Step 4 (visual-design) | -| `section_plan.md` exists, `group_spec.json` missing | Step 5 (prep) | -| `group_spec.json` exists, any `visual_clips[].file` missing **or** `caption_groups.json` missing | Step 5.5+6 (run `captions.mjs group` -> `html`, then dispatch workers for missing clips). Captions-ran criterion = `caption_groups.json` exists (NOT `captions.html`, since a legal skip produces none) | -| all `visual_clips[].file` exist + captions decided, `renders/video.mp4` missing | Step 7 (rerun assemble + sfx-verify + preflight, overwriting `finalize_brief.json` / `index.html`, then dispatch finalize) | -| `renders/video.mp4` exists | Report completed and stop | - -## Directory shape - -```text -./ # workspace root -├── .claude/skills/ -├── node_modules/ package.json -└── videos/<project-name>/ # PROJECT_DIR - HyperFrames project root - ├── hyperframes.json context.log - ├── capture/ # synthetic package (NOT a scrape) — kept for backend layout compatibility - │ ├── extracted/ # tokens.json (synthetic) + visible-text.txt (the input text) - │ └── assets/ # empty (faceless) - ├── design-system/ # build-design outputs: inference.json / design.html / chunks/ / fonts/ - ├── narrator_scripts.json audio_meta.json section_plan.md group_spec.json - ├── public/ assets/ compositions/ snapshots/ - └── renders/video.mp4 -``` +| Read | When | +| ------------------------------------------------------------------------------------------------------------ | --------------------------------------------- | +| `[../hyperframes-creative/frame-presets/](../hyperframes-creative/frame-presets/)` | Step 2: choose and adopt a frame preset. | +| `[../hyperframes-creative/references/design-spec.md](../hyperframes-creative/references/design-spec.md)` | Step 2: apply brand tokens correctly. | +| `[references/story-design.md](references/story-design.md)` | Step 3: plan the explainer story. | +| `[../hyperframes-core/references/storyboard-format.md](../hyperframes-core/references/storyboard-format.md)` | Step 3: write `STORYBOARD.md`. | +| `[../hyperframes-core/references/script-format.md](../hyperframes-core/references/script-format.md)` | Step 3: write `SCRIPT.md`. | +| `[../hyperframes-media/references/tts.md](../hyperframes-media/references/tts.md)` | Step 3.1: choose or understand TTS providers. | +| `[references/visual-design.md](references/visual-design.md)` | Step 4: enrich the storyboard visually. | +| `[references/composition.md](references/composition.md)` | Step 4: judge composition. | +| `[references/motion-language.md](references/motion-language.md)` | Step 4: judge motion language. | +| `[../hyperframes-animation/](../hyperframes-animation/)` | Step 4: cite effect and blueprint IDs. | +| `[sub-agents/frame-worker.md](sub-agents/frame-worker.md)` | Step 5: dispatch per-frame workers. | +| `[../hyperframes-core/references/subagent-dispatch.md](../hyperframes-core/references/subagent-dispatch.md)` | Step 5: dispatch sub-agents safely. | diff --git a/skills/faceless-explainer/agents/hyperframes-finalize.md b/skills/faceless-explainer/agents/hyperframes-finalize.md deleted file mode 100644 index 1c5761810e..0000000000 --- a/skills/faceless-explainer/agents/hyperframes-finalize.md +++ /dev/null @@ -1,187 +0,0 @@ -# Subagent Prompt: hyperframes-finalize (Step 7 — fix brief findings in place → one lean visual pass → render) - -**INPUT:** `<PROJECT_DIR>/index.html` (assembled by `assemble-index.mjs`, transitions injected, videos hoisted by `hoist-videos.mjs`, passed `sfx-verify`) · `<PROJECT_DIR>/finalize_brief.json` (written by `preflight-finalize.mjs`: gate results + findings + pinned `npx_prefix`) · `<PROJECT_DIR>/compositions/*.html` (worker output = visual source files: `scene_N.html` or `group_wN.html`) · Dispatch `Visual clips:` list (`id` / `file` / `scene_ids` / `start_s` / `duration_s`) · Dispatch `Scenes:` list (`scene_id` / `start_s` / `estimatedDuration_s` / `effects` / `creative_brief` for each logical scene) · `Film direction` (film-level invariants — palette system, motion budget, ambient system, negative list; per-scene briefs are deltas that assume it, so judge the contact sheet against both) · `Render quality` -**OUTPUT:** `<PROJECT_DIR>/renders/video.mp4` (passes `verify-render`) · in-place fixed visual source files under `compositions/` · `<PROJECT_DIR>/snapshots/contact-sheet.jpg` -**TOOLS:** Bash (`(cd "$PROJECT_DIR" && <npx_prefix> snapshot|render)`, `node verify-output.mjs render`) · `Edit` (fix visual source files in place) · Skill `hyperframes-core` / `hyperframes-animation` as needed (when changing a visual composition, Read the corresponding reference / rule as needed; **do not load everything up front**) -**DONE:** mp4 passes `verify-render` → report + append to `<PROJECT_DIR>/context.log` - -> **Harness note:** "Skill `X`" = load skill X via your harness's skill mechanism; without one, read `<SKILL_DIR>/../X/SKILL.md` directly. `Read` / `Edit` / `Bash` are capability names — use your harness's equivalent tools. - -You are Phase 4c finalize, responsible for carrying the already assembled `index.html` through to a qualified mp4 **fast**. Preflight does not block on findings anymore — **you are the single repair surface**: the brief hands you every machine finding (gate errors, overlap violations, keep-out Edits), you fix them in place, take ONE lean look at a contact sheet, and render. No elaborate per-frame QA walkthrough. **First thing: Read `finalize_brief.json`.** Run every CLI call through a `(cd "$PROJECT_DIR" && <npx_prefix> ...)` subshell (**`brief.npx_prefix` is a pinned `npx --yes hyperframes@<version>` with a warmed cache**; do not replace it with bare `npx hyperframes`, which makes the cache unstable). - -**BGM:** only read the `bgm` field in the brief; do not `ls assets/bgm.wav`, `ps`, or tail the BGM log. `bgm.ready=false` is not a visual repair task; render can continue. - -## Core Principle: Default to One Correct In-Place Fix, Not Rollback and Redispatch - -- **Do not read, edit, or reassemble `index.html`** (it has already been assembled by `assemble-index.mjs`, injected with inter-worker visual transitions by `transitions.mjs inject`, and machine-verified by `transitions.mjs verify`). If it is wrong (timing / track / playback order), that is an upstream bug (worker `data-duration`, or `group_spec`) — do not patch it here; STOP and let the orchestrator fix upstream + reassemble. **Inter-worker transitions (crossfade/push/etc.) have already been injected and verified; do not hand-edit transition timing / track / GSAP**. If a transition is broken, it is an injector bug → rerun `transitions.mjs inject`; do not patch visual source files to compensate. -- **You fix the relevant visual source file (`compositions/scene_N.html` or `compositions/group_wN.html`) — the worker source file, not a generated artifact.** Use `brief.caption_keepout.violations[].file`, gate output, or the dispatch `Visual clips:` mapping to locate it. -- **Problem found = identify root cause + one `Edit` that correctly fixes that visual source file + rerun only that frame's snapshot / only the affected gate.** For local problems, fix in place once; do not roll back and redispatch the entire worker. -- **Only STOP for the orchestrator to redispatch a worker when "recomposition is required":** the whole scene content is fundamentally wrong, multiple primary subjects need a real relayout, or the animation logic is broken beyond one or two local edits. This is the exception, not the default. -- The orchestrator has already run `check-compositions.mjs` (Step 6) + `assemble-index.mjs` + `transitions.mjs inject/verify` + `hoist-videos.mjs` + `verify-output.mjs sfx` + `preflight-finalize.mjs` (Step 7 (1)(2)) — **do not rerun these** (exception: re-run `hoist-videos.mjs` after changing a `data-video-src` declaration, per the Step 3 symptom table). -- **Retry budget on any gate error: 3 strikes on the same `(offender selector, container, measurement)` tuple → STOP and report.** Before each retry, confirm the tuple has changed from the previous round; if not, do not keep editing. Read the offender, container, and measurement from the gate output directly — the `Fix:` line is a hint, never a diagnosis (for `inspect` overflow specifically: see the `data-layout-allow-overflow` notes in `hyperframes-core/references/data-attributes.md`). - -**Before editing a visual source file:** if the change involves selector / timeline / component contracts, first Read `hyperframes-core` (or the relevant effect rule) as needed to confirm the right approach, then Edit. Do not break scope from memory. - -## Step 1: Digest the Brief (First Work Step) - -Read `<PROJECT_DIR>/finalize_brief.json` — get all preflight results in one pass. **Do not** separately rerun lint/validate/inspect (their results are already in the brief). Inspect these fields: - -| Field | Purpose | -| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `preflight_clean` | true → all green (gates + overlap + caption keep-out); skip Step 2 / 2.5 and go directly to Step 3 | -| `gates_clean` | true = all three CLI gates (lint/validate/inspect — inspect runs STRICT, no tolerance) passed | -| `gates.{lint,validate,inspect}.ok / .output_tail` | Diagnostic surface when a gate fails (do not rerun the same gate; a 60-line tail is enough to locate the issue) | -| `overlap.violations[]` | Rendered foreground-overlap findings (z-flattened pairwise bboxes; each carries both selectors + both rects + the overlap rect). Fixing them is YOUR job — see Step 2 | -| `bgm.status / bgm.ready / bgm.message` | Structured conclusion from `wait-bgm.mjs`. Use only for reporting; do not manually inspect processes/logs, and continue render when BGM is not ready | -| `bgm.provider / bgm.mode / bgm.loop_count` | BGM metadata. Restate directly from the brief when reporting; do not reread `audio_meta.json` or `bgm_status.json` | -| `caption_keepout.violations[]` | Static caption-band coverage violations; **each includes `edit_old` / `edit_new` quasi-Edit strings** — see Step 2.5; one-line Edit fixes it, no Read/counting needed | -| `scenes[] / internal_seams[]` | Per-scene `midpoint_s` + per-internal-seam `seam_s` (`group_wN.html` logical boundaries) — Step 3 builds its lean snapshot list from these | -| `npx_prefix` | Reuse this prefix for every CLI call (cache is warm, version pinned) | -| `deterministic_fixes_applied` | Fixes already performed by preflight (such as `caption-overrides.json` shim) — just note them, do not repeat them | - -**Fast path:** `preflight_clean === true` → jump directly to Step 3. **This is the most common path** (workers self-ran the scoped gates at authoring time). With findings, work the table below first (not mutually exclusive; handle all that apply): - -| Finding site | Section | Default action | -| --------------------------------------- | -------- | ---------------------------------------------------------------------- | -| `gates_clean === false` | Step 2 | Inspect `output_tail` → Edit upstream | -| `overlap.violations.length > 0` | Step 2 | Edit per the violation's selectors + rects, re-run `--scene` to verify | -| `caption_keepout.violations.length > 0` | Step 2.5 | Directly Edit using `edit_old` → `edit_new` from the brief | - -## Step 2: Fix Gate + Overlap Findings In Place (When the Brief Carries Any) - -This is normal expected work, not an exception — preflight hands findings to you instead of bouncing them through a worker re-dispatch round. Each failed gate already has its `output_tail` in the brief; each `overlap.violations[]` row already has both selectors, both rects, and the overlap rect. Handle them with the table below (**default to in-place Edit of visual source files**; **do not** rerun the same gate for more output — only consider `(cd "$PROJECT_DIR" && <npx_prefix> <gate> --json | jq ...)` for a structured version if the 60-line tail is not enough to locate the issue): - -| Gate error type | Action | -| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Bad asset path / leading slash `/public/` / wrong basename | `Edit` the path in the visual source file | -| Unscoped selector (`.scene-root` ancestor / `#scene-root` / `[data-composition-id]`) | `Edit` to bare `.s<N>-foo` / `#s<N>-foo`; root styles use `#root` | -| Missing `class="clip"` (GSAP animates clip element visibility/display → lint error `gsap_animates_clip_element`) | `Edit` to add `class="clip"` | -| `font_family_without_font_face` (lint warning: a font name is used without corresponding @font-face) | `Edit` to add an @font-face pointing at the captured `.woff2`, or switch the font to `var(--font-*)` | -| Literal `<template>/<style>/<script>` in comments / attribute order / single-line ↔ multi-line issue (regex false positive) | `Edit` to escape or slightly adjust | -| Timeline not registered / broken sub-comp ref / selector logic bug | Usually one or two lines → `Edit` the visual source file correctly (Read the contract first) | -| By-design overflow (depth-layer intentionally overflows ≤5px, camera zoom peak) — from `inspect` | Add `data-layout-allow-overflow="true"` (or `data-layout-ignore`; `inspect` actually recognizes both attributes) | -| `foreground-overlap` — from `brief.overlap.violations[]` (two foreground boxes intersect in the rendered frame; both selectors + rects + overlap rect given) | Move/shrink one box (or reflow the pair into a flex/grid container) until the rects clear (no opt-out attribute — every pair must clear). Verify with the scoped gate: `(cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/check-overlap.mjs --group-spec ./group_spec.json --hyperframes . --scene <scene_id>)` | -| Editorial low contrast — from `validate` (WCAG-AA non-blocking warning, **only appears in `gates.validate.output_tail`**, never in `inspect`, does not affect `gates_clean`) | **No per-element opt-out** (there is no `data-contrast-allow-low` attribute; no code in the repo reads it). Intentional low contrast → note it in `context.log` and pass by default; only change text/background colors if it is truly a color bug. `--no-contrast` is a CI/preflight-side flag; the finalize agent does not use it here | -| **Whole-scene composition is fundamentally wrong / multiple primary subjects need relayout / animation logic is too broken for one or two local edits** | **STOP → orchestrator redispatches that worker** (exception, not default) | - -After each Edit, rerun only that gate to confirm it passes: `(cd "$PROJECT_DIR" && <npx_prefix> <lint|validate|inspect> 2>&1 | tail -20)`. `inspect` runs STRICT — plain, no `--tolerance` flag (same as the preflight gate); legitimate transient wobble from 3D morph / tilt projections is handled by `data-layout-allow-overflow` on the element, never by adding tolerance. `inspect` warnings do not block by default; serious issues (CTA off-canvas, primary text clipped >30px) should be handled with the table above and noted in `context.log`. - -## Step 2.5: Batch Fix Caption Keep-Out Violations (Only When `caption_keepout.violations.length > 0`) - -**Principle:** the rendered lower edge of any foreground element must be ≤ y=900 (the caption pill occupies the bottom 180px). The static script detects three CSS shapes that push an element's lower edge beyond y > 900, and each violation already includes the computed "what to change, and what to change it to." - -**Transform- and margin-aware:** the calculator accounts for `transform: translate(...)` / `translateY(...)` / `translate3d(...)` with `%` and `px` values AND for `margin-top` / `margin-bottom` (longhand + px-literal shorthand) when computing the visual bottom edge — a negative-margin-centered card is measured at its real bbox, so it no longer false-positives. Rules with `transform: matrix(...) / calc(...) / var(...)` or unresolvable margins are conservatively SKIPPED — so any violation you see is on an element whose geometry was statically resolvable. Still, before applying a `top-plus-height-too-tall` Edit that shrinks `height`, glance at the rule body once: if it mixes `flex` children that depend on the original height to look right, shrinking via the suggested Edit can crush the interior (children pressed to the bottom border — exactly the cramped-container case in Step 3's fix-direction notes). When in doubt, prefer the `top-in-caption-band` Edit (move the element up) over the `top-plus-height-too-tall` Edit (shrink height) — moving preserves interior layout. - -Each `brief.caption_keepout.violations[]` entry is already a **hands-on Edit instruction** — you **do not need to Read that visual file**, and you **do not need to calculate geometry**. Violation fields: - -| Field | Purpose | -| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `file` | Visual source path relative to `PROJECT_DIR` (e.g. `compositions/scene_2.html` or `compositions/group_w2.html`) | -| `selector` | Problematic CSS rule (e.g. `.s2-chips-row`), for confirmation / logging | -| `pattern` | One of three: `bottom-too-small` (`bottom<180`) / `top-in-caption-band` (`top≥900`) / `top-plus-height-too-tall` (`top+height>900`). Determines the script-generated edit shape | -| `principle` | Geometric derivation for the violation (e.g. `1080 - bottom = 1024 > 900`), useful for logs | -| `element_bottom_y` | Current element lower edge y=? (> 900 means violation) | -| `edit_old` | `old_string` for the Edit tool — feed it in **exactly** | -| `edit_new` | `new_string` for the Edit tool — feed it in **exactly**. The three patterns map to different fields: bottom-too-small → change `bottom:`; top-in-caption-band → change `top:`; top-plus-height-too-tall → change `height:` | -| `edit_old_is_unique` | true → Edit directly; false → prepend the `selector` line to `old_string` when editing to create unique context | -| `instruction` | Human-readable full instruction; revisit if something unexpected happens | - -**Default action** (one Edit per violation, **without reading source files**): - -``` -Edit(file_path = "<PROJECT_DIR>/<violation.file>", - old_string = violation.edit_old, - new_string = violation.edit_new, - replace_all = false) -``` - -When `edit_old_is_unique === false` (the same CSS literal appears multiple times in the file): prepend the full `selector` line (including the following `{`) to `old_string`, and prepend the same prefix to `new_string`, to keep the context unique. - -**After editing all violations, run one verification pass** (a pure static script that takes < 1s; **do not rerun lint/validate/inspect** — caption keep-out does not affect those three gates): - -```bash -(cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/captions.mjs keepout --group-spec ./group_spec.json --hyperframes .) -``` - -exit 0 → proceed directly to Step 3. exit 1 → rare (usually fixing one violation revealed another previously occluded violation); treat the newly printed violation as a new instruction and run one more round. - -**`STOP` exception:** a violation's `selector` is clearly a key design-intent anchor (for example, the brief prose says "pinned to canvas bottom"), and the machine-suggested value would break the visual contract in the brief → STOP and report for orchestrator review. Rare — `brief.caption_keepout` is meant to be fixed mechanically by default. - -## Step 3: ONE Lean Visual Pass (Contact Sheet — Not a Per-Frame Walkthrough) - -This is a quick sanity look, not an audit. The machine gates already covered structure, overflow, collision, panel-bleed and keep-out; you are looking for the handful of things only pixels show: **a blank/black panel, missing media, unreadable text, a broken internal seam, an obviously broken frame.** - -1. **One snapshot call** at scene midpoints + group-internal seam midpoints ONLY (do NOT use the full `snapshot_times_s[]` — that is the old exhaustive schedule): - - ```bash - TIMES=$(node -e 'const b=require(process.argv[1]);const t=[...b.scenes.map(s=>s.midpoint_s),...b.internal_seams.map(x=>x.seam_s)];console.log(t.sort((p,q)=>p-q).join(","))' "$PROJECT_DIR/finalize_brief.json") - (cd "$PROJECT_DIR" && <npx_prefix> snapshot --at "$TIMES") - ``` - -2. **Read `snapshots/contact-sheet.jpg` ONCE** and scan every tile for, in order: (a) blank / black / white panel where content should be (worst class — media or mount failure); (b) primary text cut by the canvas or a container; (c) text unreadable against its background (especially an invented graphic / wordmark on a same-tone surface); (d) a card interior crushed against its border (<12px breathing room); (e) at an internal-seam tile (`brief.internal_seams[].seam_s`, a `group_wN.html` logical boundary): the carried `.gN-*` component/diagram shows a reset, duplicate ghost, or pose jump. **Do not open individual frames unless a tile looks wrong.** -3. **Only when a tile looks wrong:** re-snapshot that single timestamp full-size, diagnose with the symptom table below, Edit the visual source file in place, then re-snapshot only that frame. **After any layout Edit, machine-verify instead of eyeballing**: re-run the scoped overlap gate (`node <SKILL_DIR>/scripts/check-overlap.mjs ... --scene <scene_id>`) and, if the edit touched the canvas-bottom area with captions enabled, `captions.mjs keepout --scene <scene_id>`. -4. Nothing suspicious → go straight to Step 4. **Resist re-checking clean tiles** — a second look at a clean frame is wasted round-trip. - -Symptom reference (only for diagnosing a tile that looked wrong): - -| Symptom | Root cause → in-place fix | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Entire film blank / pure background | Bad asset path (`Edit` path); or sub-comp not mounted (inner `data-composition-id` / `window.__timelines` key ≠ scene_id → `Edit` one line to align) | -| A footage panel shows the poster still instead of moving video (or shows nothing) | Hoisted-video issue: `index.html` carries `<video data-hoisted-from="<sid>">` elements emitted by `hoist-videos.mjs` from the scene's `data-video-src` declaration. Showing the poster at a snapshot timestamp is often CORRECT (snapshot may fall outside the clamped video window — check the element's `data-start`/`data-duration` first). A genuinely missing/blank video at an in-window time → verify the declaration in the scene file (src path / offset), then re-run `node <SKILL_DIR>/scripts/hoist-videos.mjs --group-spec ./group_spec.json --hyperframes .`; do NOT hand-write `<video>` anywhere | -| Flash / frame jump / static with no animation | Inner id and timeline key mismatch → `Edit` to align | -| CTA off-canvas / primary text clipped | `Edit` position / scale | -| Dense: multiple subjects fight for the center safe zone | If possible, `Edit` in place (make supporting smaller / lower contrast / move out of primary bbox / reduce motion); **STOP and redispatch only when real relayout is required** | -| A time point shows content from another scene | Playback order is derived from `group_spec` by assembly (correct-by-construction) → if this truly happens, upstream `group_spec` order is wrong; STOP and report | -| Transition seam (`brief.transitions[].seam_mid_s`): transition between visual clips is harsh / black flash / color clash / outgoing composition's exit animation fights the transition | The transition itself has already been injected+verified; **do not edit the transition here**. If the outgoing visual composition **wrote its own exit animation** and it conflicts with the transition → that is a source bug (violates "hold the final frame at the end"); `Edit` that visual file to remove the exit tween. If the transition type itself is unsuitable (color clash should use blur) → report so upstream can change the `**Transition:**` anchor and rerun prep+inject; do not patch it here | -| Internal seam (`brief.internal_seams[].seam_s`): carried component/diagram jumps, resets, duplicates, or loses state inside `group_wN.html` | This is a group timeline/source issue, not a top-level transition issue. `Edit` the corresponding `group_wN.html` so the shared `.gN-*` node persists and evolves through the boundary; avoid deleting/recreating it at the seam | -| Effect is meant to overflow (mark sweep / 3D tilted page card / hacker-flip per-character rotation / camera zoom peak) | Add `data-layout-allow-overflow="true"` to the relevant element (this is a by-design escape hatch, not a bug) | -| Captions enabled and the bottom ~17% (y > 900) caption pill covers a chip / CTA / hero / stat / key text (Step 2.5 static check missed it — the calculator folds in CSS transforms and margins, so a miss is likely **runtime GSAP positioning** or natural flex flow pushing content down) | That element's positioning makes its lower edge fall at y > 900: decrease/increase `top:` / `bottom:` / `transform: translateY()` / `margin-top:` so the lower edge is ≤ 900. After calculating and Editing, **manually** run `captions.mjs keepout` to verify (if this is a newly exposed case, add a "keepout static miss" note to `context.log` for maintainers to extend the script later) | - -Fix-direction notes for the two eye-owned classes (contrast and cramped have no machine gate — the contact-sheet scan in Step 3.2 is their only check): - -- **Illegibility / low contrast**: move the element to a contrasting surface token, or recolor the graphic directly (FE visuals are LLM-authored — you own the paths; there is no captured `asset-descriptions` file to consult); the same applies to a user-provided `assetCandidate` image (move it to a contrasting surface — do not recolor user assets). Depth-stack ghosting on long words → reduce `LAYER_COUNT` to 2 (preferred) or per-layer offset to ≤2px. -- **Cramped / pressed-to-frame** (<12px breathing room, "stuffed" card): root cause is usually a Step 2.5 `top-plus-height-too-tall` Edit that shrank a card without retuning its interior — **preferred fix: restore the original `height:` / `top:`**, then re-run `captions.mjs keepout` to confirm the original was actually fine (the calculator is margin-aware, so a margin-centered card won't re-fire). Otherwise drop a non-essential child or reduce padding / gap / one font tier — never just delete the bottommost content child. - -**Re-application sanity rule:** before applying a `brief.caption_keepout` `edit_old → edit_new` that shrinks `height:` on an element with `transform: translate*(...)` or Y margins — the calculator already folds those in, so the violation is real for that rule; but if the container looks fine to your eye, the violation may be on a sibling element. Verify the selector matches before Editing. - -## Step 4: Render - -```bash -(cd "$PROJECT_DIR" && <npx_prefix> render --quality <quality> --output renders/video.mp4) -``` - -`<quality>` comes from dispatch (default `high`). **Do not add `--strict`** (gates have passed). On failure → inspect the last ~30 stderr lines (bad quality value? missing asset?); **do not blindly retry with different flags**. - -## Step 5: Verify mp4 - -```bash -(cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/verify-output.mjs render --hyperframes . --group-spec ./group_spec.json) -``` - -- exit 0 → done. -- exit 1 → it reports concrete size / duration drift values. Duration drift usually means a sub-comp did not mount (static fallback ran for the full duration) → go back to Step 3 and fix that visual source; size too small → render actually failed, inspect Step 4 stderr. - -## Completion Report - -- Brief summary: `gates_clean` / findings fixed in Step 2/2.5 (each: finding → fix → scoped re-verify status) / any `deterministic_fixes_applied` / `pinned_hyperframes_version` -- BGM: `brief.bgm.status` / `brief.bgm.ready` / `brief.bgm.message` -- **Lean pass:** contact-sheet tile count + verdict per suspicious tile only (clean tiles = one aggregate line, e.g. "9 tiles scanned, 8 clean") -- **Visual files fixed in place: file + what changed** (path / scope / downgrade / escape hatch ...) -- Any (exceptional) worker STOP redispatch + reason -- Render: path / bytes / ffprobe duration / quality -- Unresolved warnings that were allowed through - -Append to `<PROJECT_DIR>/context.log` (generate the timestamp with the machine in UTC; do not hand-write it — avoids inconsistencies with mp4 mtime / other phase line time zones): - -```bash -(cd "$PROJECT_DIR" && cat >> context.log <<EOF - -## Phase 4c: finalize [done $(date -u +%Y-%m-%dT%H:%M:%SZ)] -Gates: lint <status> / validate <status> / inspect <status, strict> / overlap <status> -Lean pass: <n> contact-sheet tiles scanned (<m> escalated) — blank-panel/cut-text/contrast/cramped/internal-seams eye-checked once -Fixes in place: <scene_N/group_wN: what> ... (none if none) -BGM: <brief.bgm.status> (<brief.bgm.message>) -Render: renders/video.mp4 (<size>, <duration>s, quality=<quality>) -EOF -) -``` diff --git a/skills/faceless-explainer/agents/hyperframes-scene.md b/skills/faceless-explainer/agents/hyperframes-scene.md deleted file mode 100644 index fb898059c6..0000000000 --- a/skills/faceless-explainer/agents/hyperframes-scene.md +++ /dev/null @@ -1,430 +0,0 @@ -# Subagent Prompt: hyperframes-scene (Step 6 worker) - -**INPUT:** Dispatch context — top-level: `Worker ID` / `PROJECT_DIR` / `Composition ID` / `Composition file` / `Composition duration_s` / `Composition width` + `Composition height` (canvas size — default 1920×1080 landscape; may be 1080×1920 portrait or 1080×1080 square) / `Captions: enabled|disabled` (when enabled, dispatch also carries `Caption band top y` + `Foreground max y` for the bottom caption-band keep-out; see constraint #13); packet shared header: `## Film direction` (film-level invariants every scene obeys — palette system, type roles, motion defaults + budget, ambient system, film negative list; your `creative_brief` is **deltas on top of it**: apply Film direction wherever the brief is silent, and let the brief win where they conflict) + `## Tokens/easings/voice`; per scene: `scene_id` / `local_start_s` / `effects` / `rule_paths` / `assetCandidates` / `estimatedDuration_s` / `voicePath` / `design_chunks` (includes the full component library — see resource #3 and constraint #11) / `continuity` (`continue` = same worker as previous scene; `break` = new worker, see "Continuous scene groups") / `intent` + `sharedMotif` (SOFT hints only) / `creative_brief` -**OUTPUT:** exactly one visual composition file: `<PROJECT_DIR>/<Composition file>`. Single-scene workers use `compositions/scene_N.html`; multi-scene continue workers use `compositions/group_wN.html`. -**TOOLS:** Read multiple files · Write · Bash (self-check: grep block + scoped keepout/overlap gates) — do **not** load the `hyperframes-core` / `hyperframes-animation` skills; the render contract is inlined below -**DONE:** File written + all self-checks pass → one-line report for the visual composition and its logical scenes; **do not write** `./context.log` - -You are a faceless-explainer Step 6 scene worker, running in parallel fan-out with sibling workers. You cannot see sibling outputs; final assembly happens in Step 7. - -**Path contract:** Dispatch provides `PROJECT_DIR` (the video project root) and `Composition file`. Write exactly that file under `PROJECT_DIR`; do not create a `hyperframes/` subdirectory under `PROJECT_DIR`. - -## Pre-Write Cheat Sheet (scan before typing; saves 15-20% rework) - -1. **Component elements that will be tweened → remove CSS-baked `transform: rotate(...)`; move the tilt into GSAP `rotation`.** CSS transform and GSAP transform on the same element overwrite each other, and the preset tilt signature is lost. See constraint #5b. -2. **Use `gsap.set` for an element's "initial hidden" state, not CSS `opacity: 0` / `display: none`** — leave CSS opacity at 1 and hide via `gsap.set("#sN-foo", { opacity: 0 })` at the top of the timeline, so it animates in correctly under the engine's frame-seek. -3. **Root `<div>` 5 attributes + class + style on the same line** — multi-line is valid HTML, but the self-check regex requires a single-line match. See skeleton. -4. **`group_wN.html` (continue runs) → set `data-layout-allow-overflow="true"` on the composition root AND on every scene-local primary/supporting element at construction.** Cross-segment layout-box unions almost always overflow during morph seams (other-segment elements remain in the DOM at `opacity: 0`). `inspect` measures layout boxes, not visibility — `overflow: hidden` does not suppress it. See `data-layout-allow-overflow` in `hyperframes-core/references/data-attributes.md`. -5. **NEVER write `<video>` in a scene file** — the runtime only drives media that is a direct child of the `index.html` host root; a nested `<video>` renders BLANK (no gate can see it, only per-frame snapshots) and `check-compositions` Rule 6a fatals on sight. Author the poster `<img class="clip">` in the slot and **declare** the footage on it with `data-video-src` — Step 7 `hoist-videos.mjs` mounts the real host-root `<video>` automatically. See constraint #4. -6. **No two foreground boxes may overlap (constraint #10) — machine-checked.** Your self-check runs the rendered overlap gate (`check-overlap.mjs`, z-flattened pairwise bboxes); lay foreground out in flow containers (`flex`/`grid`) and it passes by construction. The budgets that stay author-owned (constraint #10b): interior clearance ≥12px, graphic↔surface contrast, depth-stack ghosting. - -After writing, run the self-check block (grep + two scoped machine gates, at the end). If anything FAILs, fix before reporting. Step 7 preflight uses the same gates; catching it locally saves an 8-13 minute round-trip. - -## Required Resources (read all up front, in parallel where your harness allows) - -1. **Composition contract (inlined — do NOT load the `hyperframes-core` / `hyperframes-animation` skills).** Everything needed for a render-correct sub-composition is here + in your `rule_paths`: - - **`<template>` transport:** each visual composition is a `<template id="<Composition ID>-template">` whose `<head>` is discarded at mount — put all `<style>` + markup + `<script>` **inside** the template (see Skeleton below). - - **Three-way id match (literal strings):** host `data-composition-id="<Composition ID>"` ≡ template id `<Composition ID>-template` ≡ timeline key `window.__timelines["<Composition ID>"]`. Exact match; never a computed/variable key. - - **Build synchronously + paused:** construct the whole `gsap.timeline({ paused: true })` at load (the engine seeks it frame-by-frame); never build it inside a callback / promise / `tl.call()`. - - **`gsap.fromTo`, not `gsap.from`,** for entry tweens — `from` is not seek-safe (seeking back past it leaves the wrong state); `fromTo` gives explicit start+end so every frame seek is correct. - - **Determinism (hard):** no `Math.random` / `Date.now` / `performance.now` / `repeat: -1` / `fetch(` anywhere. Animate **`opacity` / `transform`**, never `display` / `visibility` (they don't tween and break seeking). Initial-hidden via `gsap.set`, not CSS `opacity:0` (cheat-sheet #2). - - **Runtime:** GSAP is the default and is loaded by the harness; a `rule_path` body names another runtime only if it explicitly says so. Your animation recipes are the `rule_path` bodies (item 2) — you need no skill index. -2. **Every** `.md` file in your `rule_paths` list (absolute paths; read all of them) — your per-effect animation recipes (the only thing you need from the animation library) -3. **`design_chunks` field (replaces the old full read of `design.html`):** - - `tokens_file` — the token vocabulary (`--brand-*`, `--cl-*`, `--font-*`, spacing/radius). These are declared **once globally** in `index.html`'s `<head>` by `assemble-index.mjs` and inherit into every mounted scene, so **do NOT paste the `:root` block into your scene** — just reference tokens as `var(--token)`. Skim the inline body in the dispatch packet's `## Tokens/easings/voice` section (or Read this absolute path, ~1 KB) only to see which token names exist. If a scene genuinely needs a different value (e.g. a dark scene flipping `--canvas`), override that single token on your own `#root { ... }` — the local declaration wins by cascade. - - `easings_file` — **prefer the inline body from the packet section** (same as above); Read only if missing, ~0.5 KB. Paste the full `const EASE = { ... }; const DUR = { ... }` block at the top of the scene `<script>`. `creative_brief` only references canonical role keys (`EASE.entry/emphasis/exit/drift`, `DUR.snap/med/slow`). **If the brief references a key not present in the pasted object**: use the semantically closest existing role key (for example `EASE.emphasis`→`EASE.entry`, `DUR.slow`→`DUR.med`), **and note one line in the completion report: `ease-key fallback: <brief key>→<actual key>` — do not silently drop it or hard-code raw curves.** - - `voice_file` — **prefer the inline body from the packet section** (same as above); Read only if missing, ~0.5 KB. Write **all visible DOM text** (headline / chip / button / stat label) in this register: follow the recipe (strip articles, UPPERCASE, sentence breaks, etc.) when rewriting English phrases from the `creative_brief`. **Do not** modify the narrator script associated with `<audio>` (Phase 2 already shaped it for TTS; uppercasing would damage speech rhythm). - - `hints_file` — absolute path \| null. If non-null, read it; ~1-3 KB. It contains preset **composition / material / color preferences** (60-30-10 ratio, signature material, optional background / surface-treatment stanzas). Use it as a **style reference**: the film's 60-30-10 distribution (from `## Film direction`) and constraint #11 `#root` background choices should reference it. This is taste guidance, **not** a hard render contract. - - `type_roles_file` — absolute path \| null (points to a single `type-roles.md` file, not a directory). **Read on demand using this criterion**: first scan `components[]` to see whether there is a text slot that can carry the `creative_brief` text you need (hero display / lede / pill row / CTA button / closing end mark, etc.); **if yes → do not read** (use the component slot directly); **if no → read** `type-roles.md`, find the `t-trole-<id>` section by id, and paste that entire CSS block into the composition `<style>` (rewrite class names with the composition prefix: `s<N>-` for single-scene files, `g<N>-` for shared group nodes). This criterion avoids two waste patterns: reading it for every scene (the catalog is several KB, wasteful across scenes) / failing to read it when needed (missing type role causes degraded text). - - `components[]` — absolute path list for the **entire preset component library** (all pasteable component HTML snippets from the design system). **This is a style reference library, not a "must use all" list** — choose 0-N components that truly fit the current scene/run according to the role description in `creative_brief` ("a stat block", "a framed quote"). **Read only the few components you intend to use** (each 0.3-1.5 KB; no need to read all). Paste used components into the DOM according to the design tokens and the brief's effect→asset mapping, prefixing shared/run classes with `g<N>-` in group files and single-scene classes with `s<N>-` in scene files. A typical scene/run has **one clear focus component family + a little support**; do not cram components in. - - **Do not read** `./design-system/design.html` — chunks have replaced it. If `design_chunks` is null (chunks missing), fall back to reading `./design-system/design.html` and report an anomaly. - -**Do not load:** `hyperframes-cli` / `hyperframes-creative` / `hyperframes-registry` (outside your scope). **Do not read** `section_plan.md` (dispatch already embeds the relevant scene `creative_brief`). **Do not open** rules outside `rule_paths`, other component files, or sibling worker scene files. - -## Constraints Specific to This Skill (Not Separately Covered by hyperframes-core) - -Workers must execute these constraints exactly. The foundational render contract (template transport, three-way id match, synchronous paused timeline, `fromTo`-not-`from`, determinism bans, `opacity`/`transform`-not-`display`) is inlined in **Required Resources #1** above — there is no core skill to read. - -1. **CSS / JS selector — root uses `#root`; internal elements use the composition prefix** - - During render, producer strips the `<div class="<Composition ID>-root">` wrapper (preview/snapshot keep it), so any ancestor selector like `.<Composition ID>-root .foo` breaks completely in render. - - **Rule:** all internal classes / ids use the composition prefix: single-scene file `scene_1` → `s1-foo`; group file `group_w2` → shared/run nodes use `g2-foo`. Selectors are written **bare** as `.s1-foo` / `#s1-foo` or `.g2-foo` / `#g2-foo`; JS is synced: `querySelector(".g2-card")` / `tl.to(".g2-card", ...)`. Root styles are only written as `#root { ... }`. - - **Group exception:** a `group_wN.html` may also use `s<N>-` prefixes for truly logical-scene-only support nodes, but the continuous protagonist/component family should use `g<N>-` and persist in the DOM across the whole group timeline. - - **Forbidden:** `.<Composition ID>-root` / `#<Composition ID>-root` / `[data-composition-id="<Composition ID>"]` / `:root` / bare `body` / bare generic classes (`.card`, etc.) without prefix. - - **When pasting a component:** prefix the HTML outer element + nested classes, and update embedded `<style>` selectors accordingly; do **not** prefix `var(--*)` / `data-*` / `#root` / CSS generic families (`serif`, `sans-serif`). Missing prefix → sibling component bleed. - - ```html - <!-- ❌ inner class missing prefix, selector not synced, var incorrectly prefixed --> - <div class="s3-card"> - <span class="headline">{H}</span> - <style> - .card { - background: var(--accent); - } - .card .headline { - color: var(--s3-ink); - } - </style> - </div> - - <!-- ✅ outer + nested classes prefixed, selectors synced, var unchanged --> - <div class="s3-card"> - <span class="s3-headline">{H}</span> - <style> - .s3-card { - background: var(--accent); - } - .s3-card .s3-headline { - color: var(--ink); - } - </style> - </div> - ``` - -2. **Never copy `@font-face` into a scene** — Step 7 declares it once in `index.html` `<head>`. Inside scenes, only use `var(--font-display|body|mono|script)`; **do not hard-code literal font names** (this bypasses `@font-face`, so the real font will not apply). If `chunks/tokens.css` is missing a role token, do not degrade to a literal family; leave `var(--font-body)` so CSS fallback handles it. -3. **Track lane:** inside scenes use `data-track-index="0"`-`"9"`; `10` / `11` / `12` / `20+` belong to top-level `index.html` (voice / BGM / captions / SFX, all emitted by Step 7 `assemble-index`). **Do not emit `<audio>` in a scene.** -4. **Asset src has no leading slash** — `public/hero.png`, not `/public/hero.png`. - - **Video assets — declared, never embedded.** An `assetCandidate` whose path ends in `.mp4` / `.webm` / `.mov` is a real moving clip (a user-provided video already at `public/<basename>`). **You must NOT write a `<video>` tag** — the framework runtime only seeks/decodes media that is a direct child of the `index.html` host root, so a `<video>` nested in your composition renders **BLANK** at render time and no gate can see it (`check-compositions` Rule 6a `video-in-scene` fatals on sight). Instead, author the slot as a poster `<img>` and **declare** the footage on it: - - ```html - <img - class="s3-demo clip" - src="public/demo-poster.jpg" - data-video-src="public/demo.webm" - data-video-offset="0.6" - data-start="0.2" - data-duration="6" - /> - ``` - - - **Poster `src`** = a matching user-provided still when one exists; otherwise extract one yourself: `ffmpeg -y -ss 1 -i public/<clip> -frames:v 1 public/<clip-stem>-poster.jpg` (Bash is available). The poster is the on-canvas fallback at seams and outside the footage window — it must look correct on its own. - - **`data-video-src`** (required) — relative `public/` path to the clip. **`data-video-offset`** (optional, default 0) — scene-local seconds when footage starts. **`data-video-duration`** (optional) — cap; default plays to scene end. **`data-video-media-start`** (optional) — trim into the source. **`data-video-loop="off"`** (optional) — looping is on by default. - - Step 7 `hoist-videos.mjs` measures the poster's rendered rect in a real browser and mounts the actual `<video class="clip">` at the host root with global timing (clamped clear of scene transitions). **The slot must hold STILL during the declared window** — the hoisted video cannot follow in-scene GSAP transforms; animate the slot's entry/exit OUTSIDE the window (set `data-video-offset` after the entry settles). Source audio never plays (hoisted videos are muted); sound goes through top-level `<audio>` (track 20+) if ever needed. - -5. **GSAP transform alias whitelist:** `x` / `y` / `scale` / `scaleX` / `scaleY` / `rotation` / `opacity`. Never tween `width` / `height` / `top` / `left`. - - **Common first mistake when moving an element to a different bbox** (e.g. relocating a shape from `(720,760,480,6)` to `(200,600,700,4)` — including across a continue seam, constraint #14): the instinct is to write `tl.to(el, { left: 200, top: 600, width: 700, height: 4 })` — **this violates the whitelist**. Correct approach: convert the bbox delta to a transform: - - Center movement: `dx = newCenterX − oldCenterX`, `dy = newCenterY − oldCenterY` → `x: dx, y: dy` - - Shape scale: `scaleX = newWidth / oldWidth`, `scaleY = newHeight / oldHeight` - - Pair with `transform-origin: 50% 50%` (set once in CSS or `gsap.set`) - - Example (ink line above): `x: -410, y: -161, scaleX: 1.458, scaleY: 0.667`. Done. - -5b. **CSS baked `transform: rotate(...)` and GSAP `rotation` are mutually exclusive — use only one on the same element** - -- Hidden pitfall: pasted components (such as `feature-card` / `star-burst` / `avatar-portrait`) often include CSS `transform: rotate(var(--bf-tilt-sm-l))`; once the same element is targeted by `tl.to(el, { scale: 1, ... })` or `gsap.fromTo(el, { rotation: -2 }, ...)`, GSAP **overwrites the entire** `style.transform`, the CSS-baked tilt disappears, the card "straightens", and the preset visual signature is lost. -- Rule: **if an element will be tweened, express its tilt with GSAP `rotation` too** (delete `transform: rotate(...)` from CSS and write `rotation: <deg>` in `gsap.set` or the entry `fromTo`). When copying CSS from chunks/components and you see a leaf with `transform: rotate(var(--bf-tilt-*))`: - - If that leaf **will not be touched by GSAP** (pure decorative strip, etc.) → keep CSS baked, OK. - - If that leaf appears in a timeline `tl.to/.fromTo/.set` selector → **delete the CSS line**, and move tilt into GSAP (`gsap.set(el, { rotation: -2 })` or `fromTo({...rotation: -2}, {...rotation: -2, ...})` to preserve static tilt). -- The same applies to baked `transform: translate(...)` / `scale(...)` / `skew(...)` — once GSAP animates that element, all baked transform is overwritten. `will-change: transform` does not solve this; it is only a perf hint. - -6. **Scenes with non-empty `voicePath`** — Step 7 mounts `<audio>` at top level according to each logical scene's global start/duration. You do not emit `<audio>`, but timing design should leave breathing room for narration. - - **Ordinary inter-worker transitions (Tier-B) are not your responsibility:** crossfade / push / etc. are deterministically added by Step 7 `transitions.mjs inject` on your visual clip **wrapper** (`index.html` layer, above your composition), **not inside your composition**. Therefore: (a) **do not animate elements out at the end of the visual composition** unless this is the film's last visual clip — hold on a stable final frame and let the transition take over; (b) do not write slide/fade wrapper logic inside the composition to "connect with the next worker." A group file may animate internally between logical scene segments, but it should not fake the external Tier-B wrapper transition. - - **Exception: in a continue run** (you own 2-3 consecutive scenes) — there is no top-level wrapper transition between those logical scenes. You author the continuity inside one `group_wN.html` timeline with shared DOM. See constraint #14. -7. **Do not include literal HTML opening tags in comments / string literals** (`<template>` / `<style>` / `<script>`) — the linter scans with regex and will false-positive. Escape as `<template>` or use plain text. -8. **Timeline registration uses a literal Composition ID string:** `window.__timelines["scene_1"] = tl;` for a single-scene file or `window.__timelines["group_w2"] = tl;` for a group file. Do not wrap it behind a variable (`check-compositions.mjs` cannot recognize it with regex). The whole `<script>` selector / dataset key / timeline key must use literals. -9. **Macro-camera scenes get a layout escape hatch by default** - - If `effects` contains any of `coordinate-target-zoom` / `multi-phase-camera` / `camera-cursor-tracking` / `viewport-change` → add `data-layout-allow-overflow="true"` to the outermost zoom/pan wrapper. - - Reason: the zoom peak necessarily exceeds the canvas viewport, and `hyperframes inspect` will report `text_box_overflow`. This is by design; declare it in advance. - - Example: `<div class="s2-zoom-outer" id="s2-zoom-outer" data-layout-allow-overflow="true">` - - ⚠ **`allow-overflow` only pardons decorative bleed; it does not pardon primary large text**: pushing brand text / headlines out of frame is a bug, not by-design (finalize snapshot QA will bounce it back as a repair). Keep display text ≤ ~88% canvas width at the zoom peak so a slight center offset cannot clip it. - - ⚠ **Zooming into an asymmetric target (e.g. companion wider than chip) → measure the offset, do not hand-derive it**: after `await document.fonts.ready`, read the target's real `getBoundingClientRect()` center and bake `TARGET_OFFSET` (`center − viewport_center`); the equal-width card formula gives the **wrong sign** in asymmetric layouts, and 3×+ scaling magnifies the error out of frame. See the `coordinate-target-zoom` rule in `/hyperframes-animation`, section "Getting the offset". - - ⚠ **Leave scale headroom:** at peak, primary text should be ≤ ~88% canvas width (derive `maxScale = 0.88×W/r.width` from measured dimensions); do not pick round numbers by feel — if text fills the canvas, a slight center offset clips it. - - ⚠ **`inspect` runs STRICT (no tolerance):** preflight gates `inspect` at the CLI default (2px) — transient bbox wobble from 3D tilt / morph projections is not numerically tolerated. Any element whose 3D transform legitimately flutters its bbox past a container edge needs the same `data-layout-allow-overflow="true"` declaration as the zoom wrappers above. -10. **No foreground overlap (HARD — machine-checked by `check-overlap.mjs`)** - Only one `primary subject` at any moment; follow `PrimarySubjectTimeline` / `Handoff` from `creative_brief` (do not redesign). Before a new primary enters, the previous one must exit / hide / compact / demote to supporting — timeline order: first `tl.to(previousPrimary, ...)` out, then `tl.fromTo(newPrimary, ...)` in. **Camera pan/zoom/push does not count as a handoff.** Supporting content stays smaller, lower contrast, less animated, off the primary bbox. - **No FOREGROUND object may intersect another** (card / panel / stat / media / icon / button / text block). **Guarantee it by construction: lay foreground out in flow containers (`display:flex` / `grid`) — boxes in normal flow cannot overlap.** Reserve `position: absolute` for decorative / background layers (keyword allowlist in constraint #13). An absolutely-positioned foreground box must clear every other foreground bbox at **every phase of the timeline**, not just the resting pose. - **The gate (run in your self-check, re-run by preflight over all scenes):** the scene is loaded headless, its timeline seeked to 0.4 / 0.7 / 0.92 of duration, every non-background paint atom (text block / media / painted surface) flattened onto one plane — **z-index is ignored** — and any two atoms intersecting ≥4px on both axes at **≥2 probes** is a violation. A single-probe hit is reported as a mid-tween transient (not blocking). DOM ancestors never count (text inside its own card is composition, not collision); an atom ≥90% inside a surface counts as placed-on-it, not overlapping. - **Nesting is composition, not overlap:** a chip pinned on a card corner is fine only when nested inside the card (ancestor — the gate ignores DOM-nested pairs). There is **no opt-out attribute** — every flagged pair must be resolved by construction (move / shrink / reflow / stagger). - Keep `data-layout-role="primary|supporting"` / `data-layout-act="<act-name>"` annotations on major groups (review aid). - 10b. **Author-owned geometry budgets (not machine-measured — keep them by mental math)** - - Overlap, text-fit and media-fit are machine-gated now (`check-overlap.mjs`; strict `inspect` catches text/container/canvas overflow including `height:auto` media clipping its panel). What remains yours to keep, checked with real px values before writing CSS: - - | Budget | Rule (check with real numbers, not by feel) | - | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | - | **Interior clearance** | Every container holding foreground children gives them **≥12px top AND bottom clearance** at rest (sum children heights + gaps + paddings vs container height — do the addition). If you shrink a container (or a keep-out fix shrinks it), **retune its interior** in the same edit | - | **Graphic ↔ surface contrast** | Author every graphic (inline SVG / icon / invented wordmark) with fills that contrast the surface it sits on — a dark-glyph SVG on a dark card is invisible. FE visuals are LLM-authored: you own the paths, so pick `fill` / surface token pairs from `tokens.css` deliberately (there is no captured asset library with light/dark variants to swap); the same applies when placing a user-provided `assetCandidate` image | - | **Depth-stack ghosting** | Multi-layer offset text ("stamp" depth effect): on long words (≥10 chars) at display tier, keep **layers ≤2 or per-layer offset ≤2px** — `layers × offset` beyond ~4px reads as edge ghosting | - -11. **`#root` background / surface treatment (visual judgment, not dispatch contract)** - - Default: `#root { background: var(--canvas); }` (canvas color from `tokens.css`). - - **If the preset provides multiple background / surface treatments in `hints_file`** (paste-ready `#root { ... }` stanzas — e.g. paper texture base, dark authority panel, signal board), **you may choose one** that fits this scene's mood and paste the entire stanza into the scene `<style>`, so the frame feels like this preset rather than "generic SaaS colors." This is a **style choice**; no one forces which one to pick. All `var(--*)` tokens are already defined in `tokens.css`; do not replace them. - - **Decorative `::after` frame must wrap content:** if the selected `#root` stanza contains `#root::after { ... }` (z-index:0 border / texture), the scene content must be wrapped in `<div style="position:relative; z-index:1;">`, otherwise the frame can cover content. -12. **`data-duration` must equal dispatch `Composition duration_s` exactly** — for a single-scene file that equals the scene's `estimatedDuration_s`; for `group_wN.html` it equals the sum/span of the logical scenes in the run. Step 7 `assemble-index.mjs` places the full-film timeline using `group_spec`, then checks each visual root `data-duration`; mismatch is **fatal** and blocks all of Step 7 back to you. Do not use an approximate value from `creative_brief`; do not round yourself. This is especially important when `voicePath` is non-empty (global timings for voice / SFX / captions are based on this value). -13. **Bottom caption-band keep-out (HARD constraint — only when dispatch `Captions: enabled`, machine-checked in preflight)** - - The canvas is `<Composition width>×<Composition height>` (from dispatch — landscape 1920×1080 by default, but portrait 1080×1920 or square 1080×1080 when the dispatch says so). When `Captions: enabled`, finalize places a full-film word-by-word karaoke pill in a bottom band. **The dispatch hands you two numbers — use them, never hardcode 900 / 880:** - - **`Caption band top y`** — the band runs from this y down to the canvas bottom (the bottom ~16.67% of canvas height). - - **`Foreground max y`** — every FOREGROUND element's target rendered lower edge must be ≤ this (= `Caption band top y` − 20px safety). Foreground = headline / cards / CTA / button / chip / stat / hero text / quote / key logo / any readable content. - - Worked values: landscape 1920×1080 → band y900–1080, `Foreground max y` = 880. Portrait 1080×1920 → band y1600–1920, `Foreground max y` = 1580. - - Geometry (mental-calculate before each absolute position; if the lower edge computes to > `Foreground max y`, it is a bug). Let **H = `<Composition height>`** and **FGmax = `Foreground max y`**: - - | CSS shape | element lower-edge y | Legal condition | - | ------------------------------------------------ | -------------------------------------- | ---------------------------- | - | `bottom: <B>px` (no `top` / `height`) | `H − B` | `B ≥ H − FGmax` | - | `top: <T>px` + `height: <Hc>px` | `T + Hc` | `T + Hc ≤ FGmax` | - | `top: <T>px` + natural height (estimate) | `T + content height` | `T ≤ FGmax − content height` | - | `top: <T>px` + `bottom: <B>px` (stretched strip) | `H − B` (bottom determines lower edge) | `B ≥ H − FGmax` | - | flex/grid child + `align-self: end` | Parent container bottom | Parent lower edge ≤ FGmax | - - `H − FGmax` is the minimum bottom offset: **200px on landscape, 340px on portrait** — i.e. a chip that sits at `bottom: 200px` on landscape must move to `bottom: 340px` on portrait. A centered hero anchors around **y ≈ 0.42 × H** (landscape ≈ 454, portrait ≈ 806), not the canvas midpoint. - - **BACKGROUND exceptions (exempt, may be full-bleed to the canvas bottom):** - - `#root` background / surface decoration / `::before` / `::after` frame / ambient mesh / full-bleed invented-graphic / gradient base layer. - - Decorative leaf class names — preflight automatically skips selectors containing any of these keywords (split by hyphen/underscore): `bg` / `background` / `dot-grid` / `mesh` / `gradient` / `swell` / `ambient` / `texture` / `noise` / `scanline` / `surface` / `overlay` / `halo` / `glow` / `frame` / `pin` / `corner-pin` / `deco` / `star-burst` / `burst` / `ring` / `stripe` / `rect` / `shadow` / `pulse` / `ripple` / `measure` / `probe` / `hidden` / `scrim` / `backdrop` / `veil` / `fog` / `grain`. - - Macro-camera overflow wrappers from constraint #9 (with `data-layout-allow-overflow="true"`) — zoom peaks naturally exceed the frame. - - **When `Captions: disabled`:** full-canvas, vertical center y = H / 2, content may extend all the way to the canvas bottom. All constraints above are disabled; positioning is free. - - **Preflight machine check** (Step 7 (2) `captions.mjs keepout`) catches three shapes: - 1. `position: absolute` + `bottom: <X>px`, X < 180 and non-decorative - 2. `position: absolute` + `top: <X>px`, X ≥ 900 and non-decorative - 3. `position: absolute` + statically addable `top + height` > 900 and non-decorative - - The static math folds in **CSS `transform: translate*`** (px / % literals) **and `margin-top` / `margin-bottom`** (longhand + px shorthand) — so a negative-margin-centered card is measured at its real bbox, and conversely a negative `margin-bottom` that pushes a chip down IS caught. Each violation generates quasi-Edit strings (`edit_old` / `edit_new`) and writes them to `finalize_brief.json.caption_keepout.violations[]`; the finalize agent directly runs `Edit(file, edit_old, edit_new)` to fix it. **So a contract mistake is not left for snapshot visual inspection; preflight catches it immediately — check values against the table before writing.** - - **Shapes static analysis cannot catch** (GSAP runtime `translateY`, natural flex layout pushing content to y > 900, unresolvable transforms/margins like `calc()`/`var()`) — these are covered by finalize snapshot visual inspection, but **when writing code still position by the rule "element lower edge y ≤ 880"**; do not intentionally hug the edge. - -14. **Continuous scene runs (continuity: continue) — one `group_wN.html`, true shared DOM** - - When your dispatch packet contains **2-3 consecutive scenes**, you own one continue run. Write **one** visual composition file, usually `compositions/group_wN.html`, with `data-composition-id="group_wN"` and `window.__timelines["group_wN"]`. Do **not** write separate `scene_N.html` files for the logical scenes in this worker. There is no cross-worker bridge contract, no `data-bridge-id`, no `check-bridge`, and no top-level crossfade inside the run. - - Build a single paused GSAP timeline whose duration is `Composition duration_s`. Treat each logical scene as a labeled segment: - - `const T = { scene_3: 0, scene_4: <scene_4.local_start_s>, scene_5: <scene_5.local_start_s> };` - - scene 3 tweens fire around `T.scene_3 + ...` - - scene 4 tweens fire around `T.scene_4 + ...` - - add a tiny hold/tween through the boundary when needed, but keep it inside the same timeline. - - Author the continuity with real persistent nodes: - - **Same component family:** a process-step card, logo lockup, stacked quote, counter, or badge keeps the same `.gN-*` DOM node and gains content/state across the run. - - **Same diagram/data-viz primitive:** one curve, node graph, counter, stepper, axis, or flow line persists and evolves. Do not destroy/recreate it at the boundary; animate its opacity/transform/path/value state in the shared timeline. - - **Prebuild states, no runtime mutation:** if content changes, put both old/new labels or state layers in DOM and animate opacity/transform/clipping. Avoid `tl.call()`/`textContent` mutation; frame-seek should work from a static DOM + timeline. - - **Boundary behavior:** the outgoing logical scene should resolve into the same shared element pose that the incoming logical scene continues from. There is no wrapper transition to hide a mismatch, so the group timeline itself must carry the viewer's eye. - - **Scene-local support:** non-persistent support nodes may use `s<N>-` and appear only in their segment. The persistent protagonist uses `g<N>-`. - -## Scope - -Only write `<PROJECT_DIR>/<Composition file>`. **Do not** modify `index.html` / copy assets / run `npx hyperframes lint|validate|inspect|snapshot|render` (at initial authoring time `index.html` does not exist yet, so project gates cannot run — **exception: Repair Mode below runs a scoped `inspect`**) / add or remove effects (if a rule cannot run → STOP and report; do not silently drop it). - -Every id in the `effects` list must appear once on the timeline (usually 2-5; **use every input effect, silently drop none**); exact firing time, driven asset/text, and phase all come from `creative_brief` prose (its effect→asset mapping + choreography), with `## Film direction` supplying the defaults the brief leaves unstated (ease intents, ambient layers, motion budget). Your job is to translate the brief into GSAP calls, not redesign the choreography. - -**`assetCandidates` is usually `[]` (faceless).** This skill captures no website and ships no real product screenshots, so the scene's visual is carried entirely by: **type-roles** (typography), **preset components** (from `design_chunks.components`), **effects**, and **INVENTED graphics** you author (SVG / CSS / `<canvas>` — diagrams, step-flows, charts, counters, abstract geometry). Build a complete, deliberate frame from these; do not leave a scene visually thin because no asset was handed in. - -**Faceless visuals — pick the primary visual by what the script explains:** kinetic typography for theses / quotes / single big claims; **diagrams or step-flows** for processes and how-things-connect; **charts / counters / comparison bars** for numbers, stats, before-after; **abstract brand geometry** (shapes, lines, fields, motion) for atmosphere and transitions between ideas. Let the brief's choreography + effect→asset mapping decide the rhythm; the visual _kind_ follows the sentence. **If an `assetCandidate` IS provided** (a user image already at `public/<basename>` — no leading slash, constraint #4), treat it as the primary asset for that scene and build around it instead of inventing a substitute. - -## Flow - -1. Parallel Read the required resources (3 items above) -2. Write exactly one `<PROJECT_DIR>/<Composition file>` (skeleton below) -3. Self-check (the `bash grep` block below); fix before reporting if anything fails -4. One-line report - -## Skeleton - -Example below uses single-scene `scene_1` (for other single scenes, replace `scene_1` / `s1-` with the corresponding number). For a multi-scene worker, use `group_wN` everywhere the example uses `scene_1`, use `gN-` for shared persistent nodes, and set `data-duration` to `Composition duration_s`. - -⚠ root `<div>` 5 attributes + class + style must be **written on the same line** — the self-check regex and `check-compositions` Rule 1 both require "id and class in the same tag" as a single-line match. Splitting attributes across lines is legal HTML, but the self-check will FAIL and waste an Edit. - -```html -<template id="scene_1-template"> - <div - id="root" - class="scene_1-root" - data-composition-id="scene_1" - data-width="<Composition width>" - data-height="<Composition height>" - data-duration="<Composition duration_s>" - style="position:relative; width:<Composition width>px; height:<Composition height>px; overflow:hidden;" - > - <style> - /* Root element styles — write #root (not a self data-composition-id selector or .scene_1-root). - Brand tokens (--brand-*, --cl-*, --font-display/body/mono, spacing/radius) are declared - ONCE globally in index.html's <head> and inherit here — do NOT redeclare the :root block. - Reference them with var(--*). Override a single token locally only if this scene needs a - different value (the local declaration wins by cascade). */ - #root { - background: var(--canvas); - font-family: var(--font-body); /* default font; headings use var(--font-display) */ - /* e.g. a dark scene: --canvas: var(--cl-navy); */ - } - #root *, - #root *::before, - #root *::after { - box-sizing: border-box; - } - - /* Scene-specific rules — all bare classes. - The CSS scoper automatically adds scope. - Class names carry the s1- prefix so sibling scenes do not conflict. */ - .s1-grid { - /* ... */ - } - .s1-word { - /* ... */ - } - </style> - - <!-- Build DOM according to the creative_brief effect→asset mapping. - All classes use s1- prefix; ids also use s1- prefix (e.g. id="s1-headline"). --> - - <script> - // Paste the EASE / DUR const block from easings.js / dispatch inline section - const EASE = { entry: "power2.out" /* ... */ }; - const DUR = { med: 0.55 /* ... */ }; - window.__timelines = window.__timelines || {}; - const tl = gsap.timeline({ paused: true }); - // Write selectors as bare .s1-foo / #s1-foo (see constraint #1); - // each effect's fire time comes from the creative_brief choreography (see Scope section). - const headlineEl = document.querySelector("#s1-headline"); - tl.fromTo( - ".s1-word", - { opacity: 0, y: 20 }, - { opacity: 1, y: 0, duration: DUR.med, ease: EASE.entry }, - 0, - ); - window.__timelines["scene_1"] = tl; - </script> - </div> -</template> -``` - -## Self-Check (run for the visual composition; fix failures before reporting) - -Replace placeholders below with real values. For single-scene `scene_1`: `CID=scene_1`, `PREFIX=s1`, `EXPDUR=<estimatedDuration_s>`, `F=compositions/scene_1.html`. For group worker `w2`: `CID=group_w2`, `PREFIX=g2`, `EXPDUR=<Composition duration_s>`, `F=compositions/group_w2.html`. - -```bash -PROJECT_DIR="<Dispatch context PROJECT_DIR>" -SKILL_DIR="<Dispatch context SKILL_DIR>" -F="$PROJECT_DIR/<Composition file>" -CID=<Composition ID>; PREFIX=<sN-or-gN>; EXPDUR=<Composition duration_s> -W=<Composition width>; H=<Composition height> # from dispatch (default 1920 / 1080 landscape) - -# File exists -[ -s "$F" ] || echo "FAIL: empty/missing $F" - -# Root 5 attributes present at once (most common omissions: data-duration / id=\"root\") — if any are missing, finalize will catch it later and waste a round-trip -for ATTR in 'id="root"' "class=\"${CID}-root\"" "data-composition-id=\"${CID}\"" "data-width=\"${W}\"" "data-height=\"${H}\"" 'data-duration="'; do - grep -q "$ATTR" "$F" || echo "FAIL: root missing $ATTR — all 5 attributes must be present" -done - -# id=\"root\" and class=\"<sid>-root\" must be on the same div (check-compositions Rule 1 requires same tag; splitting into two divs can slip past self-check but gate will fatal) -grep -qE "id=\"root\"[^>]*class=\"${CID}-root\"|class=\"${CID}-root\"[^>]*id=\"root\"" "$F" || \ - echo "FAIL: id=\"root\" and class=\"${CID}-root\" must be on the same div tag" - -# data-duration value must equal dispatch Composition duration_s — Step 7 assemble-index.mjs treats mismatch as fatal and blocks the whole phase -grep -q "data-duration=\"${EXPDUR}\"" "$F" || echo "FAIL: root data-duration must equal Composition duration_s=${EXPDUR} (do not use approximations / do not round)" - -# Literal HTML opening tags are forbidden in comments (lint regex can treat <template>/<style>/<script> in comments as real tags -> 1-2 minutes of false-positive debugging) -grep -nE '<!--[^>]*<(template|style|script)[> ][^>]*-->' "$F" && \ - echo "FAIL: comment contains literal <template>/<style>/<script> — escape as <...> or rewrite as plain text" - -# Must be 0 — bug shapes -# 1) `.<Composition ID>-root` used as an ancestor selector (producer strips this wrapper during render, causing all selectors to miss -> black scene) -grep -nE "\\.${CID}-root[[:space:]]" "$F" && echo "FAIL: do not use .${CID}-root as an ancestor selector — write bare .${PREFIX}-foo instead" -# 2) Do not write a self data-composition-id selector; root styles use #root, internal elements use the composition prefix -grep -nE "\\[[[:space:]]*data-composition-id[[:space:]]*=[[:space:]]*['\"]${CID}['\"][[:space:]]*\\]" "$F" && \ - echo "FAIL: do not write [data-composition-id=\"${CID}\"] selector — use #root for root styles and .${PREFIX}-foo / #${PREFIX}-foo for internal elements" -# 3) Forbid #<Composition ID>-root; root id must only be #root, internal ids use the composition prefix -grep -nE "#${CID}-root\\b|getElementById\\(\"${CID}-root\"\\)" "$F" && echo "FAIL: do not use #${CID}-root" -# 4) Forbidden by core deterministic contract (determinism-rules.md): Date.now / performance.now / unseeded Math.random / fetch(at render time) / repeat:-1. -# Plus PLV-specific pre-flight constraints (check-compositions Rule 5, not a core contract): CSS transition:/animation: (PLV requires all motion to go through one seekable -# GSAP timeline — note that hyperframes-animation/adapters/css-animations.md actually supports seekable CSS keyframes, but PLV is stricter), @font-face (must be declared in index.html <head>). -grep -nE '@font-face|transition:|animation:|Date\.now|Math\.random|performance\.now|fetch\(|repeat:\s*-1' "$F" && \ - echo "FAIL: hits above (including embedded <style> pasted from components[]) must be fixed: rewrite CSS transition:/animation: as GSAP tweens (CSS transitions are not controllable during producer frame-by-frame seek); move @font-face to index.html <head>; Date.now/Math.random/performance.now/fetch/repeat:-1 are hard-forbidden by the core deterministic contract." -# 5) Font names must use var(--font-*) tokens — hard-coded literal font names bypass index.html <head> @font-face -# Allowlist: var(--font-display/body/mono), CSS generic families (serif/sans-serif/monospace/system-ui/ui-monospace/ui-sans-serif/ui-serif), -# safe fallbacks (Georgia/Times/Helvetica/Arial/Menlo/Monaco/SFMono-Regular/-apple-system/BlinkMacSystemFont) -# ⚠ macOS bash pitfall: `grep -v >/dev/null` returns 0 on empty input (GNU grep returns 1), causing `&& echo FAIL` to always fire. -# Use an if-block + explicit output line check to avoid pipefail-off false positives. -HARDCODED_FONTS=$(grep -nE "font-family:[[:space:]]*['\"]" "$F" | grep -vE "var\\(--font-(display|body|mono)\\)" || true) -[ -n "$HARDCODED_FONTS" ] && \ - echo "FAIL: hard-coded font names — use var(--font-display/body/mono) so index.html @font-face applies"$'\n'"$HARDCODED_FONTS" -# 6) Asset paths must not have a leading slash — /public/... is fatal under check-compositions Rule 6 (catching it here avoids waiting for gate failure) -grep -nE '["(]/public/' "$F" && echo "FAIL: asset path has leading slash — write public/... (not /public/...)" -# 6a) NO <video> in a scene file — nested video is never seeked/decoded and renders BLANK (check-compositions Rule 6a is fatal). -# Footage is declared on the poster <img> via data-video-src (constraint #4); hoist-videos.mjs mounts the real host-root <video> in Step 7. -grep -nE '<video\b' "$F" && \ - echo "FAIL: <video> tag(s) above — replace with a poster <img class=\"clip\" src=\"public/<still>\" data-video-src=\"public/<clip>\" ...> declaration" -# 7) Caption-band keep-out (constraint #13) — run the REAL preflight gate, scoped to your composition. -# ONLY when dispatch says `Captions: enabled` (static, instant). Same math as preflight: a pass here is a pass there. -# $CID works for both file shapes (a group_wN id matches its visual clip; a scene_N id matches its scene file). -(cd "$PROJECT_DIR" && node "$SKILL_DIR"/scripts/captions.mjs keepout \ - --group-spec ./group_spec.json --hyperframes . --scene "$CID") -# exit 1 → each violation prints the selector + an edit_old → edit_new fix; apply it, re-run until clean. - -# 8) Foreground overlap (constraint #10) — run the REAL rendered gate, scoped to your composition (always; ~5-10s). -# Loads your composition headless, seeks the timeline to 0.4/0.7/0.92 of duration, z-flattens all -# non-background paint atoms, and reports any two that intersect. -(cd "$PROJECT_DIR" && node "$SKILL_DIR"/scripts/check-overlap.mjs \ - --group-spec ./group_spec.json --hyperframes . --scene "$CID") -# exit 1 → fix by root cause (move a box / flow container / stagger visible windows), -# re-run until clean. There is no opt-out attribute. -# exit 2 → gate unavailable (deps not ensured). Do NOT npm-install here (parallel siblings would -# race); note "overlap self-check unavailable" as an anomaly in your report and continue — -# preflight runs the same gate authoritatively. -# Group files (group_wN.html): the overlap gate probes per-logical-scene files, so a group clip's -# scenes report as "skipped" — constraint #10 stays author-owned there (finalize's contact-sheet -# pass is the visual check); the keepout gate in step 7 DOES scan your group file. - -# Must be >= 1 — structural evidence -grep -c "class=\"${CID}-root\"" "$F" # root div still has class, useful while previewing/dev -grep -c "data-composition-id=\"${CID}\"" "$F" # host contract -grep -c "#root" "$F" # root self styles (CSS vars, bg, font) -grep -c "window\\.__timelines\\[\"${CID}\"\\]" "$F" # timeline registration - -# Composition class / id must carry prefix (rough match: at least one .s<N>-/.g<N>- or #s<N>-/#g<N>- appears) -grep -cE "[.#]${PREFIX}-[a-z]" "$F" - -# Strict class-prefix check: list every token in HTML class=\"...\" attributes that is **not** prefixed with the composition prefix -# Legal allowlist: (1) starts with ${PREFIX}-; (2) ${CID}-root (root div class, only for preview/dev) -# In group files, logical-scene-only s<N>- support classes are also allowed; inspect those manually if listed. -# Any hit -> component missing prefix, source of sibling scene bleed -UNPRX=$(grep -oE 'class="[^"]*"' "$F" \ - | sed -E 's/class="([^"]*)"/\1/' \ - | tr ' ' '\n' \ - | grep -vE "^(${PREFIX}-[a-zA-Z0-9_-]+|s[0-9]+-[a-zA-Z0-9_-]+|${CID}-root)$" \ - | grep -E "^[a-z]" \ - | sort -u) -[ -n "$UNPRX" ] && echo "FAIL: classes missing ${PREFIX}- prefix (or scene-local sN- in group files): $(echo $UNPRX | tr '\n' ' ')" - -# All assets are under PROJECT_DIR/public/ -grep -oE 'public/[A-Za-z0-9._/-]+' "$F" | sort -u | while read p; do - [ -s "$PROJECT_DIR/$p" ] || echo "MISSING ASSET: $p" -done -``` - -Any FAIL / MISSING / bug-shape hit → fix before reporting. Step 7 finalize has the same harness, so catching it here saves an 8-13 minute round-trip. - -## Repair Mode (TARGETED REPAIR re-dispatch) - -When the dispatch contains a `## Repair context` block, you are repairing an **existing** composition file after a Step 7 preflight failure — not authoring from scratch. The repair dispatch carries: the verbatim gate findings for your scene(s) (`inspect` error lines / `overlap` violations with both selectors + rects + overlap geometry / `caption_keepout` violations / a fix list), `npx_prefix` (pinned, cache-warmed — from `finalize_brief.json`), and `Inspect at: <t1,t2,...>` (absolute composition timestamps inside your scene's window). - -Rules that differ from authoring mode: - -1. **Edit in place; do not rewrite.** Preserve the root contract (all 5 attributes), `data-duration` EXACTLY, `s<N>-` / `g<N>-` prefixes, timeline registration, every dispatched effect, and — in a `group_wN.html` continue run — the persistent shared-element continuity across its logical scenes (constraint #14). -2. **Fix the listed bugs by root cause**, not by suppressing the check — `data-layout-allow-overflow` is legitimate only for genuinely intentional overflow (3D scroll-clip viewports, zoom peaks), never to silence a real clip. -3. **Self-verify before reporting (the contract that makes repair converge in one round).** `index.html` is already assembled at repair time, so you CAN and MUST run the scoped gates yourself: - - ```bash - # Scoped inspect — only your scene's time window; STRICT, no --tolerance flag (same as the preflight gate) - (cd "$PROJECT_DIR" && <npx_prefix> inspect --at "<Inspect at>" 2>&1 | tail -30) - # Rendered overlap gate, scoped to your composition (always — layout edits can introduce new overlap) - (cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/check-overlap.mjs --group-spec ./group_spec.json --hyperframes . --scene <Composition ID>) - ``` - - - Pass condition: **zero `✗` lines naming your composition's selectors** (`#s<N>-…` / `.s<N>-…` / `#g<N>-…` / `.g<N>-…`) and check-overlap exit 0 for your composition. A `✗` naming another worker's composition is not yours — note it in the report, do not fix it. - - When dispatch says `Captions: enabled`, also re-run the static keep-out scoped to your composition: - - ```bash - (cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/captions.mjs keepout --group-spec ./group_spec.json --hyperframes . --scene <Composition ID>) - ``` - - - Still failing after 3 distinct fix attempts on the same finding → STOP and report the finding + what you tried (do not loop). - -4. Also re-run the authoring self-check grep block (above) — a repair must not break the structural contract. -5. Report: one line per scene + `scoped inspect ✓ / overlap ✓ / keepout ✓` (or the STOP detail). This self-verification replaces the orchestrator's per-round full preflight — the orchestrator runs preflight once after ALL repair workers return, expecting it green. - -## Report Template - -One line per visual composition: - -``` -group_w2: file=compositions/group_w2.html duration=9.37s scenes=[scene_3,scene_4] effects=[...] overlap=✓ keepout=✓ -``` - -`overlap=` / `keepout=` restate the scoped gate results from the self-check (`keepout=skipped` when Captions: disabled; `overlap=unavailable` only on exit 2). Plus anomalies (missing asset, ambiguous rule combination, attempted effect drop). Do not write `context.log`. In Repair Mode, append the self-verify status line (rule #5 above). diff --git a/skills/faceless-explainer/agents/scriptwriting.md b/skills/faceless-explainer/agents/scriptwriting.md deleted file mode 100644 index a0d549de21..0000000000 --- a/skills/faceless-explainer/agents/scriptwriting.md +++ /dev/null @@ -1,50 +0,0 @@ -# Subagent Prompt: scriptwriting (Phase 2) - -**INPUT:** `<PROJECT_DIR>/capture/extracted/visible-text.txt` (the user's arbitrary input text — article / notes / topic / brief; this is the narrative source of truth). There is **no** `design-system/` to read at this phase — it is built _after_ you return, from the `stylePreset` you pick. -**OUTPUT:** `<PROJECT_DIR>/narrator_scripts.json` (includes the top-level `stylePreset` you pick + the `orientation` you echo from dispatch) -**TOOLS:** Read · Bash -**DONE:** Validator exit 0, report structure / scene count / total duration, append to `<PROJECT_DIR>/context.log` - -You are the **faceless-explainer** Phase 2 subagent. Read `<SKILL_DIR>/phases/scriptwriting/guide.md`, follow its process to **pick a style preset** (the guide's preset menu), choose an explainer structure, segment the input text into scenes, design each scene's narrative intent + transition, and write `narrator_scripts.json`. Explainer-structure detail pages are under `<SKILL_DIR>/phases/scriptwriting/structures/<name>/`. - -**Path contract:** Run Bash through a `(cd "$PROJECT_DIR" && ...)` subshell. - -**Input constraints:** - -- `capture/extracted/visible-text.txt` is the **only narrative source**: the user's raw input text. There is **no** `context_pack.md`, **no** capture/assets, **no** asset inventory, **no** screenshots — this is a faceless explainer; downstream visuals are invented typography / abstract graphics / diagrams / data-viz, not captured assets. Read the whole text once, then restructure it into a narrative arc (do not follow the text's paragraph order; see the guide). -- **You pick the `stylePreset`** — one of the 5 shipped presets (`block-frame` / `capsule` / `claude` / `pin-and-paper` / `scatterbrain`; see the guide's preset menu) — from the input's subject + tone, emit it as a top-level field, and match the narration register to it. Default to `pin-and-paper` when nothing clearly fits. There is **no** `design-system/` (no `inference.json`, no `design.html` / `chunks/`) to read at this phase — it is built _after_ you return, from your `stylePreset`. Do **not** run any build step yourself. -- **Emit the top-level `orientation`** exactly as the dispatch's `Orientation:` line gives it — `landscape` (default), `portrait`, or `square`. This is **dictated by the user's chosen aspect, not a creative choice**: copy it verbatim, do not infer or change it. prep reads it to set the canvas (portrait → 1080×1920); omitting it falls back to landscape. If the dispatch has no `Orientation:` line, use `landscape`. -- **`assetCandidates` is `[]` for every scene by default.** FE is faceless: there are no real assets to name. Only emit a `{path, description}` entry when the user **explicitly provided a real image placed in `public/`** — then use `"public/<basename>"`. Do not invent asset paths. -- Do not generate derived files. -- Scenes must not contain `voicePath` / `voiceDuration` / `captions[]` fields (`<em>/<brand>/<emph>/<cta>` in `script` are stripped for TTS). - -## Self-Check Before Reporting Done - -The `Schema validator:` provided by dispatch is an absolute path. After writing, run it directly (**do not read the script source**): - -```bash -(cd "$PROJECT_DIR" && node <validator-path> ./narrator_scripts.json) -``` - -Iterate until it exits 0. See the `narrator_scripts.json — canonical schema` chapter in the guide for the full schema. - -## Report After Completion - -- Selected explainer structure (one of: concept-explainer / how-to-process / listicle / story-explainer, or a `"<outer> with <inner>"` compound) -- Chosen `stylePreset` (one of the 5 shipped presets) + one-line rationale -- Scene count + total estimated duration -- One summary line for each scene (`sceneNumber` + `sceneName` + 8-word gist) - -Append to `<PROJECT_DIR>/context.log` (generate the timestamp with the machine in UTC; do not hand-write it): - -```bash -(cd "$PROJECT_DIR" && cat >> context.log <<EOF - -## scriptwriting [done $(date -u +%Y-%m-%dT%H:%M:%SZ)] -Structure: <name> -Style: <stylePreset> -Orientation: <orientation> -Scenes: <count>, total ~<duration>s -EOF -) -``` diff --git a/skills/faceless-explainer/agents/visual-design.md b/skills/faceless-explainer/agents/visual-design.md deleted file mode 100644 index 14ae81a636..0000000000 --- a/skills/faceless-explainer/agents/visual-design.md +++ /dev/null @@ -1,40 +0,0 @@ -# Subagent Prompt: visual-design (Phase 3) - -**INPUT (all inside the dispatch packet `<PROJECT_DIR>/.dispatch/vd-dispatch.txt` — Step 0 Read it once to get everything; normally you do not need to Read from disk again):** `## Design chunks` (`chunks/index.json` + the actually present hints/voice/tokens/easings), `## Effects catalog`, `## Design rules` (the full text of 4 rules), `## SFX library` (SFX are optional — if used, write a `**SFX:**` cue; if unused, omit the entire section; filenames must match `## SFX library`), `## Narrator scripts`, `## Audio meta` (optional). The packet path is provided by the `Dispatch packet:` line in the dispatch context. -**OUTPUT:** `<PROJECT_DIR>/section_plan.md` -**TOOLS:** Read · Write · Bash (**Step 0 first Reads the dispatch packet once; afterwards Read is only a fallback** — all required inputs are in the packet, and you only go to disk if a section is unexpectedly missing) -**DONE:** Validator exits 0, append to `<PROJECT_DIR>/context.log` using the template below - -You are the **faceless-explainer** Phase 3 / visual-design subagent. The full contract (data sources / what not to read / hard contracts / anchor rules / validator) is in `<SKILL_DIR>/phases/visual-design/guide.md`; execute it in order from §1 → §5. **Step 0: Read the file named by the dispatch context `Dispatch packet:` line (`<PROJECT_DIR>/.dispatch/vd-dispatch.txt`) once to obtain all inputs. Wherever guide §1 says to "Read `chunks/...`", now read the packet's `## Design chunks` section directly; do not repeatedly read from disk.** - -**Path contract:** Run Bash through a `(cd "$PROJECT_DIR" && ...)` subshell. - -**`audio_meta.json` priority:** If it exists and `scenes[].duration_s` differs from `narrator_scripts.json` `estimatedDuration` by more than 10%, use the `audio_meta.json` value for the `**Duration:**` anchor. - -> **Output file shape (mandatory):** `section_plan.md` = an optional one-line H1 + **one `## Film Direction` block** (film-level invariants written once — palette system, type roles, motion defaults + budget, ambient system, film negative list, transition vocabulary, visual register mix + asset coverage, stillness allocation; guide §4.1) + `## Scene N:` blocks of **delta prose only** (≤150 words target; guide §4.2), **nothing else**. Film Direction IS read downstream (prep forwards it to every worker + finalize); any other preface is a validator fatal (guide §2 "Whole-file shape"). The litmus test for every scene sentence: could it appear verbatim in another scene's prose? Yes → it belongs in Film Direction. (Per scene you apply the `voice.md` register to DOM text; keep that judgment in your head, not in the file.) - -## Self-Validation - -The `Schema validator:` provided by dispatch is an absolute path. After writing: - -```bash -(cd "$PROJECT_DIR" && node <validator-path> ./section_plan.md) -``` - -Iterate until the exit code is 0. See the "hard contracts" subsection in `guide.md` for validation rules. Do not report done before it passes. - -## Completion Report - -Verbally report: scene count, total `Duration`, one line per scene (composition + 1-2 effect names), and any creative decisions that depart from the baseline. - -Append to `<PROJECT_DIR>/context.log` (generate the timestamp with the machine in UTC, **do not hand-write it** — hand-writing easily mixes time zones / introduces mistakes: `TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)`): - -```bash -(cd "$PROJECT_DIR" && cat >> context.log <<EOF - -## Phase 3: visual-design [done $(date -u +%Y-%m-%dT%H:%M:%SZ)] -Scenes: <count> -Notes: <one line> -EOF -) -``` diff --git a/skills/faceless-explainer/assets/sfx/CREDITS.md b/skills/faceless-explainer/assets/sfx/CREDITS.md deleted file mode 100644 index 750d6e8393..0000000000 --- a/skills/faceless-explainer/assets/sfx/CREDITS.md +++ /dev/null @@ -1,35 +0,0 @@ -# SFX Credits - -All sound effects in this directory are sourced from [Pixabay](https://pixabay.com/sound-effects/) and used under the [Pixabay Content License](https://pixabay.com/service/license-summary/). - -The Pixabay license allows free use for commercial and non-commercial purposes without attribution, but attribution is appreciated and given here for transparency. - -## Files - -The following `.mp3` files are bundled with this skill: - -- `chime.mp3` -- `click.mp3` / `click-soft.mp3` -- `error.mp3` -- `glitch-1.mp3` / `glitch-2.mp3` / `glitch-3.mp3` -- `impact-bass-1.mp3` / `impact-bass-2.mp3` -- `key-press.mp3` -- `notification.mp3` -- `ping.mp3` -- `pop.mp3` -- `riser.mp3` -- `sparkle.mp3` -- `typing.mp3` -- `whoosh.mp3` / `whoosh-short.mp3` / `whoosh-cinematic.mp3` - -See `manifest.json` for per-file metadata (duration, energy character, recommended use). - -## License - -All files are distributed under the [Pixabay Content License](https://pixabay.com/service/license-summary/), which permits: - -- Commercial and non-commercial use -- Modification and remixing -- Redistribution as part of derivative works (such as videos rendered with HyperFrames) - -without any attribution requirement. diff --git a/skills/faceless-explainer/assets/sfx/chime.mp3 b/skills/faceless-explainer/assets/sfx/chime.mp3 deleted file mode 100644 index 12da953a35..0000000000 Binary files a/skills/faceless-explainer/assets/sfx/chime.mp3 and /dev/null differ diff --git a/skills/faceless-explainer/assets/sfx/click-soft.mp3 b/skills/faceless-explainer/assets/sfx/click-soft.mp3 deleted file mode 100644 index 2706b1d2ea..0000000000 Binary files a/skills/faceless-explainer/assets/sfx/click-soft.mp3 and /dev/null differ diff --git a/skills/faceless-explainer/assets/sfx/click.mp3 b/skills/faceless-explainer/assets/sfx/click.mp3 deleted file mode 100644 index 2706b1d2ea..0000000000 Binary files a/skills/faceless-explainer/assets/sfx/click.mp3 and /dev/null differ diff --git a/skills/faceless-explainer/assets/sfx/error.mp3 b/skills/faceless-explainer/assets/sfx/error.mp3 deleted file mode 100644 index 6b4487ff46..0000000000 Binary files a/skills/faceless-explainer/assets/sfx/error.mp3 and /dev/null differ diff --git a/skills/faceless-explainer/assets/sfx/glitch-1.mp3 b/skills/faceless-explainer/assets/sfx/glitch-1.mp3 deleted file mode 100644 index 124f450194..0000000000 Binary files a/skills/faceless-explainer/assets/sfx/glitch-1.mp3 and /dev/null differ diff --git a/skills/faceless-explainer/assets/sfx/glitch-2.mp3 b/skills/faceless-explainer/assets/sfx/glitch-2.mp3 deleted file mode 100644 index 580b6fdc4f..0000000000 Binary files a/skills/faceless-explainer/assets/sfx/glitch-2.mp3 and /dev/null differ diff --git a/skills/faceless-explainer/assets/sfx/glitch-3.mp3 b/skills/faceless-explainer/assets/sfx/glitch-3.mp3 deleted file mode 100644 index 5a8adc2442..0000000000 Binary files a/skills/faceless-explainer/assets/sfx/glitch-3.mp3 and /dev/null differ diff --git a/skills/faceless-explainer/assets/sfx/impact-bass-1.mp3 b/skills/faceless-explainer/assets/sfx/impact-bass-1.mp3 deleted file mode 100644 index ecfe0c48a6..0000000000 Binary files a/skills/faceless-explainer/assets/sfx/impact-bass-1.mp3 and /dev/null differ diff --git a/skills/faceless-explainer/assets/sfx/impact-bass-2.mp3 b/skills/faceless-explainer/assets/sfx/impact-bass-2.mp3 deleted file mode 100644 index 4ae448b58a..0000000000 Binary files a/skills/faceless-explainer/assets/sfx/impact-bass-2.mp3 and /dev/null differ diff --git a/skills/faceless-explainer/assets/sfx/key-press.mp3 b/skills/faceless-explainer/assets/sfx/key-press.mp3 deleted file mode 100644 index d669006e01..0000000000 Binary files a/skills/faceless-explainer/assets/sfx/key-press.mp3 and /dev/null differ diff --git a/skills/faceless-explainer/assets/sfx/manifest.json b/skills/faceless-explainer/assets/sfx/manifest.json deleted file mode 100644 index 7db0ad7ca5..0000000000 --- a/skills/faceless-explainer/assets/sfx/manifest.json +++ /dev/null @@ -1,97 +0,0 @@ -{ - "chime": { - "file": "chime.mp3", - "duration": 2.5, - "description": "Soft melodic chime — gentle positive beat: success/confirmation or a lighthearted transition. Sync to the visual moment." - }, - "click-soft": { - "file": "click-soft.mp3", - "duration": 0.37, - "description": "Quiet short click — low-key UI tap / soft selection. Short accent, sync exactly to the on-screen action." - }, - "click": { - "file": "click.mp3", - "duration": 0.37, - "description": "Crisp UI click — button press, toggle, selection. Short accent, sync exactly to the on-screen action." - }, - "error": { - "file": "error.mp3", - "duration": 1.62, - "description": "Negative / error tone — failure state, a 'wrong' beat, or a glitchy interruption." - }, - "glitch-1": { - "file": "glitch-1.mp3", - "duration": 2.64, - "description": "Punchy digital glitch — hard-cut accent or sudden reveal. Trigger on the hit; let the decay bleed into the next shot (J-cut)." - }, - "glitch-2": { - "file": "glitch-2.mp3", - "duration": 3.5, - "description": "Harsh, longer glitch — chaotic / jarring transition or a distorted reveal." - }, - "glitch-3": { - "file": "glitch-3.mp3", - "duration": 3.1, - "description": "Low-key glitch texture — subtle digital shift, minimal transition that sits under other audio." - }, - "impact-bass-1": { - "file": "impact-bass-1.mp3", - "duration": 2.12, - "description": "Bass impact hit — logo/hero snap, headline slam. Trigger on the visual landing; decay carries into the next shot (J-cut)." - }, - "impact-bass-2": { - "file": "impact-bass-2.mp3", - "duration": 2.59, - "description": "Bass impact with a short swell — brief anticipation then a deep hit. Place so the peak lands on the reveal." - }, - "key-press": { - "file": "key-press.mp3", - "duration": 0.4, - "description": "Single key press — one keystroke / terminal-input beat. Short accent, sync to the typed character." - }, - "notification": { - "file": "notification.mp3", - "duration": 2.46, - "description": "Notification chime — alert, message-in, toast/badge appears. Sync to the element entering." - }, - "ping": { - "file": "ping.mp3", - "duration": 1.32, - "description": "Sharp electronic ping — punchy accent on a key reveal or data point. Sync to the beat." - }, - "pop": { - "file": "pop.mp3", - "duration": 0.72, - "description": "Quick pop — element appear/spawn, chip/tag/badge in. Small precise accent, sync to the pop-in." - }, - "riser": { - "file": "riser.mp3", - "duration": 10.03, - "description": "Long cinematic riser (~10s build, peak at the end). Trigger at (climax_time − 10.03s) so it crests exactly on the reveal." - }, - "sparkle": { - "file": "sparkle.mp3", - "duration": 1.8, - "description": "Bright sparkle / shimmer — magical reveal or 'shine' highlight on a hero element. Sync to the highlight." - }, - "typing": { - "file": "typing.mp3", - "duration": 1.5, - "description": "Typing burst (~1.5s of keys) — keyboard / code typing reveal, text-being-typed beat. Start as the text begins typing." - }, - "whoosh-cinematic": { - "file": "whoosh-cinematic.mp3", - "duration": 5.54, - "description": "Cinematic whoosh build (~5.5s) — sweeping scene transition. Align so the swell peaks on the cut." - }, - "whoosh-short": { - "file": "whoosh-short.mp3", - "duration": 0.57, - "description": "Short whoosh — quick swipe/slide accent, fast element move, snappy transition. Sync to the motion." - }, - "whoosh": { - "file": "whoosh.mp3", - "duration": 0.57, - "description": "Punchy whoosh/impact — fast reveal or hard transition accent. Sync to the motion." - } -} diff --git a/skills/faceless-explainer/assets/sfx/notification.mp3 b/skills/faceless-explainer/assets/sfx/notification.mp3 deleted file mode 100644 index d3f0d1fa22..0000000000 Binary files a/skills/faceless-explainer/assets/sfx/notification.mp3 and /dev/null differ diff --git a/skills/faceless-explainer/assets/sfx/ping.mp3 b/skills/faceless-explainer/assets/sfx/ping.mp3 deleted file mode 100644 index 99808c8b63..0000000000 Binary files a/skills/faceless-explainer/assets/sfx/ping.mp3 and /dev/null differ diff --git a/skills/faceless-explainer/assets/sfx/pop.mp3 b/skills/faceless-explainer/assets/sfx/pop.mp3 deleted file mode 100644 index e3ed16fef0..0000000000 Binary files a/skills/faceless-explainer/assets/sfx/pop.mp3 and /dev/null differ diff --git a/skills/faceless-explainer/assets/sfx/riser.mp3 b/skills/faceless-explainer/assets/sfx/riser.mp3 deleted file mode 100644 index 0cea32c08d..0000000000 Binary files a/skills/faceless-explainer/assets/sfx/riser.mp3 and /dev/null differ diff --git a/skills/faceless-explainer/assets/sfx/sparkle.mp3 b/skills/faceless-explainer/assets/sfx/sparkle.mp3 deleted file mode 100644 index dae37802ff..0000000000 Binary files a/skills/faceless-explainer/assets/sfx/sparkle.mp3 and /dev/null differ diff --git a/skills/faceless-explainer/assets/sfx/typing.mp3 b/skills/faceless-explainer/assets/sfx/typing.mp3 deleted file mode 100644 index ff69cc0420..0000000000 Binary files a/skills/faceless-explainer/assets/sfx/typing.mp3 and /dev/null differ diff --git a/skills/faceless-explainer/assets/sfx/whoosh-cinematic.mp3 b/skills/faceless-explainer/assets/sfx/whoosh-cinematic.mp3 deleted file mode 100644 index 93b7218007..0000000000 Binary files a/skills/faceless-explainer/assets/sfx/whoosh-cinematic.mp3 and /dev/null differ diff --git a/skills/faceless-explainer/assets/sfx/whoosh-short.mp3 b/skills/faceless-explainer/assets/sfx/whoosh-short.mp3 deleted file mode 100644 index e23e513d27..0000000000 Binary files a/skills/faceless-explainer/assets/sfx/whoosh-short.mp3 and /dev/null differ diff --git a/skills/faceless-explainer/assets/sfx/whoosh.mp3 b/skills/faceless-explainer/assets/sfx/whoosh.mp3 deleted file mode 100644 index e23e513d27..0000000000 Binary files a/skills/faceless-explainer/assets/sfx/whoosh.mp3 and /dev/null differ diff --git a/skills/faceless-explainer/phases/audio/guide.md b/skills/faceless-explainer/phases/audio/guide.md deleted file mode 100644 index c070a8e19a..0000000000 --- a/skills/faceless-explainer/phases/audio/guide.md +++ /dev/null @@ -1,55 +0,0 @@ -# Audio (Phase 2.5) - workflow guide - -Phase 2.5 is handled end-to-end by **`scripts/audio.mjs`**: `narrator_scripts` -> per-scene voice + word JSON + `audio_meta.json`, plus optional detached BGM. In Step 3, the orchestrator runs `node audio.mjs` directly; there is **no subagent**. The script first uses ffprobe on TTS output to get the measured total duration, then asks the local MusicGen fallback to generate one ~28s seed clip in a single call (one `generate()`, kept within the model's ~30s positional-encoding limit). After that: if the target is shorter than the seed, it trims the seed; if the target is longer than the seed, it uses an ~0.3s crossfade to loop and tile the seed into an equal-length `assets/bgm.wav`; finally it applies overall fade-in and fade-out. Compared with the old segment-by-segment stitching, this avoids hard seams. - -For the full flag list, see SKILL.md Step 3 / `audio.mjs --help`. This file only describes the schema and failure modes. - -## Artifacts - -``` -./audio_meta.json # index for prep.mjs (PROJECT_DIR root) -assets/voice/scene_<N>.wav # per-scene narration (PROJECT_DIR/assets/, no hyperframes/ subdirectory) -assets/voice/scene_<N>_words.json # per-scene word-level timestamp JSON -assets/bgm.wav # BGM (optional; may not be written yet when audio.mjs exits) -``` - -`audio_meta.json` schema (consumed by `prep.mjs`): - -```json -{ - "tts_provider": "heygen" | "elevenlabs" | "kokoro", - "voice_id": "<provider-specific voice id>", // actual TTS voice id used (top-level) - "bgm_provider": "lyria" | "musicgen" | null, - "bgm_enabled": true | false, - "bgm_pending": true | false, // detached BGM may still be rendering; Step 7 wait-bgm.mjs verifies it - "bgm_path": "assets/bgm.wav" | null, - "bgm_log": "<private mkdtemp dir>/bgm-<timestamp>.log" | null, - "bgm_pid": 12345 | null, - "bgm_mode": "detached-single" | "detached-seed-loop" | "detached-seed-trim" | null, - "bgm_target_duration_s": 62.4 | null, // BGM target duration (= measured total voice duration; trim/loop to this) - "bgm_seed_duration_s": 28 | null, // MusicGen: single seed clip length (<=30s to avoid the positional-encoding limit) - "bgm_loop_count": 3 | null, // number of seed crossfade-loop tiles needed to reach target duration (1 when trimming) - "total_duration_s": <sum of measured voice durations for successful scenes (failed scenes excluded)>, - "scenes": { - "scene_1": { - "voicePath": "assets/voice/scene_1.wav", - "voiceDuration": 4.823, - "wordsPath": "assets/voice/scene_1_words.json" - }, - "scene_2": { ... } - } -} -``` - -Provider chain / voice id / mood prompt / environment detection are all handled inside `audio.mjs`; the orchestrator does not choose them. Force a provider with `--provider <name>`, and override the BGM mood with `--bgm-prompt "<text>"`. See the `hyperframes-media` skill for the underlying capability documentation. - -## Failure Modes - -| Failure | Behavior | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Single scene TTS exits 1 | That scene is omitted from `audio_meta.scenes`; the rest continue. Phase 4a falls back to `group_spec` `estimatedDuration_s` (from `narrator_scripts.estimatedDuration`). | -| BGM pending | `bgm_enabled: true` + `bgm_pending: true`. Step 7 runs `wait-bgm.mjs` first, and mounts track 11 only when ready. | -| BGM exits 1 | `wait-bgm.mjs` produces `bgm_status.json { status: "failed" }` during Step 7 finalize (this phase does not produce it); voice is complete, and Phase 4c skips the `<audio>` element. | -| All scenes fail | `audio.mjs` exits 1, reports an error on stderr, and the pipeline stops. | - -BGM failure never blocks; only "zero scenes received voice" is fatal. diff --git a/skills/faceless-explainer/phases/audio/lyria-recipe.py b/skills/faceless-explainer/phases/audio/lyria-recipe.py deleted file mode 100644 index 5ea2abe878..0000000000 --- a/skills/faceless-explainer/phases/audio/lyria-recipe.py +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env python3 -"""Generate BGM using Google Lyria RealTime API. - -Usage: - python lyria-recipe.py --output <path> --duration <seconds> [tuning flags] - -Requires: - $GOOGLE_API_KEY or $GEMINI_API_KEY environment variable (treated as aliases). - pip install google-genai python-dotenv (installed on demand by the audio agent). -""" - -from __future__ import annotations - -import argparse -import asyncio -import os -import sys -import wave -from pathlib import Path - -DEFAULT_PROMPT = "Uplifting corporate tech, bright and modern, gentle piano with synth pads" -SAMPLE_RATE = 48000 -CHANNELS = 2 -SAMPLE_WIDTH = 2 # 16-bit - - -def parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser(description="Generate BGM via Google Lyria RealTime.") - p.add_argument("--output", required=True, help="Output WAV path.") - p.add_argument("--duration", type=float, required=True, help="Target duration in seconds.") - p.add_argument("--prompt", default=DEFAULT_PROMPT, help="Mood / instrumentation prompt.") - p.add_argument("--negative-prompt", default=None, help="Styles to exclude (optional).") - p.add_argument("--bpm", type=int, default=110) - p.add_argument("--brightness", type=float, default=0.8, help="0-1, higher = brighter mood.") - p.add_argument("--density", type=float, default=0.5, help="0-1, higher = fuller mix.") - p.add_argument( - "--scale", - default="MAJOR", - help="MAJOR / MINOR / PENTATONIC / etc. — see google.genai.types.Scale. Pass empty string for none.", - ) - return p.parse_args() - - -async def generate_bgm(args: argparse.Namespace) -> dict: - from google import genai - from google.genai import types - - api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY") or "" - if not api_key: - raise RuntimeError("Neither GOOGLE_API_KEY nor GEMINI_API_KEY is set.") - - client = genai.Client( - api_key=api_key, - http_options={"api_version": "v1alpha"}, - ) - - out_path = Path(args.output) - out_path.parent.mkdir(parents=True, exist_ok=True) - - target_bytes = int(args.duration * SAMPLE_RATE * CHANNELS * SAMPLE_WIDTH) - - cfg: dict = {"bpm": args.bpm, "temperature": 1.0} - if args.density is not None: - cfg["density"] = args.density - if args.brightness is not None: - cfg["brightness"] = args.brightness - if args.scale: - scale_enum = getattr(types.Scale, args.scale, None) - if scale_enum: - cfg["scale"] = scale_enum - - prompts = [types.WeightedPrompt(text=args.prompt, weight=1.0)] - if args.negative_prompt: - prompts.append(types.WeightedPrompt(text=args.negative_prompt, weight=-1.0)) - - buf = bytearray() - timeout = args.duration + 8 - - async with client.aio.live.music.connect( - model="models/lyria-realtime-exp", - ) as session: - await session.set_weighted_prompts(prompts=prompts) - await session.set_music_generation_config( - config=types.LiveMusicGenerationConfig(**cfg), - ) - await session.play() - - async def collect(): - while len(buf) < target_bytes: - async for msg in session.receive(): - sc = msg.server_content - if sc and sc.audio_chunks: - for chunk in sc.audio_chunks: - buf.extend(chunk.data) - if len(buf) >= target_bytes: - return - await asyncio.sleep(1e-6) - - try: - await asyncio.wait_for(collect(), timeout=timeout) - except TimeoutError: - print(f"Timeout after {timeout:.0f}s, collected {len(buf)} bytes", file=sys.stderr) - - audio = bytes(buf[:target_bytes]) - with wave.open(str(out_path), "wb") as wf: - wf.setnchannels(CHANNELS) - wf.setsampwidth(SAMPLE_WIDTH) - wf.setframerate(SAMPLE_RATE) - wf.writeframes(audio) - - actual_duration = len(audio) / (SAMPLE_RATE * CHANNELS * SAMPLE_WIDTH) - print(f"BGM: {out_path} ({actual_duration:.2f}s)") - return {"file": str(out_path), "duration_sec": round(actual_duration, 2)} - - -def main() -> None: - args = parse_args() - try: - asyncio.run(generate_bgm(args)) - except RuntimeError as exc: - print(f"BGM generation failed: {exc}", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/skills/faceless-explainer/phases/captions/guide.md b/skills/faceless-explainer/phases/captions/guide.md deleted file mode 100644 index 0ef6e64bc1..0000000000 --- a/skills/faceless-explainer/phases/captions/guide.md +++ /dev/null @@ -1,133 +0,0 @@ -# Captions (Phase 4a.5) - deterministic, no subagent - -Captions are produced by **two deterministic scripts** that hand off to each other and emit `compositions/captions.html`; `assemble-index.mjs` then mounts it in `index.html` as a **track-12 clip**. There is **no captions LLM agent** (removed). The entire caption path uses zero LLM calls, so the old class of render-time footguns from "agent hand-writes captions.html" (§6 Illegal invocation / timeline not registered / raw colors / two groups on screen / fitText not wired) is eliminated. - -``` -captions.mjs group -> caption_groups.json (word engine: clean/group/classify/global timing/scene+surface) -captions.mjs html -> compositions/captions.html (HTML engine: choose skin + inject words + brand-tokenize + self-check) -assemble-index.mjs -> if file exists, mount track-12 clip (data-composition-id="captions", data-start=0, data-duration=total) -``` - -Captions remain an **independent file + sub-composition** (not inline), so the **studio caption editor** (recognizes `.caption-group` + fetchable caption source file) and runtime `captionOverrides` (recognizes `.caption-group/.caption-word`) both continue to work. - ---- - -## 0. Inputs - -The following are inputs for `captions.mjs html`; the input for `captions.mjs group` is `group_spec.json` (see §1). - -| File | Purpose | -| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `caption_groups.json` | **Single source of truth for word data**: `groups[]` (`id`/`scene_id`/`surface`/`start`/`end`/`text`/`words[]`, global seconds, cleaned, classed), `total_duration_s`, `stats`. Produced by `captions.mjs group`. | -| `design-system/chunks/tokens.css` | Brand DNA (`--font-display`/`--font-body`/`--brand-primary`/`--canvas`/`--ink` + surface aliases). Used at build for the canvas-measure `FONT_FAMILY` and to validate brand-strict colors; the tokens themselves are declared once globally in `index.html`'s `<head>` (by `assemble-index.mjs`) and inherit into `captions.html` when it mounts, so the per-file `<style data-brand-tokens>` block is stripped before write. | -| `design-system/inference.json` (optional) | Used for skin scoring (site DNA / selected preset vibe). If missing, fall back based on brand color lightness/darkness. | -| `design-system/chunks/caption-skin.html` (optional) | **Preset-provided caption skin (first source)** - when the selected preset places `caption-skin.html` in `style-presets/<preset>/`, `emit-chunks` copies it here. **If present -> prefer it** (prebaked, already tokenized; see §2); absent -> fall back to registry scoring. | - ---- - -## 1. Run (orchestrator runs Bash directly in Step 5.5, before scene fan-out) - -```bash -(cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/captions.mjs group \ - --group-spec ./group_spec.json --hyperframes . \ - --tokens design-system/chunks/tokens.css --out ./caption_groups.json) - -(cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/captions.mjs html \ - --hyperframes . --groups ./caption_groups.json \ - --tokens design-system/chunks/tokens.css \ - --inference design-system/inference.json \ - --out compositions/captions.html) -``` - -**flags (`captions.mjs html`)**: `--skin caption-<name>` forces a skin (supported set only); `--no-emit` only scores + writes `caption_skin_scores.json`, without installing/generating; `--skin-file <path>` uses a predownloaded skin (offline/CI, skips `npx hyperframes add`). - -**skip code (exit 0, not an error)**: `captions: skipped (<reason>)` - no caption groups / no brand tokens. In this case captions.html is not generated, assemble-index does not mount track 12, and the video still renders normally, just without captions. - ---- - -## 2. Source Priority + Supported Skin Set - -**First source - preset-provided**: if the selected preset has `chunks/caption-skin.html` (copied by `emit-chunks` from `style-presets/<preset>/caption-skin.html`), `captions.mjs html` **prefers it**. It is a prebaked, brand-tokenized skin; the script only performs **generic fill-in** (inject `var GROUPS` / `var DURATION` / `data-duration` + inline `tokens.css`), with no per-preset code. `--no-preset-skin` disables it, and `--skin <registry>` can still force a registry skin. `design.html` also embeds it as **§C live preview** (see design-system/guide.md). **Only when `caption-skin.html` is absent** does it fall back to scoring the closed registry set below. - -**Second source - closed registry set. Two skins have been reviewed in** (script `SKINS` table `supported: true`): - -| Skin | Readability | Selection condition (`scoreSkins`) | -| ---------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| `caption-pill-karaoke` | Built-in opaque pill (no scrim needed) | **Safe default**. Wins when `voice_tone` = warm/neutral/missing; any tie falls back to it | -| `caption-highlight` | Transparent -> transform injects a brand-strict scrim band | Wins when `voice_tone` = **direct** (+2); loud presets (neo-brutalism/raw-grid/...) get another +1 | - -Both satisfy: canonical `.caption-group/.caption-word` (recognized by studio + captionOverrides; highlight adds its own `.hl-*` classes **alongside** the canonical classes), tokenizable CSS colors, bottom placement, and runtime grouping that can be bypassed. `scoreSkins` scores with `site_dna.voice_tone` + `selected.name` from `inference.json`, is **deterministic**, and ties always return pill-karaoke. `--skin <name>` force-overrides scoring. - -Other skins each have skin-specific blockers and must be reviewed in **one by one** according to a descriptor (see the `SKINS` table in the script + the corresponding transform branch). **Do not** assume plug-and-play: - -| Skin | Blocker | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| neon-accent / emoji-pop | Colors/glow are computed in **JS with parseInt(hex)** -> cannot be mechanically tokenized; keywords/emoji are hard-coded English word lists (animations go silent for other products) | -| weight-shift | No `.caption-word` class (animation acts on the line) -> studio detection gap | -| clip-wipe | `.wp-*` class names + RAW_GROUPS/KEYWORDS are **hard-coded by index** | -| editorial-emphasis | Captions are at `top:580px`, in the **middle of the canvas** -> incompatible with the bottom caption-band model | - -If `--skin` points to an unsupported skin, the script exits 1 and prints the concrete reason. - ---- - -## 3. Deterministic Skin Transformations Performed by `captions.mjs html` (pill-karaoke example) - -The script reads the downloaded skin file and performs **asserted string transforms** according to the descriptor (if any handle is missing = registry drift = loud exit 1; never silently emit empty captions): - -1. Remove Google Fonts `<link>` (brand @font-face is injected into `index.html` by assemble-index; the flattened sub-composition can use it inside that document). -2. Remove demo `<video>` placeholder + its dead CSS. -3. host `data-composition-id="caption-pill-karaoke"` -> `"captions"`; `data-duration="8"` -> `total_duration_s`. -4. **`var DURATION = 8` -> `total_duration_s`**. Otherwise the skin's `normalizeWords` clamps every word's `end` to 8s -> captions are broken after 8 seconds in a 60-90s video. -5. **Inject engine groups**: `var GROUPS = <caption_groups groups>`, bypassing the skin's built-in `normalizeWords` + scene-agnostic `makeGroups`. Engine groups are already in global seconds, scene-aware, and non-overlapping - this single step solves both "words clamped to 8s" and "captions cross scene cuts." -6. Change per-word karaoke from **editing color values** to **toggling `.is-active` class** (CSS tokens provide color): `.caption-word { color: color-mix(--ink 45%, --canvas) }`, `.caption-word.is-active { color: var(--ink) }`. GSAP cannot interpolate `var()` colors; class flips are both brand-strict and readable. -7. **Double rename**: host `data-composition-id` and `window.__timelines["caption-pill-karaoke"]` are **both** changed to `"captions"` (compositionScoping only remaps writes when timeline key === inner root composition-id; both must change so it lands at `__timelines["captions"]`). -8. Full-film tail anchor `tl.to({}, { duration: DURATION }, 0)`, so the sub-composition timeline duration equals the host clip duration. -9. Inline `tokens.css` into `<style data-brand-tokens>`; tokenize hard-coded CSS colors/fonts: pill bg `#e7e5e7` -> `var(--canvas)`, shadow `rgba(0,0,0,.12)` -> `color-mix(in srgb, var(--ink) 14%, transparent)`, font `"Poppins"` -> `var(--font-display)` (the JS `FONT_FAMILY` for measureText uses the real family name extracted from tokens.css, because canvas text measurement cannot use `var()`). - -**Readability (for this skill = visual keep-out + band)**: pill-karaoke has a built-in **opaque pill** (`background: var(--canvas)` + active text `var(--ink)` -> constant contrast), so it needs **no** scrim and **no** render-time contrast probe. Transparent skins (such as caption-highlight) must add a brand-strict gradient scrim band as the first child of the caption root (`color-mix(var(--ink) ...)`, z-index below `.caption-group`). Scene foreground keeping the upper ~83% clear is enforced by `hyperframes-scene.md` constraint #13 + visual-design briefs (see those two places), not by this script. - -### 3b. Differences / Extra Transformations for caption-highlight - -highlight (TikTok-style per-word red background sweep) uses an **independent transform branch** from pill. Differences: - -1. **No demo `<video>` element** (only dead `#hl-video` CSS) -> do not remove an element, only remove that dead CSS rule. -2. **Different grouping data shape**: its build/timeline loop consumes a **flat global `WORDS` array** + `GROUPS` index ranges `{wordStart,wordEnd,start,end}` (word element id = `wordStart+i`). The transform flattens engine groups into these two structures, and **also** removes demo `TRANSCRIPT` plus the **index-hard-coded `RAW_GROUPS`** (the same blocker as clip-wipe, structurally solved in this new branch). -3. **Parallel classes**: add canonical `.caption-group`/`.caption-word` **alongside** `.hl-group`/`.hl-word` (preserves built-in animation while letting studio/captionOverrides recognize them). -4. **Scrim band**: because it is transparent, tokenize the full-screen first child `.hl-overlay` (z-index 1, below words at z-index 10) into a bottom brand-strict gradient band (`color-mix(var(--ink) ...)`). -5. **Adaptive contrast (critical)**: active words sit on a solid `var(--brand-primary)` fill, and **the primary color lightness is unknown at build time** (deterministic script cannot measure color and does not gamble on `contrast-color()`) - therefore **no single text color** is always safe on it (canvas washes out on light primary colors; ink washes out on dark primary colors). Solution: text fill `var(--canvas)` **+ 8-direction `var(--ink)` stroke** (plus a soft ink shadow). Readability no longer depends on primary-color brightness; it rides on the **guaranteed-contrast `canvas↔ink` pair**: light primary -> ink stroke outlines it, dark primary -> canvas fill pops, any primary color stays clear. Inactive words on the scrim follow the same idea (stroke defines shape). -6. **Color tokenization**: red gradient `#ff1745→#df1238` -> `var(--brand-primary)` (dark end `color-mix(... var(--ink))`) while preserving solid fill; red shadow -> `color-mix`; text shadow -> ink stroke described above; `"Montserrat"` -> `var(--font-display)` (canvas measureText uses the real family name extracted from tokens). -7. **Geometry**: 80px uppercase @ `bottom:140px` reaches ~y845 (above the reserved band) -> shrink to 46px @ `bottom:36px`, so 1-2 lines both land inside the keep-out band (y900-1080). -8. Double rename -> `"captions"` + full-film tail anchor (same as pill steps 7/8). - -**self-lint**: common gates + skin-specific gates (pill / highlight each have dedicated assertions) are described in §4. - ---- - -## 4. Node Structure Self-Check (replaces old browser self-lint) - -Before writing, `captions.mjs html` asserts the produced artifact (`check-compositions.mjs` does not scan captions.html, so this is the only structure gate). Any failure exits 1. - -**Common gates**: `data-composition-id="captions"` exists, literal `window.__timelines["captions"]` exists, `.caption-group`/`.caption-word` exist, placeholder string "Every great video starts" is gone, demo video is gone, no Google Fonts link, **no `window.getComputedStyle(`/`requestAnimationFrame(`/`matchMedia(`**, `DURATION === total_duration_s`, brand-strict (after removing `<style data-brand-tokens>`, zero raw hex/rgb). - -**Skin-specific gates**: pill - no `#avatar-video`, `var DURATION` rewritten; highlight - no `RAW_GROUPS` residue, no `<video>`, full-film tail anchor present. - ---- - -## 5. Failure Modes - -| Symptom | Root cause | Fix | -| ----------------------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| `captions: skipped` | No caption_groups / no tokens.css | Normal - do not mount track 12; video still renders | -| `transform "...": expected literal not found` | Registry skin changed; handle drift | Compare against new skin source and update that skin's descriptor / transform strings in the script | -| `self-lint: brand-strict violation` | Color/font not tokenized (common when adding skins) | Add tokenization mappings for that skin; skins that compute colors in JS with parseInt(hex) (neon/emoji) cannot be mechanically tokenized - see §2 | -| `--skin "..." not yet supported` | Points to an unreviewed skin | Use the supported set, or write a descriptor + transform for that skin according to §2/§3 | -| `npx hyperframes add ... failed` | Offline / no registry | Pass `--skin-file <downloaded skin>` | -| Captions break after 8 seconds | (Regression) DURATION was not rewritten | Self-check already asserts `DURATION === total`; confirm transform step 4 matched | -| Captions cross scene cuts / two groups onscreen | (Regression) used the skin's built-in makeGroups instead of engine groups | Confirm transform step 5 matched (inject engine GROUPS) | - ---- - -## 6. Acceptance - -Render a 60-90s captioned video and verify: 1. captions remain correct after 8s (DURATION/full-film tail anchor); 2. per-word highlight works and does not overlap across scenes (engine groups); 3. readable on both dark/light themes (pill built-in contrast / highlight scrim band); 4. scene foreground stays in the upper ~83%, backgrounds remain full-bleed (keep-out); 5. studio recognizes `.caption-group`; 6. node self-check has zero failures; 7. `--no-emit` skin selection can be reviewed (neutral -> pill, direct -> highlight); 8. `--skin caption-highlight` can be forced and renders. diff --git a/skills/faceless-explainer/phases/design-system/README.md b/skills/faceless-explainer/phases/design-system/README.md deleted file mode 100644 index 85119c1651..0000000000 --- a/skills/faceless-explainer/phases/design-system/README.md +++ /dev/null @@ -1,231 +0,0 @@ -# Style Preset Standard - Using Block Frame as the Reference - -`block-frame/` is the **reference implementation (reference preset)**. This README defines what a style preset must contain, what each part outputs, and how to **add / refactor / convert another style** into the standard format. - -> The fastest way to start a new preset: `cp -r block-frame <new-name>`, then rewrite section by section according to §2, follow the rules in §4, and verify with §6. - ---- - -## 1. Directory Shape - -``` -<preset-name>/ # directory name = preset internal name (lowercase kebab-case) -├── preset.md # preset-meta + §A/§B/§D/§T/§E/§G/§H/§I -├── components/ # >=1 <id>.md file, each one paste-ready block -│ ├── hero.md -│ ├── feature-card.md -│ └── ... -└── caption-skin.html # required - preset-provided caption skin (see §3.5) -``` - -The build script (`phases/design-system/scripts/build-design.mjs`) reads by directory; there are **no extra convention files** such as `studio`. Every preset has the same directory shape (`preset.md` + `components/` + `caption-skin.html`); only `block-frame/` additionally carries this `README.md` (= the standard itself, both reference implementation and documentation). - -### 1.1 Existing Presets (reference set for comparison) - -Before creating a new one, scan them first: **(a) avoid duplicate names / duplicate positioning; (b) choose the preset whose visual language is closest to your target as the `cp -r` starting template** (§5), which is usually faster than starting from block-frame. `name` = directory name = `preset-meta.name`; fingerprints come from each preset's `preset-meta.fingerprint` (used for preset selection / inference matching; see §2.0). - -| `name` (directory name) | label | One-sentence style fingerprint | -| ----------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------- | -| `block-frame` | Block Frame | hard black shadow · 4px solid ink border · saturated pastel cycle (**reference implementation**) | -| `neo-brutalism` | Neo-Brutalism | hard shadow · thick solid border · hit-and-hold motion · high-density high contrast | -| `creative-mode` | Creative Mode | warm cream paper · thick ink square border · color-to-ink hard shadow · editorial magazine voice | -| `retro-zine` | Retro Zine | paper-on-paper offset panels · 3px ink border · soft paper shuffle · warm paper over forest green | -| `peoples-platform` | People's Platform | triple overprint shadow · cream inset frame · stamp impact · manifesto voice | -| `pin-and-paper` | Pin & Paper | yellow paper texture · hard ink shadow with zero blur · fine ink border · handwritten field-note voice | -| `daisy-days` | Daisy Days | hard charcoal shadow · chunky charcoal radius · rounded display type · picture-book pastels · pop in and settle | -| `playful` | Playful | double-stroked offset frame · asymmetric organic blob · back-overshoot hand placement · doodle | -| `scatterbrain` | Scatterbrain | softly blurred lifted paper · borderless sticky notes · hand-placed micro-tilt · warm pastel paper stack | -| `8-bit-orbit` | 8-Bit Orbit | pixel-stacked offsets · pixel-snap flicker · dark neon · closed palette | -| `sakura-chroma` | Sakura Chroma | hard zero-blur shadow · 1.5px ink border · refined paper snap · cassette-package editorial voice | -| `stencil-tablet` | Stencil & Tablet | fully flat no shadow · rounded stone tablets · refined stamps · earthy saturation · stencil display face | -| `editorial` | Editorial / Swiss | no shadow or hairline · hairline border · restrained slide-in · low-density Swiss style | -| `editorial-forest` | Editorial Forest | literary quarterly · serif 500 with opsz · mono uppercase wide tracking · flat paper no shadow | -| `emerald-editorial` | Emerald Editorial | strict rectangles · no shadow · 4px solid ink line · double-line playbill · extreme Bodoni scale | -| `soft-editorial` | Soft Editorial | soft radius · no shadow · 1px warm ink dashed line · translucent white + pastel cards · small-format quarterly voice | -| `capsule` | Capsule | universal capsule shapes · soft low-opacity offset · Didone serif + Grotesk · floating capsule wallpaper | -| `liquid-glass` | Liquid Glass | inner highlight · translucent hairline edge · rise-and-settle motion · high-contrast aurora base | - ---- - -## 2. `preset.md` - Top to Bottom - -### 2.0 `preset-meta` (fenced JSON, **required** - missing or invalid JSON fails build immediately) - -The **first block** in the file must be ` ```preset-meta { ... } ``` `. Block Frame's: - -```json -{ - "name": "block-frame", // internal name, = directory name - "label": "Block Frame", // display name - "fingerprint": { // one-sentence style fingerprint (human-readable + preset selection reference) - "shadow": "hard-offset-black", "border": "4px-solid-ink", - "palette": "saturated-pastel-cycle", "motion": "tilt-and-snap", - "decoration": "tilted-puncture" - }, - "match_signals": [ // for automatic inference: site capture matching these signals increases this preset's score - { "kind": "shadow_zero_blur", "weight": 0.3 }, - { "kind": "thick_solid_border", "weight": 0.3 } - ], - "best_for": ["indie SaaS launches", "agency credentials", ...], // suitable use cases - "avoid_for": ["regulated disclosures", "formal legal briefs", ...], // unsuitable use cases - "chromeFonts": { // "native fonts" for the design.html preview page (not brand DNA) - "googleFontsHref": "https://fonts.googleapis.com/css2?family=Inter:wght@...&family=Space+Grotesk:wght@...&display=swap", - "display": "Inter", "body": "Inter", "script": "Inter", "mono": "Space Grotesk" - } -} -``` - -- `match_signals` determines automatic matching when `build-design` runs without `--style`; ignored when manually passing `--style <name>`. -- `chromeFonts` makes the design.html document chrome + §T atlas + §6 preview render in the preset's native typography (via `.preset-native-scope`; see §I); **brand fonts still apply to paste-ready §6 component code**. - -### 2.1 Each `## §X` Section - -Parsed as `## §<letter> <title>`. Block Frame has 8 sections, in this order: - -| Section | Required? | Content | Downstream artifact | -| ------------------------------ | ------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| **§A** Director's intent | recommended | prose: director intent and tone for this style | design.html §1 prose | -| **§B** Decoration tokens | **required** | `:root { ... }` design tokens | -> `ROOT` marker -> `chunks/tokens.css` | -| **§D** Font pairing fallback | recommended | one bullet per role: `- **display**: \`'Name1'\` · \`'Name2'\`` | fallback chain when site fonts cannot resolve (otherwise final hard fallback) | -| **§T** Type-role atlas | optional (standard includes it) | ` ```type-roles ` JSON, named text roles | -> `chunks/type-roles.md` (worker looks up by id) | -| **§E** Motion | **required** | `const EASE = {...}; const DUR = {...}` GSAP constants | -> `MOTION` marker -> `chunks/easings.js` | -| **§G** Voice transform recipe | **required** | rewrite register for visible DOM text (strip/case/line breaks) | -> `VOICE` marker -> `chunks/voice.md` | -| **§H** Scene composition hints | recommended | background/material preferences / 60-30-10 color use (style reference, not contract) | -> `HINTS` -> `chunks/composition-hints.md` | -| **§I** Page-level CSS | optional (standard includes it) | design.html shell CSS + `.preset-native-scope` + `.t-trole-*` role CSS + decorative CSS | injected into design.html `<style>` | - -**Two hard constraints (the parser exits with an error):** - -- **Do not write `## §F`** - §F (components) is automatically synthesized from the `components/` directory; writing it causes `✗ declares §F inline`. -- **Do not keep `## §M` (Atomic motifs)** - motif support has been removed from this standard; use §6 components to express signature gestures. New presets should not have §M; when refactoring old presets, delete it (along with `.ds-motif*` CSS in §I). - -**`§B` token naming convention** (Block Frame example): brand colors `--brand-primary/secondary/tertiary/accent/costume`, `--ink`, `--canvas`, `--brand-gradient`, decorative colors `--deco-1..4`, fonts `--font-display/body/mono`, and preset-private tokens with a prefix (Block Frame uses `--bf-*`: `--bf-border-bold`, `--bf-shadow`, `--bf-tilt-*`, `--bf-pad-*`, ...). - -**`§T` role schema** (each entry): `id` · `family` (display/body/mono/script, resolved at render time to `var(--font-*)`) · `purpose` · `px_min`/`px_max` · `weight` · `leading` · `tracking` · `case` · `sample_html` (uses `.t-trole-<id>` class). Decorative CSS for each role lives in §I as `.t-trole-<id> { ... }`. Block Frame currently has 11 roles: `heading-xl / heading-lg / heading-md / close-title / quote-text / stat-number / card-title / step-num / label-pill / mono-tag / counter`. - -> **`sample_html` copy convention:** sample text should be the kind of **short real copy a video would use** (headline / number / eyebrow, etc.). **Do not write self-describing placeholder prose** (for example, `<p>Body sits at 24-28px, weight 400 — never uppercase...</p>` describing the role itself). That kind of self-description reads like debug notes in the design.html §T atlas, not a sample. Either provide a proper sample line, or, if the role is just generic body text without a signature worth demonstrating, do not create that role at all (let §6 components carry body copy; see how capsule leaves almost no generic body role). - ---- - -## 3. `components/` - Paste-Ready Components - -- **One `.md` = one component**; filename without `.md` = id, must match `[a-z0-9-]+`. -- At least **1 component** is required (zero components fails build). Alphabetical filename order -> deterministic output. -- File body = **bare HTML + optional `<style>`**, using `{SLOT}` placeholders (e.g. `{HEADLINE}`/`{LEDE}`/`{NUM}`). **Do not** add `<!-- COMPONENT -->` markers yourself (the parser adds them). -- File body contains only bare HTML + `<style>`; **do not write YAML frontmatter**. (Historically, frontmatter fields such as `surface`/`role`/`composes`/`avoids_same_scene`/`slots` were supported for plan-agent surface filtering + mutual-exclusion validation. **That machine has been removed**: the design system is now a pure style reference, components are selected by the Phase 4b worker via visual judgment, no longer filtered by the plan by surface/role, and there are no surface anchors / Components anchors. emit-chunks can still parse old frontmatter, but downstream no longer consumes it; do not write it in new components.) -- CSS references brand tokens with `var(--*)`; classes use a preset prefix (Block Frame uses `.bf-*`). Block Frame currently has 10 components: `hero / feature-card / stat-counter / timeline-step / quote-frame / button / chip / dot-grid-bg / corner-pins / star-burst`. - ---- - -## 3.5 `caption-skin.html` - Caption Skin (**required**, preset-provided) - -Every preset **must** include a `caption-skin.html` at its root (next to `preset.md`) = this style's **own lower-third karaoke caption look**. Captions are first-class video content, not an optional attachment. Without it, captions fall back to the generic registry pill and the visual language breaks away from the style. Block Frame includes one as reference. - -**Priority / data flow:** if the selected preset has `caption-skin.html`, it is the caption system's **first source** - `emit-chunks` copies it to `chunks/caption-skin.html`, Phase 4a.5 `captions.mjs html` **prefers it** (falling back to registry `caption-pill-karaoke` / `caption-highlight` scoring only when absent); `build-design` also embeds it into design.html as **§C live preview** (looping). **The whole chain makes zero agent judgments** - scripts select, fill words, and self-check automatically. - -It is a **"prebaked" skin**: the author writes a complete, brand-tokenized caption sub-composition, and the script only performs **generic fill-in** (the three holes below, each asserted to appear exactly once), with no per-preset code. Therefore **the contract must be followed exactly**: - -| Author must provide | Builder-filled holes (do not rename / duplicate) | -| --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| root `data-composition-id="captions"` + registered `window.__timelines["captions"]` | `var GROUPS = [];` <- engine groups | -| canonical hooks `.caption-group` / `.caption-word` (+ states `.is-active` / `.is-spoken`) | `var DURATION = 0;` and root `data-duration="0"` <- real total duration | -| all colors via `var(--*)` / `color-mix` - **zero raw hex** (passes self-lint) | empty `<style data-brand-tokens></style>` <- inline `tokens.css` (including @font-face) | -| no `<video>` / no Google Fonts `<link>` / no placeholder copy / no `window.{getComputedStyle,requestAnimationFrame,matchMedia}()` | optional `var FONT_FAMILY = "";` <- brand display family (only when the skin uses canvas `measureText` for fit) | - -**Seek-safe iron rule:** state switches must use `gsap.set(el, { className: "caption-word is-active" })` (`set` takes effect during frame-by-frame engine seek); **never** use `tl.call()` callbacks (seek does not trigger them -> caption state is wrong during render). GSAP via CDN `<script>` is fine. - -**How to write one:** - -1. `cp block-frame/caption-skin.html <your-preset>/caption-skin.html` - it is already a compliant template (three holes + hooks + seek-safe timeline + generic `buildCaptions(GROUPS)` included). -2. **Only change visuals inside `<style>`**: replace `.caption-pill` / `.caption-line` / `.caption-word{,.is-active,.is-spoken}` with tokens from your preset (border / shadow / radius / font / active highlight). **Do not touch** the three holes, `.caption-*` class names, `data-composition-id`, `window.__timelines["captions"]`, or the `gsap.set(className)` pattern. -3. Use only §B `var(--*)` for colors; use `clamp()` + flex-wrap for adaptive type, with the lower edge inside the bottom caption band (roughly y900-1080). - -**Verify:** run §8 `build-design` + `emit-chunks` -> scroll design.html to **§C** and inspect live behavior; run `captions.mjs html` for the caption artifact (see `phases/captions/guide.md`), stdout should print `skin: preset-skin (preset-local -> ...)` + `self-lint: OK` (self-check covers every contract item in the table; any mismatch exits 1 loudly). - -> The two registry karaoke skins (`caption-pill-karaoke` / `caption-highlight`) still exist, but only as **runtime fallback**. This standard requires every preset to provide its own `caption-skin.html`; missing it means **non-compliant** (caption style will break into a generic SaaS pill). `captions.mjs html` currently does not fail when the skin is missing (it falls back instead of exiting 1), so this rule is **enforced by the standard, not yet by machine**: when creating a new preset, you must add it. - ---- - -## 4. Standard Invariants (House Rules) - -1. **All text >= 24px** - every §T role `px_min`, every §I `.t-trole-*` font-size, and **every component font-size** must be at least 24px. Video must be readable at a glance from a distance; `build-design.mjs` itself says "Don't use body text under 24px in video." Headings should be around ~28px or larger to sit above 24px body text (heading > body). Purely decorative non-text values (e.g. 120px quote marks, border/shadow px) are exempt. -2. **No §M motifs** - express signature gestures with components, not motifs. -3. **Self-contained CSS** - component / §T role CSS uses only `var(--*)` tokens, with **zero raw hex/rgb** (all brand colors tokenized). -4. **Class-prefix layering** - `.t-trole-*` = §T roles; `.<prefix>-*` (e.g. `.bf-`) = this preset's components/decorations; `.ds-*` / `.preset-native-scope` are reserved for the design.html shell, do not use them in components. -5. **Section order** follows the §2 table; `§F` is never inline; `preset-meta` is always first. -6. **Own `caption-skin.html`** - every preset must provide a caption skin (§3.5), not an optional add-on. Write it according to the §3.5 contract (three holes + canonical hooks + seek-safe `gsap.set` + zero raw color), so lower-third captions share the same visual language as components. Missing it = non-compliant. - ---- - -## 5. Add a New Preset - -```bash -cd phases/design-system/style-presets -cp -r block-frame my-new-style -cd my-new-style -``` - -1. Edit `preset.md` `preset-meta`: `name`/`label`/`fingerprint`/`match_signals`/`best_for`/`avoid_for`/`chromeFonts` (replace with the new style's native fonts + Google Fonts href). -2. Rewrite sections §A->§I: change §B tokens (color/type/geometry), §D fallback fonts, §T role scale (**px_min >=24**), §E EASE/DUR, §G voice recipe, §H composition/color hints, §I shell + `.t-trole-*` + decorative CSS. -3. Rewrite `components/*.md` (replace `.bf-` prefix with yours; **all font-size values >=24**). -4. Change `caption-skin.html` `<style>` visuals to the new style (§3.5 contract: only change visuals; do not touch the three holes / `.caption-*` hooks / `data-composition-id` / `window.__timelines["captions"]` / `gsap.set` pattern). `cp -r block-frame` already brought it over; you only need to change visuals. -5. Regenerate + verify according to §6. - -## 6. Refactor an External Style / Old Preset into This Standard - -> Use this to align a non-compliant style (copied from elsewhere, or a handwritten draft) to the standard. Check each item (use block-frame for comparison): - -- [ ] Delete the entire `## §M` section + `.ds-motif*` CSS blocks in §I (motifs are deprecated). -- [ ] §T: raise every role `px_min` to >=24; also raise the corresponding §I `.t-trole-<id>` font-size to >=24; delete pure small-text content roles (e.g. 15px card-body / 18px subtitle). Signature small-text-like treatments can remain in components if they are essential. -- [ ] `components/*.md`: scan every `<style>` font-size; all values must be >=24 (heading > body). -- [ ] Ensure there is >=1 component; `§F` is not inline; `preset-meta` is valid. -- [ ] Tokenize raw hex -> tokens. -- [ ] Add `caption-skin.html` (§3.5, required): after `cp block-frame/caption-skin.html`, change `<style>` visuals to this style and tokenize to zero raw color; `captions.mjs html` should print `self-lint: OK`. -- [ ] Generate + verify according to §6. - -## 7. Convert "Another Style" (website / Figma / brand guidelines) into a Preset - -1. **Extract DNA:** primary/neutral/accent colors -> §B tokens; font families -> `chromeFonts` + §D; radius/border/shadow/spacing -> §B private tokens (`--xx-*`). -2. **Set type scale** -> §T roles (**all >=24**), including hero/title/body/eyebrow/numbers/counters. -3. **Signature gestures -> components:** turn the style's instantly recognizable elements (cards, quote frame, stat, timeline, decoration) into separate `components/<id>.md` files. -4. **Fill** §A intent, §E motion language, §G voice, §H composition/color hints. -5. **Caption look -> `caption-skin.html`** (§3.5, required): build this style's lower-third caption look according to the contract (`cp` block-frame and change visuals). -6. Apply §4 invariants and verify according to §6. - ---- - -## 8. Generate & Verify Loop - -```bash -# Run from project root (<ds-dir> is a video project's design-system directory, -# <cap> is the hyperframes capture directory) -node phases/design-system/scripts/build-design.mjs <ds-dir> --capture <cap> --style <preset-name> -node phases/design-system/scripts/emit-chunks.mjs <ds-dir> -``` - -- `build-design` -> `<ds-dir>/design.html` + `inference.json`; `--no-emit` only computes inference scores, without rendering. -- `emit-chunks` -> `<ds-dir>/chunks/` (tokens.css / easings.js / voice.md / composition-hints.md / type-roles.md / components/\*.html / index.json; when the preset has `caption-skin.html`, it also copies `chunks/caption-skin.html` and records `index.json.caption_skin_file`; see §3.5). **If design.html lacks ROOT/MOTION/VOICE markers, it exits 1** (meaning §B/§E/§G must produce output). - -**"No small text" verification** (must run after edits): list every font-size below 24px (**empty output = pass**). Covers `px` / `rem` (×16) / `vw` (×19.2 @1920), and only reads each declaration's **first size token** (= clamp lower bound or raw value), avoiding false positives on the middle `vw` term in clamp: - -```bash -grep -rhoE "font-size:[^;]+" <ds-dir>/chunks/components/*.html <ds-dir>/chunks/type-roles.md \ - | awk 'match($0,/[0-9.]+(px|rem|vw)/){t=substr($0,RSTART,RLENGTH);n=t+0;p=n; - if(t~/rem$/)p=n*16; if(t~/vw$/)p=n*19.2; - if(p<24)print " WARN "t" approx "p"px <- "$0}' -``` - -> **Do not scan only `px`** — `rem` / `vw` values will be missed; the normalized version above covers all three. - -> This grep is a **hard final check**: only empty output passes — any <24px value must be raised or deleted. - -**`caption-skin.html` verification** (§4 rule 6, required): every preset should provide one. Run: - -```bash -node <SKILL_DIR>/scripts/captions.mjs html \ - --hyperframes <ds-dir> --groups <caption_groups.json> \ - --tokens <ds-dir>/chunks/tokens.css --out <ds-dir>/compositions/captions.html -``` - -stdout should print `skin: preset-skin (preset-local -> ...)` + `self-lint: OK`. - -> When a skin is missing, the builder falls back to registry instead of exiting 1, so the "must provide `caption-skin.html`" rule is **enforced by this standard, not yet by machine**. When creating a new preset, be sure to add it; seeing `skin: preset-skin (preset-local ...)` + `self-lint: OK` from `captions.mjs html` means it is in place. diff --git a/skills/faceless-explainer/phases/design-system/scripts/build-design.mjs b/skills/faceless-explainer/phases/design-system/scripts/build-design.mjs deleted file mode 100644 index b22386bd72..0000000000 --- a/skills/faceless-explainer/phases/design-system/scripts/build-design.mjs +++ /dev/null @@ -1,2948 +0,0 @@ -#!/usr/bin/env node -/** - * build-design.mjs - * - * Merges site brand DNA (from `hyperframes capture` output) with a style - * preset into a single design.html. design.html is the only artifact - * downstream phases read. - * - * v3: replaces designlang dependency — reads hyperframes capture's native - * schema directly (capture/extracted/{tokens,design-styles,animations, - * fonts-manifest}.json + visible-text.txt). Material/imagery/voice/intent - * labels are derived in-process by deriveSiteDna() and friends. - * - * Usage: - * node build-design.mjs <design-system-dir> [--capture <dir>] [--style <preset-name>] - * [--out <file>] [--out-scores <file>] [--no-emit] - * - * --capture: path to hyperframes capture dir (default: <design-system-dir>/../capture). - * --style: force a preset (e.g. neo-brutalism, editorial). If omitted, auto-infers. - * --out: override design.html output path (default: <dir>/design.html). - * --out-scores: where to write inference.json (default: <dir>/inference.json). Always written. - * --no-emit: run inference + write inference.json, but skip design.html / component rendering. - * Used by the design-system subagent for the "review before commit" pass: - * first run with --no-emit, read inference.json, decide whether to override - * the baseline winner, then re-run with --style <X> to emit. - */ - -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { discoverSourceUrl } from "../../../scripts/lib/capture-meta.mjs"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const PRESETS_DIR = path.resolve(__dirname, "..", "style-presets"); - -// ═══════════════════ CLI ═════════════════════════════════ -const argv = process.argv.slice(2); -const outDir = path.resolve(argv[0] || "."); -let cliCaptureDir = null, - cliOut = null, - cliStyle = null, - cliOutScores = null, - cliNoEmit = false, - cliBrandPrimary = null; -for (let i = 1; i < argv.length; i++) { - if (argv[i] === "--capture" && argv[i + 1]) cliCaptureDir = argv[++i]; - else if (argv[i] === "--out" && argv[i + 1]) cliOut = argv[++i]; - else if (argv[i] === "--style" && argv[i + 1]) cliStyle = argv[++i]; - else if (argv[i] === "--out-scores" && argv[i + 1]) cliOutScores = argv[++i]; - else if (argv[i] === "--no-emit") cliNoEmit = true; - // --brand-primary <hex>: the design-system agent's screenshot-based override - // of the auto-classified brand primary (see inference.json.brand.candidates). - else if (argv[i] === "--brand-primary" && argv[i + 1]) cliBrandPrimary = argv[++i]; -} -if (!fs.existsSync(outDir)) { - fs.mkdirSync(outDir, { recursive: true }); -} -if (!fs.statSync(outDir).isDirectory()) { - console.error(`✗ ${outDir} is not a directory`); - process.exit(1); -} - -// Default capture dir: sibling to design-system/ — matches the PROJECT_DIR -// layout: PROJECT_DIR/capture/ (input) + PROJECT_DIR/design-system/ (output). -const captureDir = cliCaptureDir - ? path.resolve(cliCaptureDir) - : path.resolve(outDir, "..", "capture"); - -const outFile = cliOut ? path.resolve(cliOut) : path.join(outDir, "design.html"); -const outScoresFile = cliOutScores - ? path.resolve(cliOutScores) - : path.join(outDir, "inference.json"); - -function readJSONAbs(absPath, fallback) { - try { - return JSON.parse(fs.readFileSync(absPath, "utf8")); - } catch { - return fallback; - } -} -function readTextAbs(absPath) { - try { - return fs.readFileSync(absPath, "utf8"); - } catch { - return ""; - } -} - -// ═══════════════════ Read hyperframes capture ═════════════ -const hfTokens = readJSONAbs(path.join(captureDir, "extracted", "tokens.json"), null); -if (!hfTokens) { - console.error( - `✗ ${path.join(captureDir, "extracted", "tokens.json")} not found. ` + - `Run 'npx hyperframes capture <url> -o ${captureDir}' first.`, - ); - process.exit(1); -} -const hfDesignStyles = readJSONAbs(path.join(captureDir, "extracted", "design-styles.json"), { - typography: [], - spacing: { observed: [], baseUnit: 8 }, - radius: [], - shadows: [], - buttons: [], - cards: [], - nav: null, -}); -const hfAnimations = readJSONAbs(path.join(captureDir, "extracted", "animations.json"), null); -const hfVisibleText = readTextAbs(path.join(captureDir, "extracted", "visible-text.txt")); -const hfMeta = readJSONAbs(path.join(captureDir, "meta.json"), null); - -// Source URL discovery — shared with derive-context-pack.mjs via -// lib/capture-meta.mjs (grep CLAUDE.md/AGENTS.md/.cursorrules, fall back to -// reconstructing from meta.id host slug) so the two stay in lockstep. -const sourceUrl = discoverSourceUrl(captureDir, hfMeta); - -// ═══════════════════ Color helpers ════════════════════════ -function _normalizeHex(c) { - if (!c) return ""; - const s = String(c).trim(); - if (s.startsWith("#")) { - if (s.length === 4) { - return ("#" + s[1] + s[1] + s[2] + s[2] + s[3] + s[3]).toUpperCase(); - } - if (s.length === 7) return s.toUpperCase(); - return ""; - } - const m = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); - if (!m) return ""; - return ( - "#" + [m[1], m[2], m[3]].map((n) => parseInt(n).toString(16).padStart(2, "0")).join("") - ).toUpperCase(); -} -function _sat(hex) { - const m = String(hex).match(/^#?([0-9a-f]{6})$/i); - if (!m) return 0; - const [r, g, b] = [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16) / 255); - const max = Math.max(r, g, b), - min = Math.min(r, g, b); - if (max === 0) return 0; - return (max - min) / max; -} -function _lightness(hex) { - const m = String(hex).match(/^#?([0-9a-f]{6})$/i); - if (!m) return 0.5; - const [r, g, b] = [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16) / 255); - return 0.299 * r + 0.587 * g + 0.114 * b; -} -function _isGrayish(hex) { - const m = String(hex).match(/^#?([0-9a-f]{6})$/i); - if (!m) return true; - const [r, g, b] = [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16)); - const max = Math.max(r, g, b), - min = Math.min(r, g, b); - return max - min < 18; -} -// HSL saturation + lightness (0..100). Used by the signal-based brand classifier. -function _hslSL(hex) { - const m = String(hex).match(/^#?([0-9a-f]{6})$/i); - if (!m) return { s: 0, l: 0 }; - const [r, g, b] = [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16) / 255); - const max = Math.max(r, g, b), - min = Math.min(r, g, b); - const l = (max + min) / 2; - let s = 0; - if (max !== min) { - const d = max - min; - s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - } - return { s: s * 100, l: l * 100 }; -} -// Redmean perceptual color distance (0..~765) — used to keep secondary distinct. -function _redmean(a, b) { - const x = String(a).match(/^#?([0-9a-f]{6})$/i); - const y = String(b).match(/^#?([0-9a-f]{6})$/i); - if (!x || !y) return 999; - const xr = [0, 2, 4].map((i) => parseInt(x[1].slice(i, i + 2), 16)); - const yr = [0, 2, 4].map((i) => parseInt(y[1].slice(i, i + 2), 16)); - const rm = (xr[0] + yr[0]) / 2; - const dr = xr[0] - yr[0], - dg = xr[1] - yr[1], - db = xr[2] - yr[2]; - return Math.sqrt((2 + rm / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rm) / 256) * db * db); -} - -// ═══════════════════ Brand color derivation ═══════════════ -// Brand primary = the chromatic color most used as an interactive / repeated -// FILL — validated against 17 real sites (signal-based classifier beats the -// legacy "first button bg" by ~17pts). Scoring (per capture colorStats entry): -// score = bgCount*3 + interactiveBg*18 + areaBg*2 + sat*1.3 + log10(count)*4 -// + 400 if the color is an actual non-nav CTA background (decisive) -// * 0.15 if a low-saturation light tint (section surface, not brand) -// * 0.30 if text/logo-only (never a fill — kills the default link blue) -// * 0.45 if near-black (dark surface/text, rarely the brand fill) -// Falls back to the legacy heuristic when colorStats is absent (old captures). -// -// Ambiguous sites (multi-color brands, or a brand whose color lives only in a -// logo / large section block) can't be resolved from CSS stats alone — the -// scored candidate pool + a `confidence` are emitted to inference.json so the -// design-system agent can look at the first-screen screenshot and override via -// `--brand-primary <hex>` (see guide.md "brand review"). `_brandChrom` stashes -// the ranked pool for that report. -let _brandChrom = null; -function _brandClassify(stats, buttons) { - const btnBg = new Set(buttons.map((b) => _normalizeHex(b.background || "")).filter(Boolean)); - return stats - .map((v) => { - const hex = _normalizeHex(v.hex); - if (!/^#[0-9A-F]{6}$/.test(hex)) return null; - const { s: sat, l } = _hslSL(hex); - const chromatic = (sat > 25 && l > 5 && l < 95) || (sat > 40 && v.interactiveBg > 0); - if (!chromatic) return null; - let score = - v.bgCount * 3 + - v.interactiveBg * 18 + - v.areaBg * 2 + - sat * 1.3 + - Math.log10(Math.max(1, v.count)) * 4; - if (btnBg.has(hex)) score += 400; - if (l > 85 && sat < 40) score *= 0.15; - if (v.bgCount === 0) score *= 0.3; - if (l < 12) score *= 0.45; - return { - hex, - sat, - score: Number(score.toFixed(1)), - bgCount: v.bgCount, - interactiveBg: v.interactiveBg, - count: v.count, - onButton: btnBg.has(hex), - }; - }) - .filter(Boolean) - .sort((a, b) => b.score - a.score); -} -// Build the {primary, secondary, accent} triplet from the ranked pool. An -// explicit `primaryOverride` (the agent's screenshot pick) takes the top slot; -// secondary/accent are then derived around it. -function _tripletFromChrom(chrom, primaryOverride) { - if (!chrom.length) return null; - const primary = - primaryOverride && /^#[0-9A-F]{6}$/.test(primaryOverride) ? primaryOverride : chrom[0].hex; - const secondary = ( - chrom.find((x) => x.hex !== primary && _redmean(x.hex, primary) > 100) || - chrom.find((x) => x.hex !== primary) || { hex: primary } - ).hex; - const taken = new Set([primary, secondary]); - const accent = ( - chrom.filter((x) => !taken.has(x.hex)).sort((a, b) => b.sat - a.sat)[0] || { hex: secondary } - ).hex; - return { primary, secondary, accent }; -} -// confidence in the auto-pick: ratio of the top score to the runner-up. A clear -// winner → high; a near-tie → low → the agent should review the screenshot. -function brandConfidence(chrom) { - if (!chrom || chrom.length < 2) return { label: "high", ratio: 99 }; - const top = chrom[0].score, - second = chrom[1].score || 0.01; - const ratio = second > 0 ? top / second : 99; - const label = ratio >= 1.8 ? "high" : ratio >= 1.25 ? "medium" : "low"; - return { label, ratio: Number(ratio.toFixed(2)) }; -} - -function deriveBrandColors() { - const buttons = hfDesignStyles.buttons || []; - const stats = hfTokens.colorStats || []; - if (stats.length) { - const chrom = _brandClassify(stats, buttons); - if (chrom.length) { - _brandChrom = chrom; - const override = cliBrandPrimary ? _normalizeHex(cliBrandPrimary) : null; - return _tripletFromChrom(chrom, override); - } - } - - // ── legacy fallback: first non-gray button bg, else highest-sat palette ── - const palette = (hfTokens.colors || []) - .map(_normalizeHex) - .filter((c) => /^#[0-9A-F]{6}$/.test(c)); - - let primary = null; - for (const b of buttons) { - const bg = _normalizeHex(b.background || ""); - if (!bg) continue; - if (_isGrayish(bg)) continue; - if (bg === "#FFFFFF" || bg === "#000000") continue; - primary = bg; - break; - } - if (!primary) { - const sorted = palette.filter((c) => !_isGrayish(c)).sort((a, b) => _sat(b) - _sat(a)); - primary = sorted[0] || palette[0] || "#000000"; - } - - let secondary = null; - for (const c of palette) { - if (c === primary) continue; - if (_isGrayish(c)) continue; - if (c === "#FFFFFF" || c === "#000000") continue; - secondary = c; - break; - } - if (!secondary) secondary = primary; - - const taken = new Set([primary, secondary]); - const accentCands = palette - .filter((c) => !taken.has(c) && !_isGrayish(c)) - .sort((a, b) => _sat(b) - _sat(a)); - const accent = accentCands[0] || secondary; - - return { primary, secondary, accent }; -} - -function deriveCanvasAndInk() { - const cssVars = hfTokens.cssVariables || {}; - const canvasVarKeys = ["--background", "--bg", "--canvas", "--surface"]; - const inkVarKeys = ["--foreground", "--text", "--ink", "--color-text"]; - let canvas = null, - ink = null; - for (const k of canvasVarKeys) { - const h = _normalizeHex(cssVars[k]); - if (h) { - canvas = h; - break; - } - } - for (const k of inkVarKeys) { - const h = _normalizeHex(cssVars[k]); - if (h) { - ink = h; - break; - } - } - const palette = (hfTokens.colors || []) - .map(_normalizeHex) - .filter((c) => /^#[0-9A-F]{6}$/.test(c)) - .map((c) => ({ c, L: _lightness(c) })) - .sort((a, b) => b.L - a.L); - if (!canvas) canvas = palette[0]?.c || "#FFFFFF"; - if (!ink) ink = palette[palette.length - 1]?.c || "#000000"; - return { canvas, ink }; -} - -function deriveMaterial() { - const shadows = hfDesignStyles.shadows || []; - const radius = hfDesignStyles.radius || []; - const hasPill = radius.some((r) => /^(50%|9999px|99\d{2,}px)$/i.test(String(r))); - const blurs = []; - let zeroBlurCount = 0; - let totalSegs = 0; - for (const s of shadows) { - const segs = String(s.value || "").split(/,(?![^()]*\))/); - for (const seg of segs) { - if (/inset/.test(seg)) continue; - const lens = [...seg.matchAll(/(-?\d+(?:\.\d+)?)px/g)].map((m) => parseFloat(m[1])); - if (lens.length < 3) continue; - totalSegs++; - const blur = lens[2]; - blurs.push(blur); - if (blur === 0 && (Math.abs(lens[0]) >= 3 || Math.abs(lens[1]) >= 3)) zeroBlurCount++; - } - } - const maxBlur = blurs.length ? Math.max(...blurs) : 0; - const palette = (hfTokens.colors || []).slice(0, 6).map(_normalizeHex).filter(Boolean); - const avgSat = palette.length ? palette.map(_sat).reduce((a, b) => a + b, 0) / palette.length : 0; - if (zeroBlurCount > 0 && totalSegs > 0 && zeroBlurCount / totalSegs >= 0.3) return "brutalist"; - if (hasPill && avgSat > 0.45) return "playful"; - if (maxBlur > 24) return "soft"; - if (maxBlur >= 4) return "elevated"; - return "flat"; -} - -function deriveImagery() { - const svgs = hfTokens.svgs || []; - const sectionAssetUrls = new Set(); - for (const s of hfTokens.sections || []) { - for (const u of s.assetUrls || []) sectionAssetUrls.add(u); - } - const urls = [...sectionAssetUrls]; - const photo = urls.filter((u) => /\.(jpe?g|png|webp|gif|avif)(\?|$)/i.test(u)).length; - const svg = svgs.length + urls.filter((u) => /\.svg(\?|$)/i.test(u)).length; - const video = urls.filter((u) => /\.(mp4|webm|mov)(\?|$)/i.test(u)).length; - if (video >= 3) return "screen-recording"; - if (photo > svg * 1.5 && photo > 5) return "photography"; - if (svg > photo) return "flat-illustration"; - return "mixed"; -} - -function deriveVoice() { - const headings = (hfTokens.headings || []).filter((h) => (h.text || "").trim()); - const ctas = hfTokens.ctas || []; - const text = hfVisibleText.slice(0, 5000).toLowerCase(); - let upper = 0, - title = 0, - sentence = 0; - for (const h of headings) { - const t = (h.text || "").trim(); - if (!t) continue; - if (t.length > 3 && t === t.toUpperCase() && /[A-Z]/.test(t)) upper++; - else if ( - t - .split(/\s+/) - .slice(0, 6) - .filter((w) => /^[A-Z]/.test(w)).length >= 3 - ) - title++; - else sentence++; - } - let headingStyle = "Sentence case"; - if (upper >= Math.max(title, sentence) && upper > 0) headingStyle = "UPPERCASE"; - else if (title > sentence) headingStyle = "Title Case"; - const avgWords = headings.length - ? headings.reduce((s, h) => s + (h.text || "").split(/\s+/).filter(Boolean).length, 0) / - headings.length - : 0; - const headingLengthClass = avgWords <= 5 ? "tight" : avgWords >= 9 ? "loose" : "medium"; - const counts = {}; - for (const c of ctas) { - const first = (c.text || "").trim().split(/\s+/)[0] || ""; - const verb = first.toLowerCase().replace(/[^a-z]/g, ""); - if (verb && verb.length >= 3) counts[verb] = (counts[verb] || 0) + 1; - } - const ctaVerbs = Object.entries(counts) - .sort((a, b) => b[1] - a[1]) - .map(([value, count]) => ({ value, count })); - const youCount = (text.match(/\byou(r)?\b/g) || []).length; - const weCount = (text.match(/\bwe\b/g) || []).length; - let tone = "neutral"; - if (youCount > weCount * 2.5) tone = "direct"; - else if (weCount > youCount * 1.5) tone = "warm"; - return { - tone, - headingStyle, - headingLengthClass, - ctaVerbs, - sampleHeadings: headings.slice(0, 6).map((h) => h.text || ""), - }; -} - -function derivePageIntent() { - const url = sourceUrl || ""; - const urlPath = url.replace(/^https?:\/\/[^/]+/, "").replace(/[?#].*$/, ""); - if (/\/pricing\b/i.test(urlPath)) return "pricing"; - if (/\/blog\b|\/article\b|\/news\b/i.test(urlPath)) return "blog-post"; - if (/\/docs?\b|\/guide\b|\/reference\b/i.test(urlPath)) return "docs"; - if (/\/about\b/i.test(urlPath)) return "about"; - if (/\/contact\b/i.test(urlPath)) return "contact"; - if (urlPath === "" || urlPath === "/") return "landing"; - const title = (hfTokens.title || "").toLowerCase(); - if (/pricing|plans?/.test(title)) return "pricing"; - if (/blog|article|news/.test(title)) return "blog-post"; - return "landing"; -} - -function deriveEasingMap() { - if (!hfAnimations) return {}; - const out = {}; - const seen = new Set(); - let idx = 0; - const reps = hfAnimations.representativeAnimations || []; - for (const anim of reps) { - const t = anim.effectTiming || {}; - if (t.easing && t.easing !== "linear" && !seen.has(t.easing)) { - seen.add(t.easing); - out[`e${idx++}`] = t.easing; - } - for (const kf of anim.keyframes || []) { - if (kf.easing && kf.easing !== "linear" && !seen.has(kf.easing)) { - seen.add(kf.easing); - out[`e${idx++}`] = kf.easing; - } - } - } - return out; -} - -// ═══════════════════ Synthesize designlang shape ══════════ -// The rest of build-design.mjs (~2200 lines of rendering, scoring, inference -// emission) is unchanged — it reads tokens / motion / dna / voice / intent / -// brandHtml in designlang shape. We populate those vars from hyperframes -// capture data here so the downstream code stays put. - -const _brand = deriveBrandColors(); -const _surfaces = deriveCanvasAndInk(); -const _voiceDerived = deriveVoice(); -const _materialLabel = deriveMaterial(); -const _imageryLabel = deriveImagery(); -const _pageIntent = derivePageIntent(); -const _easings = deriveEasingMap(); - -// Border strings synthesized from buttons / cards / nav. computeFeatures() -// regex-tests these for `[3-9]px solid` / `2px solid` / `1px solid` signals. -const _borderStrings = (() => { - const seen = new Set(); - const out = []; - const push = (s) => { - const norm = String(s || "").trim(); - if (!norm || norm === "none" || seen.has(norm)) return; - if (/^\s*0px/.test(norm)) return; - seen.add(norm); - out.push(norm); - }; - for (const b of hfDesignStyles.buttons || []) push(b.border); - for (const c of hfDesignStyles.cards || []) push(c.border); - if (hfDesignStyles.nav?.border) push(hfDesignStyles.nav.border); - return out; -})(); - -// Prefix kept for error messages / log lines. v3 has no real prefix (single -// capture/ dir) so we derive a hostname-style label. -const prefix = (() => { - if (sourceUrl) { - const m = sourceUrl.match(/^https?:\/\/(?:www\.)?([^/]+)/); - if (m) return m[1].replace(/\./g, "-"); - } - return hfMeta?.id || "capture"; -})(); - -const tokens = { - $metadata: { - source: sourceUrl, - title: hfTokens.title || "", - description: hfTokens.description || "", - generator: "hyperframes-capture", - }, - primitive: { - color: { - brand: { - primary: { $value: _brand.primary, $type: "color" }, - secondary: { $value: _brand.secondary, $type: "color" }, - }, - background: { bg0: { $value: _surfaces.canvas, $type: "color" } }, - text: { t0: { $value: _surfaces.ink, $type: "color" } }, - neutral: Object.fromEntries( - (hfTokens.colors || []) - .slice(0, 8) - .map(_normalizeHex) - .filter(Boolean) - .map((c, i) => [`n${i}`, { $value: c, $type: "color" }]), - ), - }, - fontFamily: Object.fromEntries( - (hfTokens.fonts || []).map((f, i) => [`f${i}`, { $value: f.family, $type: "fontFamily" }]), - ), - shadow: Object.fromEntries( - (hfDesignStyles.shadows || []) - .filter((s) => s.value && s.value !== "none") - .map((s, i) => [`sh${i}`, { $value: s.value, $type: "shadow" }]), - ), - radius: Object.fromEntries( - (hfDesignStyles.radius || []).map((r, i) => [`r${i}`, { $value: r, $type: "dimension" }]), - ), - border: Object.fromEntries( - _borderStrings.map((s, i) => [`b${i}`, { $value: s, $type: "border" }]), - ), - }, -}; - -const motion = { - duration: {}, - easing: Object.fromEntries( - Object.entries(_easings).map(([k, v]) => [k, { $value: v, $type: "easing" }]), - ), -}; - -const dna = { - materialLanguage: { - label: _materialLabel, - confidence: 0.5, - signals: [], - metrics: { - saturation: - (hfTokens.colors || []) - .slice(0, 5) - .map(_normalizeHex) - .filter(Boolean) - .map(_sat) - .reduce((a, b) => a + b, 0) / Math.max(1, Math.min(5, (hfTokens.colors || []).length)), - shadowProfile: (hfDesignStyles.shadows || []).length === 0 ? "none" : "soft", - hasPill: (hfDesignStyles.radius || []).some((r) => /^(50%|9999px)$/.test(String(r))), - gradientCount: Object.values(hfTokens.cssVariables || {}).filter((v) => - /gradient/i.test(String(v)), - ).length, - }, - }, - imageryStyle: { label: _imageryLabel, confidence: 0.5 }, - backgroundPatterns: { labels: [], counts: {} }, - tagline: hfTokens.description || "", - description: hfTokens.description || "", -}; - -const voice = _voiceDerived; - -const intent = { - pageIntent: { type: _pageIntent, confidence: 0.5 }, - sectionRoles: { - counts: (() => { - const counts = {}; - for (const s of hfTokens.sections || []) { - counts[s.type] = (counts[s.type] || 0) + 1; - } - return counts; - })(), - }, -}; - -// brand.html → empty. The 5-slot regex-scan finds no matches; tertiary / -// costume fall through to the algorithmic saturation pick — same degradation -// path designlang relies on when sites don't ship a brand book. -const brandHtml = ""; - -// ═══════════════════ Extract brand DNA ═══════════════════ -// designlang token schema (verified against figma.com output): -// $metadata.{source, generator, generatedAt, version} -// primitive.color.{brand|neutral|background|text}.<key>.$value = "#hex" -// primitive.fontFamily.f0.$value = "..." -// primitive.shadow.shN.$value = "rgba(...) Npx Npx Npx Npx" -// primitive.radius.rN.$value = "Npx" -// semantic.color.{action,surface,text}.<key>.$value (may be a {color} ref) - -function valueOf(node) { - if (!node) return null; - if (typeof node === "string") return node; - if (node.$value !== undefined) return node.$value; - if (node.value !== undefined) return node.value; - return null; -} -const meta = tokens.$metadata || {}; -// sourceUrl already declared by the hyperframes-capture translator block above. - -const prim = tokens.primitive || {}; -const primColors = prim.color || {}; -const fontFamilies = Object.values(prim.fontFamily || {}) - .map(valueOf) - .filter(Boolean); - -// Pull all color hexes (across brand/neutral/background/text) into a flat list -function gatherColors(node) { - const out = []; - for (const v of Object.values(node || {})) { - const val = valueOf(v); - if (typeof val === "string" && /^#[0-9a-f]{3,6}$/i.test(val)) out.push(val); - else if (typeof v === "object" && v !== null && !v.$value) { - out.push(...gatherColors(v)); - } - } - return out; -} -const allColors = gatherColors(primColors); - -function isLight(hex) { - const m = String(hex).match(/^#?([0-9a-f]{6})$/i); - if (!m) return true; - const [r, g, b] = [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16)); - return 0.299 * r + 0.587 * g + 0.114 * b > 160; -} - -// HSV saturation of a hex color, 0..1. -function saturation(hex) { - const m = String(hex).match(/^#?([0-9a-f]{6})$/i); - if (!m) return 0; - const [r, g, b] = [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16) / 255); - const max = Math.max(r, g, b), - min = Math.min(r, g, b); - return max === 0 ? 0 : (max - min) / max; -} - -// Brand triplet source priority: -// 1. brand.html (designlang's canonical classification — most authoritative) -// 2. tokens primitive.color.brand.{primary,secondary} (no accent here) -// 3. saturation-based heuristic over allColors (last resort) -function brandFromBrandHtml(html) { - if (!html) return null; - const get = (cls) => { - // <article class="brand-color brand-color-<role>"> ... <span class="big-swatch-hex">#XXXXXX</span> - const re = new RegExp( - `brand-color-${cls}[\\s\\S]*?big-swatch-hex"[^>]*>\\s*(#[0-9a-fA-F]{3,6})`, - ); - const m = html.match(re); - return m ? m[1].toLowerCase() : null; - }; - const primary = get("primary"); - const secondary = get("secondary"); - const accent = get("accent"); - // tertiary + costume support designhtml-class presets (5-slot alias system). - // Brands rarely declare them in brand.html — when absent, fallbacks below kick in. - const tertiary = get("tertiary"); - const costume = get("costume"); - if (!primary && !secondary && !accent && !tertiary && !costume) return null; - return { primary, secondary, accent, tertiary, costume }; -} -const brandFromHtml = brandFromBrandHtml(brandHtml); - -let primaryHex = - brandFromHtml?.primary || valueOf(primColors.brand?.primary) || allColors[0] || "#000000"; -let secondaryHex = - brandFromHtml?.secondary || - valueOf(primColors.brand?.secondary) || - allColors.find((c) => c !== primaryHex) || - primaryHex; - -let accentHex = - brandFromHtml?.accent || - (() => { - // Fallback algorithm: highest-saturation color distinct from primary/secondary - const taken = new Set([primaryHex.toLowerCase(), secondaryHex.toLowerCase()]); - const candidates = allColors - .filter((c) => !taken.has(c.toLowerCase())) - .map((c) => ({ c, s: saturation(c) })) - .filter((o) => o.s > 0.5) - .sort((a, b) => b.s - a.s); - return candidates[0]?.c || secondaryHex; - })(); - -// Background and text: prefer first entry of those buckets if present -const bgList = gatherColors(primColors.background || {}); -const txList = gatherColors(primColors.text || {}); -let canvasHex = bgList[0] || (isLight(primaryHex) ? "#ffffff" : "#0f0f0f"); -let inkHex = txList[0] || (isLight(canvasHex) ? "#111111" : "#ffffff"); - -// ─── 5-slot alias system (tertiary + costume) ──────────────────── -// designhtml-class presets (peoples-platform, etc.) reference 5 brand alias -// slots in their §B: primary / secondary / tertiary / accent / costume. -// 3-slot presets (the existing 22) don't reference tertiary/costume — they -// inherit the defaults below as no-ops. -// -// tertiary: second authority surface / bridge stratum hue. -// 1. brand.html declares brand-color-tertiary → take it -// 2. saturation-pick from full palette → highest-sat non-overlap -// 3. fallback to accentHex → degrades to accent-as-surface -let tertiaryHex = - brandFromHtml?.tertiary || - (() => { - const taken = new Set( - [primaryHex, secondaryHex, accentHex, canvasHex, inkHex].map((c) => c.toLowerCase()), - ); - const candidates = allColors - .filter((c) => !taken.has(c.toLowerCase())) - .map((c) => ({ c, s: saturation(c) })) - .filter((o) => o.s > 0.3) - .sort((a, b) => b.s - a.s); - return candidates[0]?.c || accentHex; - })(); - -// costume: second light surface (cream frames, raised window faces, soft chrome). -// 1. brand.html declares brand-color-costume → take it -// 2. canvasHex → degrades to canvas-equals-costume -// (preset §B can still synthesize a warm tint via color-mix in CSS) -let costumeHex = brandFromHtml?.costume || canvasHex; - -// ─── Decoration colors (used by brutalism + maximalist presets) ────── -// Auto-pick 4 vibrant colors from the site's full palette, with hue diversity. -// Fall back to brutalism canonical 4-color set if site is too monochrome. -function hue(hex) { - const m = String(hex).match(/^#?([0-9a-f]{6})$/i); - if (!m) return 0; - const [r, g, b] = [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16) / 255); - const max = Math.max(r, g, b), - min = Math.min(r, g, b), - d = max - min; - if (d === 0) return 0; - let h; - if (max === r) h = ((g - b) / d) % 6; - else if (max === g) h = (b - r) / d + 2; - else h = (r - g) / d + 4; - h *= 60; - return h < 0 ? h + 360 : h; -} -const decoColors = (() => { - const FALLBACK = ["#F7CB46", "#99E885", "#C0F7FE", "#FE90E8"]; // yellow / green / blue / pink - const taken = new Set( - [primaryHex, secondaryHex, accentHex, canvasHex, inkHex].map((c) => c.toLowerCase()), - ); - const candidates = allColors - .filter((c) => !taken.has(c.toLowerCase())) - .map((c) => ({ c, s: saturation(c), h: hue(c) })) - .filter((o) => o.s > 0.4) - .sort((a, b) => b.s - a.s); - // Hue-diverse pick: bucket by 90deg quadrants (warm / lime / cool / magenta). - const buckets = [[], [], [], []]; - for (const { c, h } of candidates) { - const bucket = Math.floor((h % 360) / 90); - if (buckets[bucket].length < 1) buckets[bucket].push(c); - } - const picked = buckets.flat(); - while (picked.length < 4) picked.push(FALLBACK[picked.length]); - return picked.slice(0, 4); -})(); - -const brandName = (() => { - const host = sourceUrl - .replace(/^https?:\/\//, "") - .replace(/\/.*/, "") - .replace(/^www\./, ""); - return host ? host.split(".")[0].replace(/\b\w/g, (c) => c.toUpperCase()) : prefix; -})(); -const description = dna?.description || meta.description || ""; -const materialLabel = dna?.materialLanguage?.label || "unknown"; -// pageIntent lives on `intent.pageIntent.type` (intent.json shape), not on -// `dna` — the original designlang split kept them in separate files. Our -// translator mirrors that split. -const intentLabel = intent?.pageIntent?.type || dna?.pageIntent?.type || "unknown"; - -// Signature gradient: synthesize a 3-color linear gradient from the brand -// triplet. Conic / radial site gradients are too busy for video bg, so we -// compose a fresh linear one here for downstream use. -let signatureGradient = `linear-gradient(135deg, ${primaryHex} 0%, ${secondaryHex} 50%, ${accentHex} 100%)`; - -// Voice signals (used both in §5 and for auto-inference) -const voiceTone = voice?.tone || ""; -const voiceHeading = voice?.headingStyle || ""; -const voiceCtaVerbs = (voice?.ctaVerbs || []).map((v) => v.value || v).slice(0, 8); -const sampleHeadings = (voice?.sampleHeadings || []).slice(0, 6); - -// ═══════════════════ Load all presets ════════════════════ -// Each preset is a directory under style-presets/: -// style-presets/<name>/ -// ├── preset.md ← preset-meta + §A/§B/§D/§E/§G/§H/§I -// └── components/<id>.md ← one paste-ready component per file (raw body, no markers) -// The parser synthesizes §F by concatenating every components/*.md file in alphabetical -// order and wrapping each in <!-- COMPONENT: <id> --> markers, so downstream code -// (design.html render, emit-chunks anchor scan) sees the same structure as before. -const presets = (() => { - if (!fs.existsSync(PRESETS_DIR)) { - console.error(`✗ No style-presets/ directory at ${PRESETS_DIR}`); - process.exit(1); - } - return fs - .readdirSync(PRESETS_DIR, { withFileTypes: true }) - .filter((e) => e.isDirectory()) - .map((e) => parsePreset(path.join(PRESETS_DIR, e.name))); -})(); - -if (presets.length === 0) { - console.error( - `✗ No presets found in ${PRESETS_DIR}. Each preset must be a directory containing preset.md + components/<id>.md files.`, - ); - process.exit(1); -} - -// ═══════════════════ Component frontmatter parser ════════ -// Minimal YAML subset: top-level `key: value` lines only. Supports: -// - bare strings: surface: paper -// - comma-separated lists: composes: triple-stamp, grain-tooth -// - bracketed lists: slots: [headline, sub] -// - integers: weight: 700 -// - null / ~ / empty: optional_field: ~ -// Quoted strings have surrounding quotes stripped. Nesting / multiline / refs -// are not supported — keep frontmatter flat. -function parseComponentFrontmatter(text) { - const out = {}; - for (const line of text.split(/\r?\n/)) { - if (!line.trim() || /^\s*#/.test(line)) continue; - const m = line.match(/^\s*([a-z_][a-z0-9_]*)\s*:\s*(.*)$/i); - if (!m) continue; - const [, key, rawVal] = m; - const val = rawVal.trim(); - if (val === "" || val === "~" || val === "null") { - out[key] = null; - } else if (/^\[.*\]$/.test(val)) { - out[key] = val - .slice(1, -1) - .split(",") - .map((s) => s.trim().replace(/^["']|["']$/g, "")) - .filter(Boolean); - } else if (val.includes(",")) { - out[key] = val - .split(",") - .map((s) => s.trim().replace(/^["']|["']$/g, "")) - .filter(Boolean); - } else if (/^-?\d+$/.test(val)) { - out[key] = parseInt(val, 10); - } else if (val === "true" || val === "false") { - out[key] = val === "true"; - } else { - out[key] = val.replace(/^["']|["']$/g, ""); - } - } - return out; -} - -// ═══════════════════ Preset parser ═══════════════════════ -function parsePreset(presetDir) { - const presetMd = path.join(presetDir, "preset.md"); - if (!fs.existsSync(presetMd)) { - console.error(`✗ ${presetDir} missing preset.md`); - process.exit(1); - } - const raw = fs.readFileSync(presetMd, "utf8"); - // Frontmatter is a fenced ```preset-meta { JSON } ``` block at top of file. - const fmMatch = raw.match(/```preset-meta\n([\s\S]+?)\n```\n([\s\S]*)$/); - if (!fmMatch) { - console.error(`✗ ${presetMd} missing \`\`\`preset-meta block`); - process.exit(1); - } - let meta; - try { - meta = JSON.parse(fmMatch[1]); - } catch (e) { - console.error(`✗ ${presetMd} preset-meta is invalid JSON: ${e.message}`); - process.exit(1); - } - const body = fmMatch[2]; - - // Parse §A-§I sections by heading (preset.md never contains §F; we synthesize it below). - const sections = {}; - const headingRe = /^##\s+§([A-Z])\s+(.+?)$/gm; - const positions = []; - let m; - while ((m = headingRe.exec(body))) { - positions.push({ key: m[1], title: m[2], start: m.index, headerLen: m[0].length }); - } - for (let i = 0; i < positions.length; i++) { - const cur = positions[i]; - const next = positions[i + 1]; - const content = body.slice(cur.start + cur.headerLen, next ? next.start : body.length).trim(); - sections[cur.key] = { title: cur.title, content }; - } - if (sections.F) { - console.error( - `✗ ${presetMd} declares §F inline — §F is now sourced from ${presetDir}/components/<id>.md files. Move components out and remove the §F heading.`, - ); - process.exit(1); - } - - // Components: one file per id, filename (sans .md) IS the id. Body is raw HTML. - // Order is alphabetical by filename so design.html and chunks are deterministic. - const componentsDir = path.join(presetDir, "components"); - const components = []; - if (fs.existsSync(componentsDir)) { - const files = fs - .readdirSync(componentsDir) - .filter((f) => f.endsWith(".md")) - .sort(); - for (const f of files) { - const id = f.replace(/\.md$/, ""); - if (!/^[a-z0-9-]+$/.test(id)) { - console.error(`✗ ${componentsDir}/${f}: component id must match [a-z0-9-]+`); - process.exit(1); - } - const raw = fs.readFileSync(path.join(componentsDir, f), "utf8"); - // Optional YAML-subset frontmatter at top of file: - // --- - // surface: paper - // composes: triple-stamp, grain-tooth - // role: statement - // avoids_same_scene: framed-stamp, end-stamp - // slots: [headline, sub] - // --- - // designhtml-class presets (peoples-platform, …) use this to surface - // surface / composition / pairing metadata to the plan agent via - // chunks/index.json. Existing components without frontmatter still work — - // the meta field is null and downstream omits it. - let compMeta = null; - let bodyText = raw; - const fmMatch = raw.match(/^---\r?\n([\s\S]+?)\r?\n---\r?\n([\s\S]*)$/); - if (fmMatch) { - compMeta = parseComponentFrontmatter(fmMatch[1]); - bodyText = fmMatch[2]; - } - const block = bodyText.trim(); - if (!block) { - console.error(`✗ ${componentsDir}/${f}: empty component file`); - process.exit(1); - } - components.push({ name: id, block, meta: compMeta }); - } - } - if (components.length === 0) { - console.error(`✗ ${presetDir} has no components — add at least one components/<id>.md file`); - process.exit(1); - } - // Synthesize §F so downstream rendering code (renderComponents, emit-chunks anchor scan) - // sees the same shape as the legacy single-file format. When a component has - // frontmatter, we emit one extra HTML comment line right after the COMPONENT - // marker — emit-chunks.mjs greps that line out and writes the fields into - // chunks/index.json.components[]. Comments without frontmatter skip the line - // (zero impact on existing 22 presets). - sections.F = { - title: "Components (paste-ready, use brand vars from §B)", - content: components - .map((c) => { - const metaLine = c.meta ? `<!-- COMPONENT-META: ${JSON.stringify(c.meta)} -->\n\n` : ""; - return `<!-- COMPONENT: ${c.name} -->\n\n${metaLine}${c.block}\n\n<!-- /COMPONENT -->`; - }) - .join("\n\n"), - }; - - return { - name: meta.name, - // Preset-owned palette (R2). Slots: canvas/surface/ink/primary/accent/secondary, - // each { value, constraint?, lock?: "anchor", alias?: "<slot>" }. Consumed as a - // fallback when the capture yielded no colors — see the pickPreset() callsite. - palette: meta.palette || null, - label: meta.label || meta.name, - fingerprint: meta.fingerprint || {}, - matchSignals: meta.match_signals || [], - // Semantic hints for the LLM-review pass. Empty arrays = preset hasn't - // declared them yet (parser-tolerant; build still works). - bestFor: Array.isArray(meta.best_for) ? meta.best_for : [], - avoidFor: Array.isArray(meta.avoid_for) ? meta.avoid_for : [], - // Hard runtime / environment requirements. Each entry is checked at - // pickPreset() time; any missing requirement zeroes the preset's combined - // score and surfaces a capabilities_missing[] list in inference.json so - // the subagent can auto-install or report. See checkCapabilities() below. - requiresCapabilities: Array.isArray(meta.requires_capabilities) - ? meta.requires_capabilities - : [], - // Optional preset-native chrome fonts. When declared, build-design.mjs - // injects an extra Google Fonts <link> and renderComponents() switches - // §6 to dual-column (brand-applied | preset-native). See §I CSS for - // .preset-native-scope and chromeFonts schema in preset-meta JSON. - chromeFonts: meta.chromeFonts || null, - sections, - components, - rawBody: body, - }; -} - -// ═══════════════════ Capability detection ═════════════════ -// A capability requirement is satisfied when its kind-specific check passes: -// block_installed: BOTH `verify_file` AND `verify_lib` exist on disk. -// `verify_file` alone is not enough — `hyperframes add` writes -// the block HTML but the lib/*.iife.js is shipped separately -// by some blocks, so we check both. Missing → subagent should -// run `auto_install` command (if non-null) to materialise it. -// env_var_set: process.env[var] is set + non-empty. -// Paths in verify_* are resolved relative to process.cwd() — that's the project -// root for the pipeline. build-design.mjs is invoked from project root, not -// from the design-system/ dir, so relative paths Just Work. -function checkCapabilities(preset) { - const missing = []; - for (const req of preset.requiresCapabilities) { - if (req.kind === "block_installed") { - const fileOk = req.verify_file ? fs.existsSync(path.resolve(req.verify_file)) : true; - const libOk = req.verify_lib ? fs.existsSync(path.resolve(req.verify_lib)) : true; - if (!fileOk || !libOk) { - missing.push({ - kind: req.kind, - block: req.block, - missing_files: [!fileOk ? req.verify_file : null, !libOk ? req.verify_lib : null].filter( - Boolean, - ), - auto_install: req.auto_install || null, - alternates: req.alternates || [], - }); - } - } else if (req.kind === "env_var_set") { - const val = process.env[req.var]; - if (!val || !val.trim()) { - missing.push({ - kind: req.kind, - var: req.var, - reason: req.reason || null, - auto_install: req.auto_install || null, - }); - } - } else { - // Unknown capability kind — surface but don't gate (forward-compatible). - missing.push({ kind: req.kind, unknown: true, raw: req }); - } - } - return missing; -} - -// ═══════════════════ Style auto-inference ════════════════ -// Score each preset's match_signals against detected site features. -// Each signal contributes 0..1 * its weight when matched. -function detectSiteFeatures() { - // Collect raw CSS-like strings to grep against - const shadowStrings = Object.values(prim.shadow || {}) - .map(valueOf) - .filter(Boolean); - const borderStrings = Object.values(prim.border || {}) - .map(valueOf) - .filter(Boolean); - const easingStrings = Object.values(motion.easing || {}) - .map(valueOf) - .filter(Boolean); - - // Detection helpers - const accent = accentHex; - const sat = (hex) => { - const m = String(hex).match(/^#?([0-9a-f]{6})$/i); - if (!m) return 0; - const [r, g, b] = [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16) / 255); - const max = Math.max(r, g, b), - min = Math.min(r, g, b); - if (max === 0) return 0; - return (max - min) / max; - }; - - return { - // Brutalism signature: NON-inset offset shadow whose 3rd value (blur) is 0 - // AND at least one of X/Y offset is ≥3px. We must parse all length tokens - // from each comma-segment, not regex-grep — figma.com's "0px 24px 70px 0px" - // (4-value form: X Y blur spread) fooled greedy regexes. - shadow_zero_blur: shadowStrings.some((s) => { - // Split multi-layer shadows (commas separate layers, but commas also exist in rgb()) - const segments = s.split(/,(?![^()]*\))/); - return segments.some((seg) => { - if (/inset/.test(seg)) return false; - // Pull each "<num>px" token in order — they are X Y blur spread. - const lens = [...seg.matchAll(/(-?\d+)px/g)].map((m) => parseInt(m[1])); - if (lens.length < 3) return false; - const [x, y, blur] = lens; - if (blur !== 0) return false; - return Math.abs(x) >= 3 || Math.abs(y) >= 3; - }); - }), - thick_solid_border: borderStrings.some((s) => /^\s*[3-9]px\s+solid/.test(s)), - medium_solid_border: borderStrings.some((s) => /^\s*2px\s+solid/.test(s)), - hairline_border: borderStrings.some((s) => /^\s*1px\s+solid/.test(s)), - condensed_display: /Anton|Archivo Black|Bebas|Oswald|Space Grotesk/i.test( - fontFamilies.join(" "), - ), - serif_display: /Serif|Fraunces|Spectral|Newsreader|Playfair|Garamond|Times/i.test( - fontFamilies.join(" "), - ), - // Brutalism uses high-saturation accent AND high-sat primary together. - // Figma's #e4ff97 + #00b6ff are both high-sat but the overall page is calm. - // So require both colors to be saturated AND warm/clashing for true brutalism. - high_sat_accent: sat(accent) > 0.7 && sat(primaryHex) > 0.7, - low_saturation: sat(accent) < 0.5 && sat(primaryHex) < 0.5, - rotated_transform: false, // hard to detect from tokens alone - bouncy_easing: easingStrings.some((s) => /back|elastic|bounce/i.test(s)), - generous_padding: false, // no padding scale exposed in tokens - minimal_decoration: Object.keys(prim.shadow || {}).length < 2, - }; -} - -function scorePreset(preset, features) { - let total = 0, - maxTotal = 0; - for (const sig of preset.matchSignals) { - const weight = sig.weight || 0; - maxTotal += weight; - if (features[sig.kind]) total += weight; - } - return { raw: total, normalized: maxTotal ? total / maxTotal : 0 }; -} - -// Hybrid score: 0.7 * normalized + 0.3 * raw. -// Pure-raw sort penalises presets that declare few signals (e.g. scatterbrain -// at total weight 0.45 can never beat editorial at 1.00 even with perfect -// hits). Pure-normalised lets a 1-signal preset edge out a 5-signal one by -// matching its only signal. Hybrid: normalised dominates (precision), raw -// breaks ties toward "broadly-aware" presets. -function combinedScore(s) { - return 0.7 * s.normalized + 0.3 * s.raw; -} - -function pickPreset() { - // Always compute features so inference.json can report them, even on --style. - const features = detectSiteFeatures(); - // Presets with no match_signals fully opt out of auto-inference (no preset - // currently does this — both prior opt-outs, 8-bit-orbit and liquid-glass, - // now declare match_signals and gate via requires_capabilities instead). - const inferablePresets = presets.filter((p) => p.matchSignals.length > 0); - const scores = inferablePresets.map((p) => { - const s = scorePreset(p, features); - const matched = p.matchSignals.filter((sig) => features[sig.kind]).map((sig) => sig.kind); - const capabilitiesMissing = checkCapabilities(p); - // Hard rule: any unmet capability zeroes the auto-pick score. The preset - // still appears in inference.json so the subagent / user can see what would - // need to be installed to enable it (and auto_install command is surfaced). - const baseCombined = combinedScore(s); - const combined = capabilitiesMissing.length > 0 ? 0 : baseCombined; - return { - name: p.name, - raw: s.raw, - normalized: s.normalized, - combined, - combined_pre_capability: baseCombined, - matched, - capabilities_missing: capabilitiesMissing, - }; - }); - scores.sort((a, b) => b.combined - a.combined); - - if (cliStyle) { - const forced = presets.find((p) => p.name === cliStyle); - if (!forced) { - console.error( - `✗ unknown --style '${cliStyle}'. available: ${presets.map((p) => p.name).join(", ")}`, - ); - process.exit(1); - } - // --style is a deliberate override — the subagent is responsible for having - // already satisfied the capabilities (e.g. installed the block). We don't - // re-gate here, but we do surface what's still missing so the agent can act. - return { preset: forced, mode: "forced", scores, features }; - } - if (inferablePresets.length === 0) { - console.error(`✗ No presets are eligible for auto-inference. Use --style <name>.`); - process.exit(1); - } - const winner = presets.find((p) => p.name === scores[0].name); - return { preset: winner, mode: "inferred", scores, features }; -} - -const { preset, mode, scores, features } = pickPreset(); - -// ═══════════════════ R2: preset-owned palette fallback ═══ -// When the capture yielded NO usable colors (text-only / no-URL path, or a -// fully monochrome site), fall back to the chosen preset's declared palette -// instead of collapsing onto #000000 / palette[0] (which made ink == canvas). -// Extraction, when present, already populated the *Hex vars above — this only -// fires when nothing was scraped, so a real URL capture stays byte-for-byte -// unchanged (no golden-baseline drift). Slots resolve `alias` recursively; -// `lock: "anchor"` values are emitted as-is (§B color-mix() does the tinting). -// Trigger on the RAW captured palette (`hfTokens.colors`), NOT `allColors` — -// the latter synthesizes black/white defaults from empty input, so it's never -// 0. Empty `hfTokens.colors` is the true "nothing scraped" signal; a real URL -// capture always populates it. -if ((hfTokens.colors || []).length === 0 && preset.palette) { - const _pp = preset.palette; - const _ppGet = (slot, seen = new Set()) => { - const e = _pp[slot]; - if (!e || seen.has(slot)) return null; - seen.add(slot); - if (e.alias) return _ppGet(e.alias, seen); - return e.value ? _normalizeHex(e.value) : null; - }; - primaryHex = _ppGet("primary") || primaryHex; - secondaryHex = _ppGet("secondary") || secondaryHex; - accentHex = _ppGet("accent") || accentHex; - canvasHex = _ppGet("canvas") || canvasHex; - inkHex = _ppGet("ink") || inkHex; - tertiaryHex = _ppGet("tertiary") || tertiaryHex; - costumeHex = _ppGet("costume") || _ppGet("surface") || costumeHex; - signatureGradient = `linear-gradient(135deg, ${primaryHex} 0%, ${secondaryHex} 50%, ${accentHex} 100%)`; -} - -// ═══════════════════ Font resolution (Google Fonts) ══════ -const GFONTS = new Set( - [ - "Inter", - "Roboto", - "Open Sans", - "Lato", - "Manrope", - "Montserrat", - "Poppins", - "Work Sans", - "Plus Jakarta Sans", - "Outfit", - "DM Sans", - "IBM Plex Sans", - "Karla", - "Mulish", - "Rubik", - "Urbanist", - "Figtree", - "Space Grotesk", - "Source Sans 3", - "Public Sans", - "Albert Sans", - "Geist", - "Heebo", - "Barlow", - "Hind", - "Nunito", - "Nunito Sans", - "Raleway", - "Cabin", - "Onest", - "Instrument Serif", - "Playfair Display", - "Merriweather", - "Lora", - "EB Garamond", - "Fraunces", - "Newsreader", - "Source Serif 4", - "PT Serif", - "Spectral", - "Crimson Text", - "JetBrains Mono", - "Fira Code", - "IBM Plex Mono", - "Roboto Mono", - "Source Code Pro", - "Space Mono", - "Geist Mono", - "DM Mono", - "Inconsolata", - "Anton", - "Archivo Black", - "Bebas Neue", - "Alfa Slab One", - "Archivo Narrow", - "DM Mono", - "Caveat Brush", - "Caveat", - "Pacifico", - "Kalam", - "Sacramento", - "Permanent Marker", - "Allura", - "Cookie", - "Satisfy", - "Marck Script", - ].map((s) => s.toLowerCase().replace(/[^a-z0-9]/g, "")), -); - -// Final-fallback families if a preset's §D can't be parsed or names nothing on -// Google Fonts. Used when both site DNA and the preset's own §D fall through. -const FINAL_FONT_FALLBACK = { - display: "Instrument Serif", - body: "Inter", - mono: "JetBrains Mono", -}; - -// ═══════════════════ Local font discovery ════════════════ -// Phase 1 (web-research) downloads site-served woff/woff2/otf/ttf into -// research/assets/. If a site font is not on Google Fonts but a matching -// binary exists locally, we self-host it via @font-face so the real brand -// face renders instead of falling back to the preset's §D suggestion. -// -// Heuristic for matching a designlang family name to a downloaded file: -// - Normalize both sides (lowercase, strip non-alphanum) -// - File name's leading "<NNN>-" hash prefix and trailing weight/style -// suffixes (-Regular, -Bold, -Italic, -700, etc.) are stripped before -// compare. A trailing "-<hex8>" content hash from capture_web_context.py -// is also stripped. -const FONT_FILE_EXTS = new Set([".woff2", ".woff", ".otf", ".ttf"]); -function normalizeFamily(s) { - return String(s || "") - .toLowerCase() - .replace(/[^a-z0-9]/g, ""); -} -// designlang sometimes emits a family name that has been mashed together -// without separators (e.g. "GeistVF" → "Geistvf", "DM Serif Display" → -// "Dmserifdisplay"). The local-font filename usually does NOT have those -// axis-suffix letters. Strip a small set of trailing axis tags from a -// normalized key so getComputedStyle('Geistvf') still matches a downloaded -// "Geist-Regular.woff2". -function normalizeFamilyLoose(s) { - return normalizeFamily(s) - .replace(/(variablefont|variable|vf)$/i, "") - .replace(/(wght|opsz|slnt|ital)$/i, ""); -} -function splitCamelToWords(s) { - // "DMSerifDisplay" → "DM Serif Display"; "GeistVF" → "Geist VF"; "Poppins" → "Poppins"; - // "tt_norms_pro_mono" → "TT Norms Pro Mono"; "tt_norms_pro" → "TT Norms Pro". - // - // Rules in order: - // 1. Insert a space between a lower→upper transition ("Geist|VF") - // 2. Insert a space between two uppers followed by a lower ("DM|Serif") - // 3. Underscores become spaces (handles "tt_norms_pro" naming common to - // bundled woff2 from CMS / foundry distribution packages) - // 4. Collapse whitespace + trim - // 5. Title-case each word; words ≤2 chars treated as acronyms (uppercase) - // - // Step 5 matters for role-hint matching downstream: /\bmono\b/i needs a - // word boundary around "mono", which underscores would not provide. After - // the underscore→space + title-case normalization, "tt_norms_pro_mono" - // becomes "TT Norms Pro Mono" with proper \b on either side of "Mono". - return String(s || "") - .replace(/([a-z0-9])([A-Z])/g, "$1 $2") - .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") - .replace(/_+/g, " ") - .replace(/\s+/g, " ") - .trim() - .split(" ") - .map((w) => (w.length <= 2 ? w.toUpperCase() : w[0].toUpperCase() + w.slice(1))) - .join(" "); -} -function fileBaseToFamily(fileName) { - // Strip extension - let base = fileName.replace(/\.(woff2?|otf|ttf)$/i, ""); - // Strip leading "NNN-" capture index prefix (capture_web_context.py format) - base = base.replace(/^\d{3}-/, ""); - // Strip trailing "-<hex 8-10 chars>" content digest (capture_web_context.py - // adds this to dedupe asset URLs; not part of the family name). - base = base.replace(/-[0-9a-f]{8,10}$/i, ""); - // Webflow / CMS asset hosts prefix files with a "<24-hex-id>_" resource ID. - // Example: 657d6bc6bcb648163fa2c02b_Poppins-Regular.woff2. Pull anything - // up to and including the underscore so we end up with "Poppins-Regular". - base = base.replace(/^[0-9a-f]{16,32}_/i, ""); - // Same pattern can appear without trailing underscore but with a separating - // dash — e.g. "657d6bc6bcb648163fa2c02b-Poppins". Less common, but cheap to handle. - base = base.replace(/^[0-9a-f]{16,32}-/i, ""); - // Strip Google Fonts static-host variable-axis suffix conventions. - // Examples: "Oswald-VariableFont_wght", "Inter-VariableFont_opsz,wght". - base = base.replace(/[-_]VariableFont[_,a-zA-Z]*$/i, ""); - // Strip common font-weight/style suffixes. We loop because foundries chain - // multiple suffixes (e.g. "AnthropicSans-Roman-Web", "Inter-Bold-Italic") - // and a single pass would leave the inner one behind. - const SUFFIX_RES = [ - /[-_](Thin|ExtraLight|Light|Normal|Regular|Book|Roman|Upright|Medium|SemiBold|DemiBold|Bold|ExtraBold|Heavy|Black)$/i, - /[-_](Italic|Oblique)$/i, - // Web-host variant suffixes — foundries publish "Inter-Web" / "TT_Norms_Pro_Mono_Regular-webfont". - // The leading anchor [-_] matches either dash or underscore; the alternation - // covers both single-word and "webfont" composite forms. - /[-_](Web|Webfont|Desktop|Print|Display|Text)$/i, - /[-_](100|200|300|400|500|600|700|800|900)$/i, - /[-_]?VF$/i, - /[-_]?Variable$/i, - ]; - let prev; - do { - prev = base; - for (const re of SUFFIX_RES) base = base.replace(re, ""); - } while (base !== prev); - return base; -} -// Looks like a content-hash family name? Next.js (and other build systems) -// sometimes ship raw hashed identifiers as @font-face family — we want to -// keep them out of the discovery map so designlang's "flecha" lookup -// doesn't accidentally bind to "1eff5a6cf292d683". -function looksLikeContentHash(name) { - const s = String(name || "").trim(); - if (!s) return true; - // Strict: at least 8 consecutive hex characters anywhere in the name. - if (/[0-9a-f]{8,}/i.test(s)) return true; - // Whole string is dominated by hex / dot / dash separators. - if (/^[0-9a-f.\-_]+$/i.test(s) && /[0-9a-f]{6,}/i.test(s)) return true; - return false; -} -// Some build tools wrap the source family in markers like "__flecha_a1b2c3" -// (Next.js next/font) or "flecha Fallback" (Next.js size-adjust). Recover -// the source family name from these decorations so the lookup hits the -// intended brand identity. -function unwrapBundlerFamily(name) { - return String(name || "") - .replace(/^__/, "") - .replace(/\s+Fallback$/i, "") - .replace(/_Fallback_/i, "_") - .replace(/_[a-f0-9]{4,}$/i, "") - .trim(); -} -function discoverLocalFonts(fontsRootDir) { - // Returns Map<normalized-family, { family: <display-cased>, files: [abs-path...] }>. - // Each font is indexed under BOTH its strict normalized key and its loose - // key (axis suffixes stripped), so lookups by either form hit the same entry. - // - // Two discovery passes: - // 1. PRIMARY — read fonts-manifest.json (hyperframes capture's OpenType - // `name`-table extraction). Each record carries the authoritative - // `family` field read straight from the binary font file, which - // handles hashed bundler-emitted filenames (Next.js `_<hex>`). - // 2. FALLBACK — file-name based discovery for any font files that the - // manifest didn't cover. - const found = new Map(); - if (!fs.existsSync(fontsRootDir)) return found; - - function addEntry(familyDisplay, abs) { - const norm = normalizeFamily(familyDisplay); - if (!norm) return false; - if (!found.has(norm)) { - found.set(norm, { family: familyDisplay, files: [] }); - } - const entry = found.get(norm); - if (!entry.files.includes(abs)) entry.files.push(abs); - const looseNorm = normalizeFamilyLoose(familyDisplay); - if (looseNorm && looseNorm !== norm && !found.has(looseNorm)) { - found.set(looseNorm, entry); - } - return true; - } - - // 1. PRIMARY — fonts-manifest.json from hyperframes capture. The manifest - // sits alongside the capture's `extracted/` dir; if we received the - // direct fonts dir (e.g. <outDir>/fonts/) we look for a manifest next - // to it via ../extracted/fonts-manifest.json, otherwise we look in the - // fontsRootDir itself. - const claimed = new Set(); - const manifestCands = [ - path.join(fontsRootDir, "fonts-manifest.json"), - path.resolve(fontsRootDir, "..", "extracted", "fonts-manifest.json"), - ]; - for (const manifestPath of manifestCands) { - if (!fs.existsSync(manifestPath)) continue; - try { - const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); - const files = Array.isArray(manifest.files) ? manifest.files : []; - for (const rec of files) { - if (!rec || !rec.identified || !rec.family || !rec.file) continue; - const ext = path.extname(rec.file).toLowerCase(); - if (!FONT_FILE_EXTS.has(ext)) continue; - // Resolve against fontsRootDir (where the actual woff files live). - const abs = path.resolve(fontsRootDir, rec.file); - if (!fs.existsSync(abs)) continue; - const unwrapped = unwrapBundlerFamily(rec.family); - if (!unwrapped || looksLikeContentHash(unwrapped)) continue; - const familyDisplay = - unwrapped.length <= 4 - ? unwrapped.toUpperCase() - : unwrapped.replace(/\b([a-z])/g, (_, c) => c.toUpperCase()); - if (addEntry(familyDisplay, abs)) claimed.add(path.basename(abs)); - } - break; // first manifest wins - } catch { - // Silently fall through to file-name discovery on parse errors. - } - } - - // 2. FALLBACK — file-name based discovery for files we didn't already - // bind via the manifest. Reject hash-dominated names so Next.js-style - // files like "1eff5a6cf292d683-s.p" don't pollute the map. - for (const ent of fs.readdirSync(fontsRootDir, { withFileTypes: true })) { - if (!ent.isFile()) continue; - if (claimed.has(ent.name)) continue; - const ext = path.extname(ent.name).toLowerCase(); - if (!FONT_FILE_EXTS.has(ext)) continue; - const familyRaw = fileBaseToFamily(ent.name); - if (looksLikeContentHash(familyRaw)) continue; - const familyDisplay = splitCamelToWords(familyRaw); - if (looksLikeContentHash(familyDisplay)) continue; - const abs = path.join(fontsRootDir, ent.name); - addEntry(familyDisplay, abs); - } - return found; -} -// Two discovery roots, merged: -// 1. <captureDir>/assets/fonts/ — hyperframes capture's downloaded woff/otf -// files; the sibling extracted/fonts-manifest.json carries OpenType -// `name`-table family attribution. -// 2. <outDir>/fonts/ — user-supplied / hand-placed font files when capture -// misses a site's bundler-hashed font. Filename-discovery only. -// Merge order: capture wins on key collision; user drops fill in gaps. -const captureFontsDir = path.join(captureDir, "assets", "fonts"); -const userFontsDir = path.join(outDir, "fonts"); -const localFonts = (() => { - const primary = discoverLocalFonts(captureFontsDir); - if (!fs.existsSync(userFontsDir)) return primary; - const userDrops = discoverLocalFonts(userFontsDir); - for (const [key, entry] of userDrops) { - if (!primary.has(key)) primary.set(key, entry); - } - return primary; -})(); - -// Pick the first Google-Fonts-available name from a preset's §D bullet for one -// role. Bullet format (per README §D): `- **<role>**: \`'Name1'\` · \`'Name2'\` · ...` -function parsePresetFontFallback(presetSections) { - const dContent = presetSections.D?.content || ""; - // script is the 4th role (peoples-platform's Caveat Brush, etc.) — optional; - // a §D without a script bullet leaves it null and downstream skips emission. - const roles = { display: null, body: null, mono: null, script: null }; - for (const role of Object.keys(roles)) { - // Match the bullet line for this role; capture everything after the colon. - const lineRe = new RegExp(`^\\s*-\\s*\\*\\*${role}\\*\\*\\s*:\\s*(.+)$`, "mi"); - const lineMatch = dContent.match(lineRe); - if (!lineMatch) continue; - // Extract each backtick-wrapped name; quotes inside are optional. - const names = [...lineMatch[1].matchAll(/`['"]?([^'"`]+?)['"]?`/g)].map((m) => m[1].trim()); - // Pick the first name that's on Google Fonts. - const pick = names.find((n) => GFONTS.has(n.toLowerCase().replace(/[^a-z0-9]/g, ""))); - if (pick) roles[role] = pick; - } - return roles; -} - -function resolveFont(siteName, role, presetFontFallback) { - const norm = normalizeFamily(siteName); - const looseNorm = normalizeFamilyLoose(siteName); - // 1. Site family is a known Google Font → load from fonts.googleapis.com - if (norm && GFONTS.has(norm)) { - return { name: siteCanonicalName(siteName), source: "site" }; - } - // 2. Site family has a matching woff/woff2 in research/assets/ → self-host. - // Try strict normalized key first, then the loose key (which lets - // designlang's "Geistvf" match a downloaded "Geist-Regular.woff2"). The - // canonical display name comes from the discovered file's parsed family, - // not designlang's mashed string — so "Geistvf" renders as "Geist". - const localHit = (norm && localFonts.get(norm)) || (looseNorm && localFonts.get(looseNorm)); - if (localHit) { - return { - name: localHit.family, - source: "site-local", - localFiles: localHit.files, - }; - } - // 3. Role-hint fallback against the discovered local-font pool. - // - // Triggers when steps 1+2 fail. Two common cases this rescues: - // (a) site didn't name a family for this role at all (empty siteName) — - // e.g. Brex's "Space Mono" used only in tiny labels designlang - // didn't surface as the mono family; the local @font-face capture - // still has the file, role-hint matches it by name "Space Mono". - // (b) site DID name a family but it's an unresolvable bundler-hashed - // identifier (Next.js `flecha_<hex>`, Webpack content-hash names) — - // designlang reports siteName="flecha" which doesn't match any - // Google Font and doesn't match a local file by normalized key, - // but the underlying file (e.g. "ABCSolarDisplay-Bold.woff2") - // has a role-suggesting family name. Role-hint matches it. - // - // Previously this block was gated by `if (!siteName)` — only case (a) - // was covered, so HeyGen's `flecha` → display role kept falling - // through to preset fallback even when the real font was sitting in - // the fonts/ dir. The gate is removed. - const ROLE_HINTS = { - mono: /\b(mono|code)\b/i, - display: /\b(serif|display|headline)\b/i, - body: null, // body has no reliable name hint - script: /\b(caveat|brush|script|cursive|kalam|pacifico|sacramento)\b/i, - }; - const hint = ROLE_HINTS[role]; - if (hint) { - for (const entry of new Set(localFonts.values())) { - if (hint.test(entry.family)) { - return { - name: entry.family, - source: "site-local", - localFiles: entry.files, - roleHintMatch: true, - originalName: siteName || null, - }; - } - } - } - // 4. Preset §D bullet → first Google-Fonts-available name - // 5. Final hard-coded fallback - const fallback = presetFontFallback[role] || FINAL_FONT_FALLBACK[role]; - return { name: fallback, source: "preset", originalName: siteName }; -} -function siteCanonicalName(s) { - // Title-case each word, keep canonical names like "JetBrains Mono" intact - return String(s) - .split(/\s+/) - .map((w) => w[0].toUpperCase() + w.slice(1)) - .join(" "); -} - -// designlang sometimes ships only one family; split display/body/mono heuristically. -// Heuristic: first non-mono family is body, second/mono-named is the alternate. -// If the site has NO mono family, leave rawMono empty so resolveFont() routes -// to the preset fallback path (which honestly reports "preset fallback" in the -// type-roles card) instead of pretending "JetBrains Mono" came from the site. -const monoFromList = fontFamilies.find((f) => /mono|code/i.test(f)); -// Script families are rarely emitted by designlang as the primary site family; -// usually only show up when a brand uses a brush face for accent words. Match -// loosely against known display-script names so site-supplied "Caveat Brush" -// gets routed to the script role instead of stealing display. -const SCRIPT_FAMILY_RE = - /\b(caveat|brush|script|cursive|kalam|pacifico|sacramento|allura|cookie|satisfy|marck|permanent\s*marker)\b/i; -const scriptFromList = fontFamilies.find((f) => SCRIPT_FAMILY_RE.test(f)); -const nonMono = fontFamilies.filter((f) => !/mono|code/i.test(f) && !SCRIPT_FAMILY_RE.test(f)); -const rawDisplay = nonMono[1] || nonMono[0] || ""; -const rawBody = nonMono[0] || ""; -const rawMono = monoFromList || ""; -const rawScript = scriptFromList || ""; - -const presetFontFallback = parsePresetFontFallback(preset.sections); -const display = resolveFont(rawDisplay, "display", presetFontFallback); -const body = resolveFont(rawBody, "body", presetFontFallback); -const mono = resolveFont(rawMono, "mono", presetFontFallback); -// Script is only resolved when either the site shipped one OR the preset -// declared §D bullet. resolveFont returns a "preset" role when the bullet -// resolves; we treat a null-name result (no site name AND no preset bullet) -// as "this preset doesn't use a script role" and skip @font-face / :root emission. -const script = - rawScript || presetFontFallback.script - ? resolveFont(rawScript, "script", presetFontFallback) - : null; - -function gfontsUrl() { - // Only Google-Fonts and preset-fallback families go through fonts.googleapis. - // site-local families are loaded via the @font-face block built below. - const allRoles = script ? [display, body, mono, script] : [display, body, mono]; - const remoteRoles = allRoles.filter((r) => r.source !== "site-local"); - if (remoteRoles.length === 0) return null; - const fams = []; - const seen = new Set(); - for (const role of remoteRoles) { - const f = role.name; - if (seen.has(f)) continue; - seen.add(f); - const wts = - f === mono.name - ? "400;500" - : f === display.name - ? "400;700;800" - : script && f === script.name - ? "400" // script faces typically ship a single weight - : "400;500;600;700"; - fams.push(`family=${encodeURIComponent(f).replace(/%20/g, "+")}:wght@${wts}`); - } - return `https://fonts.googleapis.com/css2?${fams.join("&")}&display=swap`; -} - -// ═══════════════════ Self-hosted @font-face block ════════ -// For each site-local font role, copy its woff/woff2/otf/ttf binaries from -// research/assets/ into <outDir>/fonts/ (where prep.mjs Step 2b expects them), -// then emit one @font-face declaration per file. The block is wrapped in the -// exact comment anchors prep.mjs Step 2c greps for, so the path-rewrite + -// inject-into-index.html flow lights up without any changes downstream. -function buildLocalFontFaceBlock() { - const allRoles = script ? [display, body, mono, script] : [display, body, mono]; - const localRoles = allRoles.filter((r) => r.source === "site-local"); - if (localRoles.length === 0) return { block: "", copiedFiles: [] }; - const fontsDestDir = path.join(outDir, "fonts"); - fs.mkdirSync(fontsDestDir, { recursive: true }); - const copiedFiles = []; - const seenRoles = new Set(); - const faces = []; - for (const role of localRoles) { - // Deduplicate: if display + body resolve to the same site-local family, - // emit @font-face declarations once. - if (seenRoles.has(role.name)) continue; - seenRoles.add(role.name); - for (const src of role.localFiles) { - const fileName = path.basename(src); - const dest = path.join(fontsDestDir, fileName); - if (!fs.existsSync(dest)) { - fs.copyFileSync(src, dest); - } - copiedFiles.push(fileName); - const ext = path.extname(fileName).toLowerCase().slice(1); - const formatMap = { woff2: "woff2", woff: "woff", otf: "opentype", ttf: "truetype" }; - const format = formatMap[ext] || ext; - faces.push( - `@font-face {\n font-family: '${role.name}';\n src: url('fonts/${fileName}') format('${format}');\n font-display: swap;\n}`, - ); - } - } - // The wrapper anchors are NOT cosmetic — prep.mjs:159 greps for this exact - // comment pair to extract the block and inject it into index.html <head>. - // Do not rename without updating prep.mjs in lockstep. - const block = `/* === auto-injected by download-fonts.mjs === */\n${faces.join("\n")}\n/* === end download-fonts.mjs block === */`; - return { block, copiedFiles }; -} -const { block: localFontFaceBlock, copiedFiles: copiedFontFiles } = buildLocalFontFaceBlock(); - -// ═══════════════════ Voice transform demo ════════════════ -// Run the preset's voice recipe against a sample brand sentence so the human -// reader can see "Figma's voice rewritten through brutalism". This is rendered -// in §5 as IN→OUT pairs. The recipe text is left raw; we don't actually execute -// it (LLM does that in Phase 2). We just show 2 examples derived from voice.json -// content if available, else fall back to the canned example in the preset. -function pickVoiceSamples() { - const candidates = []; - if (description) candidates.push(description); - for (const h of sampleHeadings) if (h && h.length > 10) candidates.push(h); - return candidates.slice(0, 2); -} -const voiceSamples = pickVoiceSamples(); - -// ═══════════════════ HTML escape ═════════════════════════ -function esc(s) { - return String(s ?? "").replace( - /[&<>"']/g, - (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], - ); -} -function mdInlineToHtml(md) { - // Very small: code spans, **bold**, *italic*. Used only on preset prose. - return esc(md) - .replace(/`([^`]+)`/g, "<code>$1</code>") - .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>") - .replace(/\*([^*]+)\*/g, "<em>$1</em>"); -} - -// Block-level markdown for preset prose (§A intent, §G recipe, §H hints, …). -// Replaces the old `mdInlineToHtml(text).replace(/\n/g,'<br>')` pattern which -// collapsed bullet lists and tables into walls of text. Handles, in priority -// order per block (blocks split by blank line): -// 1. Heading: `## title` / `### title` / `#### title` → <h2>/<h3>/<h4> -// 2. Pipe-table with `|---|` separator row → <table class="ds-table"> -// 3. Bullet list (`- foo` / `* foo`) → <ul><li> -// 4. Numbered list (`1. foo`) → <ol><li> -// 5. Fenced code (` ```lang ... ``` `) → <pre><code> (esc'd) -// 6. Anything else → <p class="ds-prose"> with single \n → <br> -// Inline markdown (**bold**, *em*, `code`) still runs via mdInlineToHtml inside. -// Designed for short / well-formed preset prose, not arbitrary user markdown — -// nested lists, blockquotes, link syntax not supported. Add as needed. -function mdBlockToHtml(md) { - if (!md) return ""; - const blocks = md.replace(/\r\n/g, "\n").split(/\n\s*\n/); - const out = []; - for (const raw of blocks) { - const block = raw.replace(/\n+$/, "").replace(/^\n+/, ""); - if (!block.trim()) continue; - - // Heading - const headingMatch = block.match(/^(#{2,4})\s+(.+)$/); - if (headingMatch && !block.includes("\n")) { - const lvl = headingMatch[1].length; - out.push(`<h${lvl} class="ds-h${lvl}">${mdInlineToHtml(headingMatch[2])}</h${lvl}>`); - continue; - } - - // Pipe table — must have ≥2 lines and a separator row of pipes+dashes - if (/^\s*\|/m.test(block) && /\n\s*\|[\s\-:|]+\|/.test(block)) { - const lines = block.split("\n").filter((l) => /^\s*\|/.test(l)); - if (lines.length >= 2) { - const splitCells = (line) => - line - .trim() - .replace(/^\||\|$/g, "") - .split("|") - .map((c) => c.trim()); - const header = splitCells(lines[0]); - const rows = lines.slice(2).map(splitCells); - const thead = `<thead><tr>${header.map((c) => `<th>${mdInlineToHtml(c)}</th>`).join("")}</tr></thead>`; - const tbody = `<tbody>${rows - .map((row) => `<tr>${row.map((c) => `<td>${mdInlineToHtml(c)}</td>`).join("")}</tr>`) - .join("")}</tbody>`; - out.push(`<table class="ds-table">${thead}${tbody}</table>`); - continue; - } - } - - // Bullet list — every non-empty line in the block must start with - or * - if ( - /^\s*[-*]\s/.test(block) && - block.split("\n").every((l) => !l.trim() || /^\s*[-*]\s/.test(l)) - ) { - const items = block - .split(/\n(?=\s*[-*]\s)/) - .map((line) => - line - .replace(/^\s*[-*]\s+/, "") - .replace(/\n\s+/g, " ") - .trim(), - ) - .filter(Boolean) - .map((line) => `<li>${mdInlineToHtml(line)}</li>`); - out.push(`<ul class="ds-list">${items.join("")}</ul>`); - continue; - } - - // Numbered list — every non-empty line in the block must start with `\d+. ` - if ( - /^\s*\d+\.\s/.test(block) && - block.split("\n").every((l) => !l.trim() || /^\s*\d+\.\s/.test(l)) - ) { - const items = block - .split(/\n(?=\s*\d+\.\s)/) - .map((line) => - line - .replace(/^\s*\d+\.\s+/, "") - .replace(/\n\s+/g, " ") - .trim(), - ) - .filter(Boolean) - .map((line) => `<li>${mdInlineToHtml(line)}</li>`); - out.push(`<ol class="ds-list">${items.join("")}</ol>`); - continue; - } - - // Fenced code block - const codeMatch = block.match(/^```(\w*)\n([\s\S]+?)\n```$/); - if (codeMatch) { - out.push(`<pre class="ds-code"><code>${esc(codeMatch[2])}</code></pre>`); - continue; - } - - // Default paragraph — inline md + soft line breaks - out.push(`<p class="ds-prose">${mdInlineToHtml(block).replace(/\n/g, "<br>")}</p>`); - } - return out.join("\n"); -} - -// ═══════════════════ Render: §0 Title card ═══════════════ -function renderTitleCard() { - return ` -<section class="title-card"> - <div class="title-card-inner"> - <div class="brand-row"> - <span class="brand-name">${esc(brandName)}</span> - <span class="brand-x">×</span> - <span class="style-name">${esc(preset.label)}</span> - </div> - <h1 class="title-display">${esc(brandName)}</h1> - <p class="title-meta"> - <strong>Source</strong> ${esc(sourceUrl) || "—"} -  ·  - <strong>Material</strong> ${esc(materialLabel)} -  ·  - <strong>Intent</strong> ${esc(intentLabel)} -  ·  - <strong>Style</strong> ${esc(preset.label)} ${mode === "forced" ? "(forced)" : "(auto-inferred)"} - </p> - </div> -</section>`; -} - -// ═══════════════════ Render: §1 Brand DNA ════════════════ -function renderBrandDNA() { - const swatch = (label, hex, extraStyle = "") => ` - <div class="dna-swatch" style="background: ${hex}; color: ${isLight(hex) ? "#000" : "#fff"}; ${extraStyle}"> - <div class="dna-label">${label}</div> - <div class="dna-hex">${hex}</div> - </div>`; - return ` -<section id="brand-dna" class="ds-section"> - <div class="eyebrow">§1 · Brand DNA (from site)</div> - <h2>${esc(brandName)} in one glance</h2> - <div class="dna-grid dna-grid-5"> - ${swatch("Primary", primaryHex)} - ${swatch("Secondary", secondaryHex)} - ${swatch("Accent", accentHex)} - ${swatch("Canvas", canvasHex, "border: 1px solid #eee;")} - ${swatch("Ink", inkHex)} - </div> - - <div class="dna-gradient"> - <div class="dna-gradient-label">Signature gradient</div> - <div class="dna-gradient-bar" style="background: ${signatureGradient};"></div> - <code class="dna-gradient-code">${esc(signatureGradient)}</code> - </div> - - ${description ? `<p class="dna-desc">${esc(description)}</p>` : ""} -</section>`; -} - -// ═══════════════════ Render: §2 Color × Style ════════════ -// §2's :root token body, captured for the §C caption-skin preview iframe (a separate -// document that can't inherit design.html's live :root). Set by renderColorAndTokens(); -// read by renderCaptions() — the assembly calls captions after color, so it is populated. -let _captionTokensRoot = ""; - -function renderColorAndTokens() { - const decorationTokens = preset.sections.B?.content || ""; - // Extract CSS variable declarations. A declaration starts with a line whose - // first non-whitespace tokens are `--`, and continues across lines until - // every opening paren has been closed AND a `;` terminator is seen. - // - // This supports multi-line values like: - // - // --grain-image: radial-gradient( - // rgba(0,0,0,0.04) 1px, - // transparent 1px - // ); - // - // Stray non-declaration lines (`}`, comments, blanks, the opening `:root {` - // brace, fenced ```css markers) are skipped between declarations. - const tokenLines = []; - const rawLines = decorationTokens.split("\n"); - let i = 0; - while (i < rawLines.length) { - const line = rawLines[i]; - if (!/^\s*--/.test(line)) { - i++; - continue; - } - // Start of a declaration. Accumulate until paren depth is 0 and line ends with `;`. - const buffer = [line]; - let depth = (line.match(/\(/g) || []).length - (line.match(/\)/g) || []).length; - let terminated = depth === 0 && /;\s*(\/\*.*\*\/)?\s*$/.test(line); - while (!terminated && i + 1 < rawLines.length) { - i++; - const cont = rawLines[i]; - buffer.push(cont); - depth += (cont.match(/\(/g) || []).length - (cont.match(/\)/g) || []).length; - if (depth === 0 && /;\s*(\/\*.*\*\/)?\s*$/.test(cont)) { - terminated = true; - } - } - // Re-emit the declaration with the original indentation of subsequent lines - // (preserve formatting; the renderColorAndTokens output later re-indents the - // first line uniformly with " " — keep continuation lines untouched). - const first = buffer[0].trim(); - const rest = buffer.slice(1); - tokenLines.push(rest.length ? first + "\n" + rest.join("\n") : first); - i++; - } - // Font role tokens. The unquoted name lets components reference `var(--font-display)` - // and CSS will look up the @font-face declaration in <head>. The fallback chain - // covers (a) Google Fonts hadn't finished loading, (b) self-hosted woff failed. - // serif vs sans-serif vs monospace are chosen per role; the role's resolved - // name lives in `display.name` / `body.name` / `mono.name`. - const fontDisplayStack = `'${display.name}', ${preset.name === "neo-brutalism" || /Mono/i.test(display.name) ? "ui-monospace, monospace" : /Serif|Playfair|Lora|Garamond|Fraunces|Newsreader|Spectral|Times/i.test(display.name) ? "Georgia, serif" : "system-ui, sans-serif"}`; - const fontBodyStack = `'${body.name}', -apple-system, BlinkMacSystemFont, system-ui, sans-serif`; - const fontMonoStack = `'${mono.name}', ui-monospace, SFMono-Regular, Menlo, monospace`; - // Script stack only built when script role resolved (else fontScriptLine below skips emission). - const fontScriptStack = script ? `'${script.name}', cursive` : ""; - // Script font role is optional — only emitted when preset declares a §D script bullet - // OR when site exposes a script family. 3-role presets get a no-op blank line. - const fontScriptLine = script?.name ? ` --font-script: ${fontScriptStack};\n` : ""; - const rootBody = ` - --brand-primary: ${primaryHex}; - --brand-secondary: ${secondaryHex}; - --brand-tertiary: ${tertiaryHex}; - --brand-accent: ${accentHex}; - --brand-costume: ${costumeHex}; - --ink: ${inkHex}; - --canvas: ${canvasHex}; - --brand-gradient: ${signatureGradient}; - --deco-1: ${decoColors[0]}; - --deco-2: ${decoColors[1]}; - --deco-3: ${decoColors[2]}; - --deco-4: ${decoColors[3]}; - --font-display: ${fontDisplayStack}; - --font-body: ${fontBodyStack}; - --font-mono: ${fontMonoStack}; -${fontScriptLine}${tokenLines.map((l) => " " + l).join("\n")}`; - _captionTokensRoot = rootBody; - return ` -<section id="color-tokens" class="ds-section"> - <div class="eyebrow">§2 · Color × Style overlay</div> - <h2>Color tokens + ${esc(preset.label)} decoration vars</h2> - <p class="ds-prose">Brand colors come from the site. Decoration variables come from the <strong>${esc(preset.label)}</strong> preset. The <code>:root</code> block is live on this page (so §6 component previews render properly). The paste-ready source for <code>chunks/tokens.css</code> is collapsed below — open it to copy.</p> - <style>:root {${rootBody} - }</style> - <details class="ds-paste-ready"> - <summary class="ds-summary">▸ Paste-ready source → <code>chunks/tokens.css</code></summary> - <pre class="ds-code"><!-- ROOT-START -->:root {${rootBody} -}<!-- ROOT-END --></pre> - </details> -</section>`; -} - -// ═══════════════════ Render: §3 Typography ═══════════════ -// Parse §T type-roles JSON block. Each entry is a named text role the Phase 4b -// scene worker may cite by `id` ("use a stamp-statement here") to pick correct -// size / weight / leading / tracking / case + decoration. The atlas renders in -// brand DNA fonts (var(--font-*) tokens), so the role catalog is preset-declared -// but the actual typeface is whatever the brand ships. Returns [] when no §T. -// ─────────────────── Render: §C Captions (preview) ─────── -// Preview-only. When the preset ships its own caption-skin.html (style-presets/<preset>/), -// embed it as a demo-filled, auto-looping iframe so design.html shows the real caption -// skin running. That same file is what emit-chunks copies to chunks/caption-skin.html and -// build-captions-html.mjs fills with the real word timings at Phase 4a.5 — so this preview -// is the actual skin, not a re-implementation (zero drift). Presets without a caption-skin -// emit nothing (uniform with non-captioned presets). -function renderCaptions() { - const skinPath = path.join(PRESETS_DIR, preset.name, "caption-skin.html"); - if (!fs.existsSync(skinPath)) return ""; - let skin = fs.readFileSync(skinPath, "utf8"); - - // Demo fills — the same holes build-captions-html.mjs fills, with placeholder words + - // a short looping duration (plain replaces; preview-only, not the strict builder). - const demoGroups = [ - { - start: 0.3, - end: 2.1, - words: [ - { text: "WRITE", start: 0.3, end: 0.9 }, - { text: "HTML,", start: 0.9, end: 1.4 }, - { text: "RENDER", start: 1.4, end: 2.1 }, - ], - }, - { - start: 2.5, - end: 4.3, - words: [ - { text: "VIDEO.", start: 2.5, end: 3.1 }, - { text: "NO", start: 3.1, end: 3.5 }, - { text: "TIMELINE.", start: 3.5, end: 4.3 }, - ], - }, - { - start: 4.7, - end: 6.2, - words: [ - { text: "JUST", start: 4.7, end: 5.1 }, - { text: "CODE.", start: 5.1, end: 5.9 }, - ], - }, - ]; - const demoDur = 6.6; - skin = skin.replace("var GROUPS = [];", `var GROUPS = ${JSON.stringify(demoGroups)};`); - skin = skin.replace("var DURATION = 0;", `var DURATION = ${demoDur};`); - skin = skin.replace('data-duration="0"', `data-duration="${demoDur}"`); - // Inject @font-face + :root tokens. The iframe is a separate document, so it can't - // inherit design.html's <head> fonts/tokens; the @font-face url('fonts/…') paths are - // relative and resolve against design.html's own dir (which has fonts/), so the preview - // renders in the real brand display face, not a system fallback. - skin = skin.replace( - "<style data-brand-tokens></style>", - `<style data-brand-tokens>\n${localFontFaceBlock}\n:root {${_captionTokensRoot}\n}</style>`, - ); - // The skin registers a PAUSED timeline (engine drives it at render time). For the doc - // preview, grab it and loop it. - skin = skin.replace( - "</body>", - ` <script> - (function () { - var t = window.__timelines && window.__timelines["captions"]; - if (t && t.duration()) t.repeat(-1).repeatDelay(0.5).play(); - })(); - </script> -</body>`, - ); - - // Fully HTML-escape for the srcdoc attribute (the browser decodes it back into the - // iframe document). Escape & first, then angle brackets and the quote delimiter. - const srcdoc = skin - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, """); - return ` -<section id="captions" class="ds-section"> - <div class="eyebrow">§C · Captions (preset-local skin)</div> - <h2>Word <em>by word.</em></h2> - <p class="ds-prose">Live preview of <code>caption-skin.html</code> — this preset's own caption look. The same file is copied to <code>chunks/caption-skin.html</code> and filled with real word timings by the caption builder (Phase 4a.5); the demo words and loop here are preview-only.</p> - <div style="position: relative; width: 100%; max-width: 960px; aspect-ratio: 16 / 9; overflow: hidden; border: 1px solid #e5e5e5; border-radius: 8px; margin: 24px 0; background: #fff;"> - <iframe title="caption-skin preview" loading="lazy" style="position: absolute; top: 0; left: 0; width: 1920px; height: 1080px; border: 0; transform: scale(0.5); transform-origin: top left;" srcdoc="${srcdoc}"></iframe> - </div> -</section>`; -} - -function parseTypeRoles() { - const sect = preset.sections?.T?.content; - if (!sect) return []; - const fenced = sect.match(/```type-roles\n([\s\S]+?)\n```/); - if (!fenced) return []; - try { - const arr = JSON.parse(fenced[1]); - return Array.isArray(arr) ? arr : []; - } catch (e) { - console.error(`✗ ${preset.name}: §T type-roles JSON failed to parse — ${e.message}`); - return []; - } -} - -// Pull every `.t-trole-<roleId>` CSS rule out of §I page-level CSS so the -// paste-ready chunks/type-roles.md ships decoration (color, shadow, transform, -// pseudo descendants) alongside the role's metadata. Matches plain selectors, -// descendant pseudo (`.t-trole-foo em`), compound classes (`.t-trole-foo.x`), -// and chained children (`.t-trole-foo .bar`). Uses a permissive `{…}` body -// matcher — these blocks are hand-authored CSS with no nesting. -function extractRoleCss(roleId, sectionICss) { - if (!sectionICss) return ""; - const re = new RegExp(`\\.t-trole-${roleId}(?![a-zA-Z0-9_-])[^{}]*\\{[^}]*\\}`, "g"); - return [...sectionICss.matchAll(re)].map((m) => m[0].trim()).join("\n\n"); -} - -// Build chunks/type-roles.md content. One section per role: metadata header + -// CSS rule (from §I) + sample HTML. Returns "" when preset declares no §T — -// emit-chunks will then leave type-roles.md unwritten and index.json's -// type_roles_file = null. -function buildTypeRolesMd() { - const roles = parseTypeRoles(); - if (!roles.length) return ""; - const sectionICss = [...(preset.sections.I?.content || "").matchAll(/```css\n([\s\S]*?)```/g)] - .map((m) => m[1]) - .join("\n"); - const sections = roles.map((r) => { - const css = extractRoleCss(r.id, sectionICss); - const sample = String(r.sample_html || "").trim(); - return `## type-role: ${r.id} - -- family: ${r.family || "—"} · px: ${r.px_min ?? "—"}–${r.px_max ?? "—"} · weight: ${r.weight ?? "—"} -- leading: ${r.leading ?? "—"} · tracking: ${r.tracking ?? "—"} · case: ${r.case || "—"} -- purpose: ${r.purpose || "—"} - -\`\`\`css -${css || `/* (preset §I did not ship CSS for .t-trole-${r.id} — derive from metadata above) */`} -\`\`\` - -Sample: - -\`\`\`html -${sample} -\`\`\``; - }); - return `# Type-roles atlas — ${preset.label} - -Phase 4b scene worker reads this when text outside §6 components is needed (hero displays, ledes, pill rows, CTA buttons, …). Workflow: pick role by id → paste the CSS rule into scene \`<style>\` with \`s<N>-\` prefix on the class names → wrap content using the prefixed class. Family tokens (\`var(--font-*)\`) resolve to brand DNA at scene-render time. - -${sections.join("\n\n")}`; -} - -function renderTypography() { - const recipe = preset.sections.D?.content || ""; - const typeRoles = parseTypeRoles(); - const atlasBlock = typeRoles.length - ? ` - <h3 class="ds-h3" style="margin-top: 48px;">Type-role atlas</h3> - <div class="ds-trole-box"> - ${typeRoles - .map((r) => { - const sample = String(r.sample_html || "").replace(/\{(\w+)\}/g, (_, k) => - placeholderFor(k), - ); - // Meta (id / family / px / weight / leading / tracking / case / purpose) is - // kept in data-* attrs — machine-readable for the plan agent, invisible to - // the reviewer. The rendered row is just the sample, so design.html shows - // what text actually looks like at scene scale, not a spec sheet. - const dataAttrs = [ - `data-scale="${esc(r.id || "")}"`, - `data-family="${esc(r.family || "")}"`, - `data-px-min="${esc(String(r.px_min ?? ""))}"`, - `data-px-max="${esc(String(r.px_max ?? ""))}"`, - `data-weight="${esc(String(r.weight ?? ""))}"`, - `data-leading="${esc(String(r.leading ?? ""))}"`, - `data-tracking="${esc(String(r.tracking ?? ""))}"`, - `data-case="${esc(r.case || "")}"`, - `data-purpose="${esc(r.purpose || "")}"`, - ].join(" "); - return ` - <div class="ds-trole-row" ${dataAttrs}> - <div class="ds-trole-sample">${sample}</div> - </div>`; - }) - .join("")} - </div>` - : ""; - return ` -<section id="typography" class="ds-section"> - <div class="eyebrow">§3 · Typography</div> - <h2>Type roles</h2> - <p class="ds-prose">Font families resolved by 4-step lookup: site name on Google Fonts → self-host local woff2 → preset §D fallback → final hard-coded fallback. Each card shows which step landed.</p> - - <div class="type-grid"> - ${[ - [ - "Display", - display, - "serif", - `font-size: 72px; font-weight: ${preset.name === "neo-brutalism" ? 800 : 400}; letter-spacing: ${preset.name === "neo-brutalism" ? "-0.04em" : "-0.02em"}; line-height: 1;`, - brandName, - ], - [ - "Body", - body, - "sans-serif", - "font-size: 22px; line-height: 1.5;", - "The quick brown fox jumps over the lazy dog.", - ], - ["Mono", mono, "ui-monospace, monospace", "font-size: 18px;", `font: ${mono.name}`], - ...(script - ? [ - [ - "Script", - script, - "cursive", - "font-size: 56px; font-weight: 400; line-height: 1; transform: rotate(-3deg); display: inline-block;", - "gets simpler —", - ], - ] - : []), - ] - .map(([roleLabel, role, fallbackChain, specimenStyle, specimenText]) => { - let note; - if (role.source === "site") { - note = `<div class="type-note">✓ from site (Google Fonts CDN)</div>`; - } else if (role.source === "site-local") { - const fileCount = role.localFiles.length; - note = `<div class="type-note">✓ from site (self-hosted, ${fileCount} woff/woff2 file${fileCount > 1 ? "s" : ""})</div>`; - } else if (role.originalName) { - note = `<div class="type-note">site '${esc(role.originalName)}' not resolvable → preset fallback</div>`; - } else { - note = `<div class="type-note">site has no ${roleLabel.toLowerCase()} face → preset fallback</div>`; - } - return ` - <div class="type-card"> - <div class="type-role">${roleLabel}</div> - <div class="type-name">${esc(role.name)}</div> - ${note} - <div class="type-specimen" style="font-family: '${esc(role.name)}', ${fallbackChain}; ${specimenStyle}">${esc(specimenText)}</div> - </div>`; - }) - .join("")} - </div> -${atlasBlock} -${(() => { - const typeRolesMd = buildTypeRolesMd(); - if (!typeRolesMd) return ""; - return ` - <details class="ds-paste-ready" style="margin-top: 32px;"> - <summary class="ds-summary">▸ Paste-ready source → <code>chunks/type-roles.md</code> (scene worker reads on demand)</summary> - <pre class="ds-code"><!-- TYPE-ROLES-START --> -${esc(typeRolesMd)} -<!-- TYPE-ROLES-END --></pre> - </details>`; -})()} - <details style="margin-top: 32px;"> - <summary class="ds-summary">Font pairing recipe (preset §D)</summary> - <div class="ds-prose-block">${mdBlockToHtml(recipe)}</div> - </details> -</section>`; -} - -// ═══════════════════ Render: §4 Motion ═══════════════════ -function renderMotion() { - const motionContent = preset.sections.E?.content || ""; - const intent = preset.sections.A?.content || ""; - // Pull out the JS block - const jsMatch = motionContent.match(/```js\n([\s\S]*?)```/); - const motionJs = jsMatch ? jsMatch[1].trim() : motionContent; - return ` -<section id="motion" class="ds-section"> - <div class="eyebrow">§4 · Motion (preset)</div> - <h2>${esc(preset.label)} motion language</h2> - <div class="ds-prose-block">${mdBlockToHtml(intent)}</div> - <details class="ds-paste-ready"> - <summary class="ds-summary">▸ Paste-ready source → <code>chunks/easings.js</code> (paste at top of every scene's <code><script></code>)</summary> - <pre class="ds-code"><!-- MOTION-START --> -${esc(motionJs)} -<!-- MOTION-END --></pre> - </details> -</section>`; -} - -// ═══════════════════ Render: §5 Voice ════════════════════ -// Two artifacts live in this section: -// 1. Human-readable cards (DNA dl + recipe prose) — for design.html browsers. -// 2. <pre class="ds-code"><!-- VOICE-START --> ... <!-- VOICE-END --></pre> -// — the paste-ready block that emit-chunks.mjs extracts to chunks/voice.md. -// Phase 4b scene workers consume this when writing on-screen copy -// (headlines, chips, buttons) so the brand register hits the DOM text. -// Narrator scripts (TTS-bound) are NOT in scope — Phase 2 ignores it. -function renderVoice() { - const recipe = (preset.sections.G?.content || "").trim(); - const dnaLines = [ - voiceTone && `- Tone: ${voiceTone}`, - voiceHeading && `- Heading style: ${voiceHeading}`, - sampleHeadings.length && - "- Sample headings:\n" + sampleHeadings.map((s) => ` - ${s}`).join("\n"), - ].filter(Boolean); - const voiceMd = `# Voice register: ${preset.name} - -## From the site (DNA) -${dnaLines.join("\n")} - -## Transform recipe - -${recipe} - -> Phase 4b scene workers: apply to DOM text only (headline / chip / button copy). -> Phase 2 narrator scripts are TTS-bound — do NOT uppercase or strip articles.`; - - return ` -<section id="voice" class="ds-section"> - <div class="eyebrow">§5 · Voice (site × style transform)</div> - <h2>How to write on-screen copy in ${esc(preset.label)} register</h2> - - <div class="voice-grid"> - <div> - <h3 class="ds-h3">From the site (DNA)</h3> - <dl class="voice-dl"> - ${voiceTone ? `<dt>Tone</dt><dd>${esc(voiceTone)}</dd>` : ""} - ${voiceHeading ? `<dt>Heading style</dt><dd>${esc(voiceHeading)}</dd>` : ""} - ${voiceCtaVerbs?.length ? `<dt>CTA verbs</dt><dd>${voiceCtaVerbs.map((c) => `<span class="voice-cta">${esc(c)}</span>`).join(" ")}</dd>` : ""} - ${sampleHeadings.length ? `<dt>Sample headings</dt><dd>${sampleHeadings.map((s) => `<div class="voice-sample">${esc(s)}</div>`).join("")}</dd>` : ""} - </dl> - </div> - <div> - <h3 class="ds-h3">Transform recipe (preset §G)</h3> - <div class="ds-prose-block">${mdBlockToHtml(recipe)}</div> - </div> - </div> - - ${ - voiceSamples.length - ? `<h3 class="ds-h3" style="margin-top: 32px;">Try it on this site's voice</h3> - ${voiceSamples - .map( - (s) => ` - <div class="voice-pair"> - <div class="voice-in"><span class="voice-tag">IN (site)</span> ${esc(s)}</div> - <div class="voice-out"><span class="voice-tag">OUT (Phase 4b applies recipe to DOM copy)</span> <em>worker writes register-shaped HTML text</em></div> - </div>`, - ) - .join("")}` - : "" - } - - <details class="ds-paste-ready" style="margin-top: 32px;"> - <summary class="ds-summary">▸ Paste-ready source → <code>chunks/voice.md</code> (Phase 4b applies recipe to DOM text)</summary> - <pre class="ds-code"><!-- VOICE-START --> -${esc(voiceMd)} -<!-- VOICE-END --></pre> - </details> -</section>`; -} - -// ═══════════════════ Render: §6 Components ═══════════════ -// Rewrite hardcoded font-family declarations in preset component CSS into -// var(--font-*) token references. Preset MD files were written with literal -// font names ("Bodoni Moda", "Manrope") as design-time hints — at composition -// time we want them flowing through the brand-resolved tokens (which may be -// site-local woff2, Google Fonts CDN, or preset fallback). -// -// Classification: -// - Any family list containing a serif name → --font-display (display roles -// in editorial / capsule / Bodoni-class presets are the prominent serifs) -// - Any family list containing a mono name → --font-mono -// - Everything else (sans-serif body fonts: Manrope/Inter/Geist/etc) → --font-body -// -// We rewrite a font-family line as a whole; once stripped down to a token -// reference we drop the original fallback chain (the token itself carries -// system-ui / serif / monospace fallbacks). -// "serif" must not be preceded by "sans-" (the generic family "sans-serif" is -// a sans, not a serif). We test for "serif" only when no "sans-" appears just -// before it. Other serif family names match by literal word. -const SERIF_NAME_RE = - /(?<!sans-)\bserif\b|\b(Bodoni|Playfair|Garamond|Fraunces|Newsreader|Spectral|Times|Lora|Source Serif|Crimson|Merriweather|PT Serif|DM Serif|Alfa Slab|Archivo Black|Anton|Big Shoulders|Stardos Stencil|Bowlby)\b/i; -const MONO_NAME_RE = - /\b(monospace|JetBrains Mono|Fira Code|IBM Plex Mono|Roboto Mono|Source Code|Space Mono|Geist Mono|DM Mono|Inconsolata|SFMono|Menlo|Consolas|ui-monospace|Barlow Condensed|VT323|Press Start 2P)\b/i; -// Script faces (caveat-brush etc.) get routed to var(--font-script). Order -// matters: script check runs BEFORE serif because some script names share -// generic keywords. The 4th role lives next to mono/display/serif. -const SCRIPT_NAME_RE = - /\b(Caveat|Caveat Brush|Pacifico|Kalam|Allura|Sacramento|Cookie|Satisfy|Marck Script|Permanent Marker)\b/i; -function rewriteComponentFontFamilies(block) { - // Replace each `font-family: <chain>;` with the right token. Skip values - // that already reference var(--font-*) so re-running is a no-op. - return block.replace(/font-family:\s*([^;\n}]+);/g, (full, chain) => { - if (/var\(--font-/.test(chain)) return full; - let token; - if (MONO_NAME_RE.test(chain)) token = "var(--font-mono)"; - else if (SCRIPT_NAME_RE.test(chain)) token = "var(--font-script)"; - else if (SERIF_NAME_RE.test(chain)) token = "var(--font-display)"; - else token = "var(--font-body)"; - return `font-family: ${token};`; - }); -} - -// ═══════════════════ Render: §H composition hints ════════ -// Two artifacts: -// 1. Human-readable preset rules — for design.html browsers. -// 2. <pre class="ds-code"><!-- HINTS-START --> ... <!-- HINTS-END --></pre> -// — paste-ready block that emit-chunks.mjs extracts to chunks/composition-hints.md. -// Phase 3 plan agent consumes this when picking components per scene: -// - surface contract (which components go on which surface) -// - material composition rules (single triple-stamp per plate, etc.) -// - 60/30/10 brand color placement -// Presets without §H emit an empty block (anchor still present so emit-chunks -// can produce a stub composition-hints.md the plan agent can read uniformly). -function renderHints() { - const hintsContent = (preset.sections.H?.content || "").trim(); - // Build the paste-ready md block. Keep the preset's §H content verbatim — - // it's authored prose, not a structured schema. Plan agent reads it as - // free-form guidance and applies the constraints when choosing components. - const hintsMd = hintsContent - ? `# Composition hints — ${preset.label}\n\n${hintsContent}` - : `# Composition hints — ${preset.label}\n\n_(preset declared no §H — plan agent picks components by id alone)_`; - return ` -<section id="hints" class="ds-section"> - <div class="eyebrow">§H · Scene composition hints (plan agent reads these)</div> - <h2>${esc(preset.label)} composition rules</h2> - <p class="ds-prose">Surface contracts, material composition rules, brand-color placement — anything that constrains <strong>which</strong> components a Phase 3 plan can mix in a single scene.</p> - <div class="ds-prose-block">${mdBlockToHtml(hintsContent || "_no §H declared_")}</div> - <details class="ds-paste-ready"> - <summary class="ds-summary">▸ Paste-ready source → <code>chunks/composition-hints.md</code> (plan agent reads this)</summary> - <pre class="ds-code"><!-- HINTS-START --> -${esc(hintsMd)} -<!-- HINTS-END --></pre> - </details> -</section>`; -} - -function renderComponents() { - if (!preset.components.length) return ""; - // When the preset declares chromeFonts, render the live preview inside - // .preset-native-scope so var(--font-*) resolves to preset-native families - // (e.g. Alfa Slab) instead of brand DNA. The paste-ready source block under - // <details> is untouched — Phase 4b still grep + paste the original tokens, - // and those resolve to brand DNA at scene-render time. The preview is purely - // a visual reference for what each component looks like at full preset-native - // expression. No dual-column — reviewers see the north-star directly. - const presetNative = !!preset.chromeFonts?.googleFontsHref; - const previewClass = presetNative ? "comp-preview preset-native-scope" : "comp-preview"; - return ` -<section id="components" class="ds-section"> - <div class="eyebrow">§6 · Components (paste-ready)</div> - <h2>${esc(preset.label)} component library</h2> - - ${preset.components - .map((rawC) => { - const c = { ...rawC, block: rewriteComponentFontFamilies(rawC.block) }; - // Live preview = render the HTML inside the component block (it's already a working snippet) - // For safety we extract first ```html ... ``` and first ```...``` style blocks if present. - const htmlMatch = c.block.match(/```html\n([\s\S]*?)```/); - const cssMatch = c.block.match(/```html[\s\S]*?<style>([\s\S]*?)<\/style>/); - const htmlSnippet = htmlMatch ? htmlMatch[1].trim() : ""; - // Strip <style> from the html before live-rendering — to a fixpoint, so - // fragments left by one pass can't reassemble into a new <style> block - // (CodeQL js/incomplete-multi-character-sanitization). - let htmlForPreview = htmlSnippet; - for (let prev = null; prev !== htmlForPreview; ) { - prev = htmlForPreview; - htmlForPreview = htmlForPreview.replace(/<style\b[\s\S]*?<\/style\s*>/gi, ""); - } - htmlForPreview = htmlForPreview.trim(); - const expanded = htmlForPreview.replace(/\{(\w+)\}/g, (_, key) => placeholderFor(key)); - return ` - <div class="comp-card"> - <div class="comp-head"> - <span class="comp-name">${esc(c.name)}</span> - <span class="comp-marker"><!-- COMPONENT: ${esc(c.name)} --></span> - </div> - <div class="${previewClass}"> - <style>${cssMatch ? cssMatch[1] : ""}</style> - ${expanded} - </div> - <details> - <summary class="ds-summary">Source</summary> - <pre class="ds-code"><!-- COMPONENT: ${esc(c.name)} --> -${c.meta ? `<!-- COMPONENT-META: ${esc(JSON.stringify(c.meta))} -->\n` : ""}${esc(c.block)} -<!-- /COMPONENT --></pre> - </details> - </div>`; - }) - .join("")} -</section>`; -} - -// Keys whose values are HTML and should NOT be HTML-escaped before injection. -const RAW_HTML_KEYS = new Set(["FOREGROUND_CONTENT", "HEADLINE_WITH_EM"]); -function placeholderFor(key) { - // Slot fills are seen in design.html previews. Keep them in video register - // (3-6 words, fragmented sentences) so downstream scene-worker agents don't - // copy webpage-length paragraphs from the brand DNA capture into 6-second - // scenes. Long brand text belongs in chunks/voice.md, not in slot previews. - const map = { - EYEBROW: "Build faster", - HEADLINE: brandName, - SUBHEAD: "Stamped. Signed. Framed.", - LABEL: "Get started", - LEDE: "Two lines max. One idea.", - KICKER: "Issue 01", - NUM: "4M", - QUOTE: sampleHeadings[0] || "This changes how teams work.", - AUTHOR: "Customer Quote", - LEFT: "Column one content", - RIGHT: "Column two content", - HEADLINE_WITH_EM: - "borders are <em>structural</em>. shadows are <em>weight</em>. tilt is <em>intent</em>.", - DO_1: "Use accent on exactly one element per scene", - DO_2: "Keep display weight at 800, body at 500", - DO_3: "Cut between scenes — never crossfade", - DONT_1: "Don't add a second accent color", - DONT_2: "Don't use body text under 24px in video", - DONT_3: "Don't blur shadows — offset only", - FOREGROUND_CONTENT: `<div style="font-family: '${display.name}', serif; font-size: clamp(40px, 5vw, 88px); line-height: 1.05; letter-spacing: -0.02em; margin-bottom: 16px;">${brandName}</div><div style="font-family: '${body.name}', sans-serif; font-size: clamp(16px, 1.4vw, 20px); opacity: 0.85; max-width: 38ch;">Stamped. Signed. Framed.</div>`, - - // —— peoples-platform / designhtml-class additions —— - // Inline script-em pattern (e.g. "The work gets simpler as the team gets braver.") - SCRIPT_BEFORE: "The work ", - SCRIPT_WORD: "gets simpler", - SCRIPT_AFTER: " as the team gets braver.", - // Stat-block trio (numeral + unit + caption with em + supporting source line) - STAT_UNIT: "%", - STAT_SOURCE: `— Internal source, Q1 ${new Date().getFullYear()} —`, - // Quote attribution sub-roles - AUTHOR_ROLE: "— Head of Ops, Acme Inc. —", - // Diamond list (3 priority sentences) - ITEM_1: "Ship the core flow. Cut three legacy paths.", - ITEM_2: "Talk to ten teams. Brief findings every Friday.", - ITEM_3: "One launch, not five. Shared positioning across drops.", - // Track-dots timeline (4 sequential beats) - LABEL_1: "May · Kickoff", - LABEL_2: "June · Beta", - LABEL_3: "August · Launch", - LABEL_4: "October · Scale", - // Round-stamp content (big mark / small mark / closer caption) - MARK_BIG: "END", - MARK_SMALL: "— V. 01 —", - CAPTION: "Stamped, signed, closed.", - // End-plate credit line - CREDIT: `★ ${brandName} · Vol. 01 ★`, - }; - const value = map[key] ?? key; - return RAW_HTML_KEYS.has(key) ? value : esc(value); -} - -// ═══════════════════ Page styles ═════════════════════════ -// Pull preset §I "Page-level CSS" — the styling that should drive design.html -// itself (hero, section borders, dividers, decorations). Falls back gracefully -// if preset doesn't define §I. -function extractPagePresetCss() { - const raw = preset.sections.I?.content || ""; - // Pull all fenced ```css blocks (a preset may have multiple) - const blocks = [...raw.matchAll(/```css\n([\s\S]*?)```/g)].map((m) => m[1]); - if (blocks.length === 0) return ""; - return `\n/* ── Preset §I page styling (from ${esc(preset.name)}.md) ── */\n${blocks.join("\n\n")}`; -} - -function renderPageStyles() { - return ` - *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } - html { scroll-behavior: smooth; } - body { - font-family: '${esc(body.name)}', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; - color: ${inkHex}; - background: ${canvasHex}; - line-height: 1.55; - -webkit-font-smoothing: antialiased; - } - .wrap { max-width: 1120px; margin: 0 auto; padding: 0 32px; } - .ds-section { padding: 80px 0; border-top: 1px solid ${isLight(canvasHex) ? "#e5e5e5" : "#222"}; } - .ds-section:first-of-type { border-top: none; } - .eyebrow { font-family: '${esc(mono.name)}', ui-monospace, monospace; font-size: 12px; text-transform: uppercase; letter-spacing: 0.14em; opacity: 0.6; margin-bottom: 12px; } - h1, h2, h3 { font-family: '${esc(display.name)}', serif; font-weight: ${preset.name === "neo-brutalism" ? 800 : 400}; letter-spacing: ${preset.name === "neo-brutalism" ? "-0.03em" : "-0.015em"}; line-height: 1.05; } - h2 { font-size: clamp(36px, 4vw, 56px); margin-bottom: 16px; } - h3.ds-h3 { font-size: 18px; font-family: '${esc(body.name)}', sans-serif; font-weight: 600; margin: 20px 0 12px; opacity: 0.85; } - .ds-prose { font-size: 16px; max-width: 62ch; margin: 12px 0; opacity: 0.85; } - .ds-prose code, code { font-family: '${esc(mono.name)}', monospace; background: ${isLight(canvasHex) ? "#f0f0f0" : "#1a1a1a"}; padding: 2px 6px; border-radius: 3px; font-size: 0.92em; } - .ds-code { font-family: '${esc(mono.name)}', monospace; background: ${isLight(canvasHex) ? "#0f1419" : "#0a0a0a"}; color: #e6edf3; padding: 20px 24px; border-radius: 8px; overflow-x: auto; font-size: 13px; line-height: 1.6; margin: 16px 0; white-space: pre; } - .ds-summary { cursor: pointer; padding: 8px 0; font-family: '${esc(mono.name)}', monospace; font-size: 12px; text-transform: uppercase; letter-spacing: 0.1em; opacity: 0.7; } - details[open] .ds-summary { opacity: 1; } - - /* Paste-ready source blocks — collapsed by default because the raw markdown / CSS - in <pre> doesn't render as a readable doc (it's machine-bound for emit-chunks). - The rendered version sits above each <details>. */ - .ds-paste-ready { margin: 16px 0; padding: 12px 16px; border: 1px dashed ${isLight(canvasHex) ? "#bbb" : "#444"}; border-radius: 8px; background: ${isLight(canvasHex) ? "#fafafa" : "#181818"}; } - .ds-paste-ready > .ds-summary { padding: 4px 0; font-size: 11px; opacity: 0.85; } - .ds-paste-ready > .ds-summary code { background: ${isLight(canvasHex) ? "#eee" : "#222"}; padding: 1px 5px; border-radius: 3px; font-size: 0.95em; } - .ds-paste-ready[open] > .ds-summary { margin-bottom: 8px; } - .ds-paste-ready > pre.ds-code { margin: 0; } - - /* ── Title card */ - .title-card { padding: 80px 0 64px; border-bottom: 1px solid ${isLight(canvasHex) ? "#e5e5e5" : "#222"}; } - .title-card-inner { max-width: 1120px; margin: 0 auto; padding: 0 32px; } - .brand-row { display: flex; gap: 12px; align-items: baseline; font-family: '${esc(mono.name)}', monospace; font-size: 13px; letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 24px; } - .brand-name { color: ${primaryHex}; font-weight: 700; } - .brand-x { opacity: 0.4; } - .style-name { color: ${accentHex}; font-weight: 700; } - .title-display { font-size: clamp(48px, 7vw, 96px); margin: 0; } - .title-meta { margin-top: 32px; font-family: '${esc(mono.name)}', monospace; font-size: 12px; opacity: 0.7; } - .title-meta strong { font-weight: 700; opacity: 0.9; } - - /* ── §1 Brand DNA */ - .dna-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 24px 0; } - .dna-grid-5 { grid-template-columns: repeat(5, 1fr); } - .dna-swatch { padding: 24px 20px; border-radius: 8px; min-height: 120px; display: flex; flex-direction: column; justify-content: space-between; } - .dna-label { font-family: '${esc(mono.name)}', monospace; font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; opacity: 0.8; } - .dna-hex { font-family: '${esc(mono.name)}', monospace; font-size: 18px; font-weight: 700; } - .dna-desc { margin-top: 24px; font-size: 18px; line-height: 1.55; max-width: 60ch; opacity: 0.85; } - .dna-gradient { margin: 32px 0 0; } - .dna-gradient-label { font-family: '${esc(mono.name)}', monospace; font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; opacity: 0.6; margin-bottom: 10px; } - .dna-gradient-bar { height: 96px; border-radius: 8px; } - .dna-gradient-code { display: block; margin-top: 10px; font-family: '${esc(mono.name)}', monospace; font-size: 12px; opacity: 0.7; word-break: break-all; } - @media (max-width: 900px) { .dna-grid-5 { grid-template-columns: repeat(3, 1fr); } } - @media (max-width: 720px) { .dna-grid { grid-template-columns: repeat(2, 1fr); } .dna-grid-5 { grid-template-columns: repeat(2, 1fr); } } - - /* ── §3 Typography */ - .type-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin: 24px 0; } - .type-card { padding: 24px; border: 1px solid ${isLight(canvasHex) ? "#e5e5e5" : "#222"}; border-radius: 8px; } - .type-role { font-family: '${esc(mono.name)}', monospace; font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; opacity: 0.6; } - .type-name { font-size: 18px; font-weight: 600; margin: 4px 0; } - .type-note { font-family: '${esc(mono.name)}', monospace; font-size: 11px; opacity: 0.5; margin-bottom: 16px; } - .type-specimen { margin-top: 16px; } - @media (max-width: 720px) { .type-grid { grid-template-columns: 1fr; } } - - /* ── §5 Voice */ - .voice-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; margin: 24px 0; } - .voice-dl dt { font-family: '${esc(mono.name)}', monospace; font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; opacity: 0.6; margin-top: 16px; } - .voice-dl dd { font-size: 15px; margin-top: 4px; } - .voice-cta { display: inline-block; padding: 2px 10px; border: 1px solid ${isLight(canvasHex) ? "#ddd" : "#333"}; border-radius: 999px; font-size: 13px; margin: 2px 4px 2px 0; } - .voice-sample { font-size: 15px; opacity: 0.85; margin: 4px 0; } - .voice-pair { padding: 16px; border: 1px solid ${isLight(canvasHex) ? "#e5e5e5" : "#222"}; border-radius: 8px; margin: 12px 0; } - .voice-tag { font-family: '${esc(mono.name)}', monospace; font-size: 10px; text-transform: uppercase; letter-spacing: 0.14em; opacity: 0.6; margin-right: 8px; } - .voice-in { margin-bottom: 8px; } - .voice-out { opacity: 0.7; } - @media (max-width: 720px) { .voice-grid { grid-template-columns: 1fr; } } - - /* ── §6 Components */ - .comp-card { padding: 0; border: 1px solid ${isLight(canvasHex) ? "#e5e5e5" : "#222"}; border-radius: 8px; margin: 16px 0; overflow: hidden; } - .comp-head { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; background: ${isLight(canvasHex) ? "#fafafa" : "#181818"}; border-bottom: 1px solid ${isLight(canvasHex) ? "#e5e5e5" : "#222"}; } - .comp-name { font-family: '${esc(mono.name)}', monospace; font-size: 13px; font-weight: 700; } - .comp-marker { font-family: '${esc(mono.name)}', monospace; font-size: 11px; opacity: 0.6; } - /* Component preview: components are authored for 1920×1080 scenes. The doc - container is ~1056px wide, so wide components (mega-stat etc.) overflow. - Allow horizontal scroll so reviewers can pan; max-height caps vertical too. */ - .comp-preview { padding: 32px; background: ${canvasHex}; overflow: auto; max-height: 720px; } - .comp-preview::-webkit-scrollbar { height: 8px; width: 8px; } - .comp-preview::-webkit-scrollbar-thumb { background: ${isLight(canvasHex) ? "#ccc" : "#444"}; border-radius: 4px; } - - /* ── Markdown blocks (mdBlockToHtml output) */ - .ds-prose-block { max-width: 80ch; margin: 16px 0; } - .ds-prose-block .ds-prose { margin: 0 0 12px; } - .ds-prose-block .ds-prose:last-child { margin-bottom: 0; } - .ds-prose-block .ds-h2 { font-family: '${esc(display.name)}', serif; font-size: 24px; margin: 24px 0 12px; } - .ds-prose-block .ds-h3 { font-size: 16px; font-weight: 700; margin: 20px 0 8px; opacity: 0.9; } - .ds-prose-block .ds-h4 { font-size: 14px; font-weight: 700; margin: 18px 0 6px; opacity: 0.85; text-transform: uppercase; letter-spacing: 0.06em; } - .ds-prose-block .ds-list { margin: 8px 0 16px; padding-left: 24px; } - .ds-prose-block .ds-list li { margin: 4px 0; line-height: 1.55; } - .ds-prose-block .ds-list li code { font-size: 0.92em; } - .ds-prose-block .ds-code { margin: 12px 0; } - .ds-table { border-collapse: collapse; margin: 16px 0; font-size: 14px; } - .ds-table th, .ds-table td { padding: 8px 12px; border: 1px solid ${isLight(canvasHex) ? "#e5e5e5" : "#222"}; vertical-align: top; text-align: left; } - .ds-table th { background: ${isLight(canvasHex) ? "#fafafa" : "#181818"}; font-weight: 700; font-size: 13px; text-transform: uppercase; letter-spacing: 0.06em; } - .ds-table td code { font-size: 0.92em; } - `; -} - -// ═══════════════════ Assemble ════════════════════════════ -const html = `<!DOCTYPE html> -<html lang="en"> -<head> -<meta charset="UTF-8"> -<meta name="viewport" content="width=device-width, initial-scale=1.0"> -<title>${esc(brandName)} × ${esc(preset.label)} — design.html - .. → paste into scene - - - -${renderTitleCard()} - -
-${renderBrandDNA()} -${renderColorAndTokens()} -${renderTypography()} -${renderMotion()} -${renderVoice()} -${renderHints()} -${renderComponents()} -${renderCaptions()} -
- - - -`; - -// ═══════════════════ Write inference.json ════════════════ -// Always written — even on --no-emit and --style — so the design-system -// subagent (Phase 1b) can review the ranked candidate pool, the matched -// signals, and the site DNA in one structured file before deciding whether -// to override the baseline winner. -function summariseCandidate(s) { - const p = presets.find((pp) => pp.name === s.name); - return { - name: s.name, - label: p?.label || s.name, - raw: Number(s.raw.toFixed(3)), - normalized: Number(s.normalized.toFixed(3)), - combined: Number(s.combined.toFixed(3)), - combined_pre_capability: Number((s.combined_pre_capability ?? s.combined).toFixed(3)), - matched_signals: s.matched, - capabilities_missing: s.capabilities_missing || [], - fingerprint: p?.fingerprint || {}, - best_for: p?.bestFor || [], - avoid_for: p?.avoidFor || [], - sectionA_excerpt: (p?.sections?.A?.content || "").slice(0, 800), - }; -} -function summariseTopCandidates(n) { - if (!scores) return []; - // Only include eligible (capability-satisfied) candidates in top_candidates. - const winnerScore = scores[0]?.combined || 0; - return scores - .filter((s) => s.combined > 0) - .slice(0, n) - .map((s) => ({ - ...summariseCandidate(s), - delta_from_winner: Number((winnerScore - s.combined).toFixed(3)), - })); -} -// Capability-gated: would have scored well but is missing runtime / env reqs. -// Subagent reads this to know "if I install X, this preset becomes available". -function summariseCapabilityGated() { - if (!scores) return []; - return scores - .filter((s) => s.capabilities_missing && s.capabilities_missing.length > 0) - .map(summariseCandidate); -} -// confidence: high if winner clearly ahead, low if next candidate is within 0.05. -// Only considers eligible (combined > 0) candidates. -function inferConfidence() { - const eligible = (scores || []).filter((s) => s.combined > 0); - if (eligible.length < 2) return "high"; - const delta = eligible[0].combined - eligible[1].combined; - if (delta < 0.05) return "low"; - if (delta < 0.12) return "medium"; - return "high"; -} -// First-screen screenshot, relative to the PROJECT_DIR (the agent's cwd) so the -// brand-review step can open it directly. -function firstScreenshot() { - try { - const dir = path.join(captureDir, "screenshots"); - const files = fs - .readdirSync(dir) - .filter((f) => /^scroll-.*\.(png|jpe?g)$/i.test(f)) - .sort(); - if (files.length) { - return path.relative(path.resolve(outDir, ".."), path.join(dir, files[0])); - } - } catch {} - return null; -} -// Brand block: the auto-classified triplet + the ranked candidate pool + a -// confidence + a screenshot, so the design-system agent can review and override -// the primary via `--brand-primary ` when confidence is low (see guide.md). -const _brandConf = brandConfidence(_brandChrom); -const brandReport = { - primary: _brand.primary, - secondary: _brand.secondary, - accent: _brand.accent, - source: cliBrandPrimary ? "agent-override" : _brandChrom ? "signals" : "legacy", - confidence: cliBrandPrimary ? "agent-override" : _brandChrom ? _brandConf.label : "legacy", - // review needed when the auto-pick is ambiguous and the agent hasn't yet - // overridden — the agent should open `screenshot` and pick from `candidates`. - needs_review: !cliBrandPrimary && !!_brandChrom && _brandConf.label !== "high", - screenshot: firstScreenshot(), - candidates: (_brandChrom || []).slice(0, 6).map((c) => ({ - hex: c.hex, - score: c.score, - bgCount: c.bgCount, - interactiveBg: c.interactiveBg, - on_button: c.onButton, - })), -}; -const inferenceReport = { - mode, - selected: { name: preset.name, label: preset.label }, - confidence: mode === "forced" ? "forced" : inferConfidence(), - brand: brandReport, - baseline_winner: - scores && scores.find((s) => s.combined > 0) - ? { - name: scores.find((s) => s.combined > 0).name, - combined: Number(scores.find((s) => s.combined > 0).combined.toFixed(3)), - } - : null, - top_candidates: summariseTopCandidates(5), - // Presets whose match_signals would put them in the top-N but a runtime / - // environment requirement is unmet. Each entry includes `capabilities_missing` - // with `auto_install` commands the subagent can run to materialise the dep. - // If empty, no preset is currently gated — all eligible candidates compete. - capability_gated_presets: summariseCapabilityGated(), - site_features: features || {}, - site_dna: { - source: sourceUrl || null, - brand_name: brandName, - material: dna?.materialLanguage?.label || null, - imagery: dna?.imageryStyle?.label || null, - background_patterns: dna?.backgroundPatterns?.labels || [], - page_intent: intent?.pageIntent?.type || null, - section_role_counts: intent?.sectionRoles?.counts || {}, - voice_tone: voice?.tone || null, - voice_heading_style: voice?.headingStyle || null, - voice_heading_length: voice?.headingLengthClass || null, - }, - generated_at: new Date().toISOString(), -}; -fs.mkdirSync(path.dirname(outScoresFile), { recursive: true }); -fs.writeFileSync(outScoresFile, JSON.stringify(inferenceReport, null, 2)); - -// Surface the brand pick + whether the agent should review it via screenshot. -console.log( - ` brand: ${brandReport.primary} primary (${brandReport.source}, confidence=${brandReport.confidence})`, -); -if (brandReport.needs_review) { - console.log( - ` ⚠ brand review: ambiguous pick — open ${brandReport.screenshot || "first-screen screenshot"}, ` + - `choose the real brand color from inference.json.brand.candidates, ` + - `then re-run with --brand-primary `, - ); -} - -// ═══════════════════ Write design.html + report ══════════ -if (cliNoEmit) { - console.log( - `✓ ${path.relative(process.cwd(), outScoresFile)} (inference only; --no-emit, design.html skipped)`, - ); - console.log(` preset: ${preset.name} (${mode}, confidence=${inferenceReport.confidence})`); - if (mode === "inferred" && scores) { - console.log( - ` top-5: ${scores - .slice(0, 5) - .map((s) => `${s.name}=${s.combined.toFixed(2)}`) - .join(" · ")}`, - ); - } - process.exit(0); -} -fs.mkdirSync(path.dirname(outFile), { recursive: true }); -fs.writeFileSync(outFile, html); -const sizeKb = (Buffer.byteLength(html) / 1024).toFixed(1); -console.log(`✓ ${path.relative(process.cwd(), outFile)} (${sizeKb}KB)`); -console.log(`✓ ${path.relative(process.cwd(), outScoresFile)} (inference report)`); -console.log(` source: ${sourceUrl || "(no source)"}`); -console.log(` brand: ${brandName} · ${materialLabel} material · ${intentLabel} intent`); -console.log( - ` palette: ${primaryHex} primary · ${secondaryHex} secondary · ${tertiaryHex} tertiary · ${accentHex} accent · ${costumeHex} costume`, -); -console.log(` deco: ${decoColors.join(" · ")}`); -console.log( - ` fonts: ${display.name} display · ${body.name} body · ${mono.name} mono${script ? ` · ${script.name} script` : ""}`, -); -const fontRolesToReport = [ - ["display", display], - ["body", body], - ["mono", mono], -]; -if (script) fontRolesToReport.push(["script", script]); -for (const [roleName, role] of fontRolesToReport) { - if (role.source === "preset") { - if (role.originalName) { - console.log( - ` ! ${roleName.padEnd(7)}: '${role.originalName}' not resolvable → ${role.name}`, - ); - } else { - console.log( - ` ! ${roleName.padEnd(7)}: site has no ${roleName} face → ${role.name} (preset fallback)`, - ); - } - } else if (role.source === "site-local") { - const fileList = role.localFiles.map((f) => path.basename(f)).join(", "); - console.log(` ✓ ${roleName.padEnd(7)}: '${role.name}' self-hosted (${fileList})`); - } -} -if (copiedFontFiles.length > 0) { - console.log( - ` fonts/: ${copiedFontFiles.length} file(s) copied to ${path.relative(process.cwd(), path.join(outDir, "fonts"))}/`, - ); -} -console.log(` preset: ${preset.name} (${mode}, confidence=${inferenceReport.confidence})`); -if (scores && scores.length) { - console.log( - ` scores: ${scores - .slice(0, 5) - .map((s) => `${s.name}=${s.combined.toFixed(2)}`) - .join(" · ")}`, - ); - const trueFeatures = Object.entries(features) - .filter(([, v]) => v) - .map(([k]) => k); - if (trueFeatures.length) console.log(` matched signals: ${trueFeatures.join(", ")}`); -} -console.log(` components: ${preset.components.length} paste-ready`); diff --git a/skills/faceless-explainer/phases/design-system/scripts/emit-chunks.mjs b/skills/faceless-explainer/phases/design-system/scripts/emit-chunks.mjs deleted file mode 100644 index a0b8ae1d60..0000000000 --- a/skills/faceless-explainer/phases/design-system/scripts/emit-chunks.mjs +++ /dev/null @@ -1,276 +0,0 @@ -#!/usr/bin/env node -/** - * emit-chunks.mjs - * - * Parse a finished design.html (from build-design.mjs) and emit paste-ready - * chunks under /chunks/. Downstream phases (visual-design plan, scene - * workers) read these chunks instead of grepping the monolithic design.html, - * cutting their must-read load from ~12 KB to ~1-3 KB per file consumed. - * - * Usage: - * node emit-chunks.mjs - * - * Inputs: - * /design.html — must exist (produced by build-design.mjs) - * - * Outputs: - * /chunks/tokens.css — :root { ... } from §ROOT block - * /chunks/easings.js — EASE / DUR const from §MOTION block - * /chunks/voice.md — DOM-copy register from §VOICE block - * /chunks/composition-hints.md — §H rules (surface/material/colour) — plan agent reads this - * /chunks/components/.html — one file per §COMPONENT block - * /chunks/index.json — manifest (preset, paths, component list + frontmatter) - * - * Exit 0 on success; 1 if design.html or required ROOT/MOTION/VOICE markers are missing. - */ - -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -const outDir = path.resolve(process.argv[2] || "./design-system"); -const designHtmlPath = path.join(outDir, "design.html"); -const chunksDir = path.join(outDir, "chunks"); -const componentsDir = path.join(chunksDir, "components"); - -if (!fs.existsSync(designHtmlPath)) { - console.error(`✗ emit-chunks: ${designHtmlPath} not found — run build-design.mjs first`); - process.exit(1); -} - -const html = fs.readFileSync(designHtmlPath, "utf8"); - -fs.mkdirSync(chunksDir, { recursive: true }); -fs.mkdirSync(componentsDir, { recursive: true }); - -function htmlDecode(s) { - return s - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/'/g, "'") - .replace(/ /g, " ") - .replace(/&/g, "&"); -} - -// Strip an optional ``` ... ``` markdown code fence — build-design wraps -// component bodies in fences for the design.html UI; chunks need raw HTML. -function stripCodeFence(s) { - let t = s; - t = t.replace(/^\s*```[a-z]*\s*\n?/i, ""); - t = t.replace(/\n?\s*```\s*$/i, ""); - return t; -} - -// design.html's AGENT NOTE comment +

docs blocks contain -// literal references to these markers (e.g. `grep `) which -// would false-positive a naive whole-file regex. Anchor every match to a -//

 opener — that's where the paste-ready blocks live.
-const PRE_OPEN = `]*class=["']ds-code["'][^>]*>\\s*`;
-
-// ─── 1. tokens.css ────────────────────────────────────────────────
-const rootMatch = html.match(
-  new RegExp(`${PRE_OPEN}([\\s\\S]*?)`),
-);
-if (!rootMatch) {
-  console.error(
-    '✗ emit-chunks: missing 
 ...  block in design.html',
-  );
-  process.exit(1);
-}
-const tokensCss = htmlDecode(rootMatch[1]).trim();
-fs.writeFileSync(path.join(chunksDir, "tokens.css"), tokensCss + "\n");
-
-// ─── 2. easings.js ────────────────────────────────────────────────
-const motionMatch = html.match(
-  new RegExp(`${PRE_OPEN}([\\s\\S]*?)`),
-);
-if (!motionMatch) {
-  console.error(
-    '✗ emit-chunks: missing 
 ...  block in design.html',
-  );
-  process.exit(1);
-}
-const easingsJs = htmlDecode(motionMatch[1]).trim();
-fs.writeFileSync(path.join(chunksDir, "easings.js"), easingsJs + "\n");
-
-// ─── 3. voice.md ──────────────────────────────────────────────────
-// §5 ships a paste-ready register for Phase 4b workers writing on-screen copy
-// (headline / chip / button text). Narrator scripts are TTS-bound and stay in
-// Phase 2 — voice.md never enters that path.
-const voiceMatch = html.match(
-  new RegExp(`${PRE_OPEN}([\\s\\S]*?)`),
-);
-if (!voiceMatch) {
-  console.error(
-    '✗ emit-chunks: missing 
 ...  block in design.html',
-  );
-  process.exit(1);
-}
-const voiceMd = htmlDecode(voiceMatch[1]).trim();
-fs.writeFileSync(path.join(chunksDir, "voice.md"), voiceMd + "\n");
-
-// ─── 3.5 composition-hints.md ─────────────────────────────────────
-// §H ships scene-composition rules (surface contract, material avoidance, 60/30/10
-// color placement). The plan agent reads this when picking
-// components for a scene — without it, peoples-style hard rules (single
-// triple-stamp per plate, cream-frame only on dark surfaces, …) wouldn't reach
-// anyone. Presets without a §H emit a stub block so the file always exists and
-// plan agent's must-read path is uniform across presets.
-const hintsMatch = html.match(
-  new RegExp(`${PRE_OPEN}([\\s\\S]*?)`),
-);
-let hintsFile = null;
-if (hintsMatch) {
-  const hintsMd = htmlDecode(hintsMatch[1]).trim();
-  fs.writeFileSync(path.join(chunksDir, "composition-hints.md"), hintsMd + "\n");
-  hintsFile = "chunks/composition-hints.md";
-}
-
-// ─── 3.6 type-roles.md ────────────────────────────────────────────
-// §T type-role atlas (optional). Phase 4b scene worker reads on demand when
-// text outside §6 components is needed — paste-ready markdown with per-role
-// metadata, the §I CSS rule, and a sample snippet. Presets without §T → no
-// file written, index.json's type_roles_file = null.
-const typeRolesMatch = html.match(
-  new RegExp(`${PRE_OPEN}([\\s\\S]*?)`),
-);
-let typeRolesFile = null;
-let typeRolesBytes = 0;
-if (typeRolesMatch) {
-  const typeRolesMd = htmlDecode(typeRolesMatch[1]).trim();
-  fs.writeFileSync(path.join(chunksDir, "type-roles.md"), typeRolesMd + "\n");
-  typeRolesFile = "chunks/type-roles.md";
-  typeRolesBytes = Buffer.byteLength(typeRolesMd);
-}
-
-// ─── 4. components ────────────────────────────────────────────────
-// Component blocks live inside 
...
with HTML-entity- -// escaped markers (so design.html renders the markers as visible text for human -// readers). Match only when anchored to a ds-code
 opener to avoid the
-// docs paragraph that explains the marker convention with a literal placeholder.
-const compRe = new RegExp(
-  `${PRE_OPEN}<!--\\s*COMPONENT:\\s*([a-z0-9-]+)\\s*-->([\\s\\S]*?)<!--\\s*\\/COMPONENT\\s*-->`,
-  "g",
-);
-// designhtml-class presets (peoples-platform, …) emit a COMPONENT-META line right
-// after the COMPONENT marker, carrying frontmatter fields (surface / composes /
-// role / avoids_same_scene / slots, …). Pluck it out before the body is treated
-// as raw HTML, then spread the parsed JSON into the index.json entry so the plan
-// agent can filter components by surface/role without opening each .html file.
-// Legacy presets without frontmatter skip emission → meta stays null → entry
-// stays {id, file} as before.
-const componentMetaRe = /^\s*\s*\r?\n?/;
-const components = [];
-let cm;
-while ((cm = compRe.exec(html)) !== null) {
-  const id = cm[1];
-  let body = htmlDecode(cm[2]);
-  let meta = null;
-  const metaMatch = body.match(componentMetaRe);
-  if (metaMatch) {
-    try {
-      meta = JSON.parse(metaMatch[1]);
-    } catch (e) {
-      console.error(
-        `! emit-chunks: component '${id}' has COMPONENT-META but JSON is invalid (${e.message}); ignoring`,
-      );
-    }
-    body = body.slice(metaMatch[0].length);
-  }
-  body = stripCodeFence(body).trim();
-  fs.writeFileSync(path.join(componentsDir, `${id}.html`), body + "\n");
-  components.push({
-    id,
-    file: `chunks/components/${id}.html`,
-    size: Buffer.byteLength(body),
-    meta,
-  });
-}
-
-if (components.length === 0) {
-  console.error("✗ emit-chunks: no COMPONENT blocks found — design.html may be malformed or empty");
-  process.exit(1);
-}
-
-// ─── 5. index.json (manifest) ─────────────────────────────────────
-// Parse the AGENT NOTE comment for preset / source URL so downstream phases
-// can route on preset without re-parsing the HTML themselves.
-let preset = null;
-let source_url = null;
-const agentNote = html.match(//);
-if (agentNote) {
-  const note = agentNote[0];
-  const ps = note.match(/Style preset:\s*([^\n(]+?)\s*(?:\([^)]+\))?\s*$/m);
-  const su = note.match(/Brand DNA from:\s*(\S+)/);
-  if (ps) preset = ps[1].trim();
-  if (su) source_url = su[1].trim();
-}
-
-// ─── 4.6 caption-skin.html (optional preset-local caption skin) ───
-// A preset MAY ship its own pre-baked, brand-tokenized caption skin at
-// style-presets//caption-skin.html. When present, copy it into chunks/ so
-// build-captions-html.mjs can use it as the project's caption SOURCE (a second source
-// alongside the registry caption-* skins). Absent → registry skins, exactly as before.
-let captionSkinFile = null;
-if (preset) {
-  const skinSrc = path.resolve(__dirname, "..", "style-presets", preset, "caption-skin.html");
-  if (fs.existsSync(skinSrc)) {
-    fs.copyFileSync(skinSrc, path.join(chunksDir, "caption-skin.html"));
-    captionSkinFile = "chunks/caption-skin.html";
-  }
-}
-
-const index = {
-  generated_at: new Date().toISOString(),
-  source_url,
-  preset,
-  tokens_file: "chunks/tokens.css",
-  easings_file: "chunks/easings.js",
-  voice_file: "chunks/voice.md",
-  // hints_file is null when the preset doesn't declare §H. Plan agent treats null
-  // as "no preset-level composition contract — pick by component id only".
-  hints_file: hintsFile,
-  // type_roles_file is null when preset declares no §T. Worker reads on demand
-  // (paths flow through prep.mjs → dispatch).
-  type_roles_file: typeRolesFile,
-  // caption_skin_file is null unless the preset ships style-presets//caption-skin.html.
-  // When set, build-captions-html.mjs uses it as the caption source (preferred over registry
-  // caption-* skins); null → registry skin scoring, as before.
-  caption_skin_file: captionSkinFile,
-  components: components.map(({ id, file, meta }) =>
-    // Spread frontmatter (surface / composes / role / avoids_same_scene / slots)
-    // alongside id+file. Plan agent reads these without opening component .html.
-    meta ? { id, file, ...meta } : { id, file },
-  ),
-};
-fs.writeFileSync(path.join(chunksDir, "index.json"), JSON.stringify(index, null, 2) + "\n");
-
-// ─── 6. report ────────────────────────────────────────────────────
-const fmt = (b) => (b / 1024).toFixed(1);
-const tokenBytes = Buffer.byteLength(tokensCss);
-const easingBytes = Buffer.byteLength(easingsJs);
-const voiceBytes = Buffer.byteLength(voiceMd);
-const hintsBytes = hintsFile ? Buffer.byteLength(htmlDecode(hintsMatch[1]).trim()) : 0;
-const compBytes = components.reduce((sum, c) => sum + c.size, 0);
-const designBytes = Buffer.byteLength(html);
-const chunksBytes = tokenBytes + easingBytes + voiceBytes + hintsBytes + typeRolesBytes + compBytes;
-
-console.log(`✓ ${path.relative(process.cwd(), chunksDir)}/`);
-console.log(`  tokens.css         ${fmt(tokenBytes)} KB`);
-console.log(`  easings.js         ${fmt(easingBytes)} KB`);
-console.log(`  voice.md           ${fmt(voiceBytes)} KB`);
-if (hintsFile) console.log(`  composition-hints.md  ${fmt(hintsBytes)} KB`);
-if (typeRolesFile) console.log(`  type-roles.md      ${fmt(typeRolesBytes)} KB`);
-if (captionSkinFile) console.log(`  caption-skin.html  (preset-local caption source)`);
-console.log(`  components/        ${components.length} files`);
-for (const c of components) {
-  console.log(`    ${c.id}.html  (${fmt(c.size)} KB)`);
-}
-console.log(`  index.json         lists ${components.length} components (preset=${preset || "?"})`);
-console.log(
-  `  totals             chunks ${fmt(chunksBytes)} KB vs design.html ${fmt(designBytes)} KB (~${Math.round((chunksBytes / designBytes) * 100)}% of source)`,
-);
diff --git a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/caption-skin.html b/skills/faceless-explainer/phases/design-system/style-presets/block-frame/caption-skin.html
deleted file mode 100644
index 4c8a340192..0000000000
--- a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/caption-skin.html
+++ /dev/null
@@ -1,247 +0,0 @@
-
-
-  
-    
-    
-    Block Frame — Captions
-    
-    
-
-    
-    
-
-    
-  
-  
-    
- -
- - - - diff --git a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/button.md b/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/button.md deleted file mode 100644 index ff2a2f0e4f..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/button.md +++ /dev/null @@ -1,20 +0,0 @@ -```html - - - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/chip.md b/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/chip.md deleted file mode 100644 index 15b71d6385..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/chip.md +++ /dev/null @@ -1,19 +0,0 @@ -```html -{LABEL} - - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/corner-pins.md b/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/corner-pins.md deleted file mode 100644 index 9c20e2eb17..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/corner-pins.md +++ /dev/null @@ -1,48 +0,0 @@ -```html -
-
-
-
-
-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/dot-grid-bg.md b/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/dot-grid-bg.md deleted file mode 100644 index 57b831559d..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/dot-grid-bg.md +++ /dev/null @@ -1,22 +0,0 @@ -```html -
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/feature-card.md b/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/feature-card.md deleted file mode 100644 index 62b5ac05f1..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/feature-card.md +++ /dev/null @@ -1,74 +0,0 @@ -```html - -
-
-
A
-

{HEADLINE}

-

{LEDE}

-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/hero.md b/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/hero.md deleted file mode 100644 index 2872d0b575..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/hero.md +++ /dev/null @@ -1,126 +0,0 @@ -```html -
-
-
-
-
-
- {EYEBROW} -

{HEADLINE}

-

{SUBHEAD}

-
{LABEL}
-
-
-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/quote-frame.md b/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/quote-frame.md deleted file mode 100644 index 40033237da..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/quote-frame.md +++ /dev/null @@ -1,66 +0,0 @@ -```html -
-
"
-

{QUOTE}

-

{AUTHOR}

-
-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/star-burst.md b/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/star-burst.md deleted file mode 100644 index 0e8583bc1b..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/star-burst.md +++ /dev/null @@ -1,20 +0,0 @@ -```html - - - - - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/stat-counter.md b/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/stat-counter.md deleted file mode 100644 index 56ebcee5ca..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/stat-counter.md +++ /dev/null @@ -1,48 +0,0 @@ -```html -
-
-
{NUM}
-
{LABEL}
-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/timeline-step.md b/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/timeline-step.md deleted file mode 100644 index e2a9e43194..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/components/timeline-step.md +++ /dev/null @@ -1,74 +0,0 @@ -```html - -
-
01
-

{HEADLINE}

-

{LEDE}

-
-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/preset.md b/skills/faceless-explainer/phases/design-system/style-presets/block-frame/preset.md deleted file mode 100644 index 0460822690..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/block-frame/preset.md +++ /dev/null @@ -1,463 +0,0 @@ -```preset-meta -{ - "name": "block-frame", - "label": "Block Frame", - "fingerprint": { - "shadow": "hard-offset-black", - "border": "4px-solid-ink", - "palette": "saturated-pastel-cycle", - "motion": "tilt-and-snap", - "decoration": "tilted-puncture" - }, - "match_signals": [ - { "kind": "shadow_zero_blur", "weight": 0.3 }, - { "kind": "thick_solid_border", "weight": 0.3 }, - { "kind": "high_sat_accent", "weight": 0.15 }, - { "kind": "minimal_decoration", "weight": 0.05 } - ], - "best_for": ["indie SaaS launches", "agency credentials", "creative reviews", "brand redesigns", "design-led product talks"], - "avoid_for": ["regulated disclosures", "formal legal briefs", "institutional restraint", "enterprise compliance"], - "chromeFonts": { - "googleFontsHref": "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Space+Grotesk:wght@400;500;600;700&display=swap", - "display": "Inter", - "body": "Inter", - "script": "Inter", - "mono": "Space Grotesk" - }, - "palette": { - "primary": { "value": "#FE90E8", "constraint": "hot pink — the dominant pastel ground and icon-square fill; lead color in the full-bleed slide-ground cycle" }, - "secondary": { "value": "#C0F7FE", "constraint": "sky blue — the 2nd brand hue: the colored 12px close-frame shadow and the stat-deco-dot fill; cool counterpoint cycled as a secondary ground" }, - "accent": { "value": "#F7CB46", "constraint": "golden yellow — the CTA color: default button fill, list-number squares, label-pill fill; the brightest, most attention-pulling pastel" }, - "canvas": { "value": "#FFFDF5", "lock": "anchor", "constraint": "warm off-white — the neutral ground between colored slides; keep warm, never stark white" }, - "surface": { "value": "#FFFFFF", "constraint": "pure white — the default card fill, label-pill and nav-button background" }, - "ink": { "value": "#000000", "constraint": "pure black, no warm bias — every 4px border, every zero-blur offset shadow, every primary text moment; the system's contrast anchor" } - } -} -``` - -> `chromeFonts` makes the doc chrome render in the preset's native fonts; brand fonts still apply to §6 components. Block Frame refuses a third face — the `script` slot points at Inter. - -## §A Director's intent - -Block Frame is maximalist neobrutalism: every region wears a 4px solid ink -border, every elevated card carries an 8px hard offset shadow (zero blur, -solid ink), every corner is square, and the canvas cycles through five -saturated pastels — pink, blue, green, yellow, cream — plus off-white and -ink. Display is heavy uppercase Inter with negative tracking; chrome is -wide-tracked Space Grotesk in caps. Decorative tilts (±2° to ±12°), -star-bursts, stripe blocks, and dot grids puncture the grid intentionally. -Density is the rule, not the exception. - -Brand-aware color contract: `--brand-primary` is the dominant pastel -ground, `--brand-secondary` is the colored-shadow accent (replaces the -template's signature yellow shadow on the close-frame), `--brand-accent` -is the CTA fill. Class prefix is `bf-` (block-frame initials). - -## §B Decoration tokens - -```css -/* Border ladder — 4px primary, 3px secondary, 2px atomic chrome */ ---bf-border-bold: 4px solid var(--ink); ---bf-border-mid: 3px solid var(--ink); ---bf-border-thin: 2px solid var(--ink); - -/* Hard offset shadow stack — solid ink, zero blur, bottom-right */ ---bf-shadow: 8px 8px 0 var(--ink); ---bf-shadow-sm: 4px 4px 0 var(--ink); ---bf-shadow-hover: 6px 6px 0 var(--ink); - -/* Inverted close-surface depth — only colored shadow in the system, - aliased to brand-secondary so it follows the site palette instead - of the template's locked yellow. */ ---bf-shadow-close: 12px 12px 0 var(--brand-secondary); ---bf-shadow-close-btn: 6px 6px 0 var(--canvas); - -/* Tilt vocabulary — stat cards alternate small, decorations tilt loud */ ---bf-tilt-sm-l: -2deg; ---bf-tilt-sm-r: 2deg; ---bf-tilt-md-l: -8deg; ---bf-tilt-md-r: 8deg; ---bf-tilt-loud: 12deg; - -/* Spacing — template's px scale, kept px for structural fidelity */ ---bf-pad-card: 36px; ---bf-pad-card-sm: 28px; ---bf-pad-card-xs: 22px; ---bf-pad-card-lg: 60px; ---bf-gap-lg: 48px; ---bf-gap-md: 32px; ---bf-gap-sm: 24px; ---bf-gap-xs: 16px; - -/* Decorative dot-grid pattern unit */ ---bf-dot-size: 24px; ---bf-dot-radius: 1.2px; -``` - -## §D Font pairing fallback - -- **display**: `'Inter'` · `'Archivo Black'` · `'Space Grotesk'` wght 900 -- **body**: `'Inter'` · `'IBM Plex Sans'` wght 500 -- **mono**: `'Space Grotesk'` · `'JetBrains Mono'` · `'IBM Plex Mono'` wght 600 - -## §T Type-role atlas (Phase 4b reads this to size text correctly) - -Sole authoring source for non-component text; do NOT invent ad-hoc sizes — Block Frame's identity depends on the heavy-uppercase + negative-tracking + sentence-body + wide-tracked-label ladder. - -```type-roles -[ - { - "id": "heading-xl", - "family": "display", - "purpose": "hero / cover headline — uppercase Inter 900 with negative tracking", - "px_min": 48, "px_max": 96, "weight": 900, "leading": "0.95", "tracking": "-0.03em", "case": "upper", - "sample_html": "
Neo-Brutalism Style
" - }, - { - "id": "heading-lg", - "family": "display", - "purpose": "primary section headline (Inter 800, uppercase, -0.02em)", - "px_min": 32, "px_max": 64, "weight": 800, "leading": "1", "tracking": "-0.02em", "case": "upper", - "sample_html": "
What we deliver
" - }, - { - "id": "heading-md", - "family": "display", - "purpose": "region or chart title (Inter 700, sentence-case allowed)", - "px_min": 24, "px_max": 40, "weight": 700, "leading": "1.1", "tracking": "-0.01em", "case": "sentence", - "sample_html": "
Quarterly growth metrics
" - }, - { - "id": "close-title", - "family": "display", - "purpose": "closing-statement title on the inverted ink surface (cream / canvas text)", - "px_min": 40, "px_max": 80, "weight": 900, "leading": "0.95", "tracking": "-0.03em", "case": "upper", - "sample_html": "
Let's build something bold
" - }, - { - "id": "quote-text", - "family": "display", - "purpose": "uppercase pull-quote body (Inter 900, framed inside a bordered quote-frame)", - "px_min": 28, "px_max": 52, "weight": 900, "leading": "1.15", "tracking": "-0.02em", "case": "upper", - "sample_html": "
Design is how it works, how it feels, how it lasts.
" - }, - { - "id": "stat-number", - "family": "display", - "purpose": "hero / card stat numeral (Inter 900, line-height 1)", - "px_min": 36, "px_max": 64, "weight": 900, "leading": "1", "tracking": "0", "case": "upper", - "sample_html": "
98%
" - }, - { - "id": "card-title", - "family": "display", - "purpose": "feature / intro / team card title — Inter 700 uppercase", - "px_min": 24, "px_max": 28, "weight": 700, "leading": "1.2", "tracking": "0", "case": "upper", - "sample_html": "
Modular layouts
" - }, - { - "id": "step-num", - "family": "display", - "purpose": "timeline-step numeral — Inter 900 at 0.6 opacity (mandatory reduction)", - "px_min": 48, "px_max": 48, "weight": 900, "leading": "1", "tracking": "0", "case": "upper", - "sample_html": "
01
" - }, - { - "id": "label-pill", - "family": "mono", - "purpose": "universal eyebrow inside a bordered + shadowed pastel pill — Space Grotesk 600, 13px, 0.08em tracked, uppercase", - "px_min": 24, "px_max": 24, "weight": 600, "leading": "1", "tracking": "0.08em", "case": "upper", - "sample_html": "
Overview
" - }, - { - "id": "mono-tag", - "family": "mono", - "purpose": "mono tag / badge — Space Grotesk 600, 14px, 0.05em tracked, uppercase", - "px_min": 24, "px_max": 24, "weight": 600, "leading": "1", "tracking": "0.05em", "case": "upper", - "sample_html": "
12+ years
" - }, - { - "id": "counter", - "family": "mono", - "purpose": "persistent slide counter — Space Grotesk 700, 14px, 0.1em tracked, uppercase (NN / NN)", - "px_min": 24, "px_max": 24, "weight": 700, "leading": "1", "tracking": "0.1em", "case": "upper", - "sample_html": "
01 / 10
" - } -] -``` - -## §E Motion (GSAP consts — REPLACES site ease) - -```js -// RULE: motion is snap-and-hit, never slow ease-in-out for primary entrances. -// RULE: cards "punch in" — translate from -8/-8 offset with shadow growing -// from 0 to var(--bf-shadow). Mirrors the hover lift-up signature. -// RULE: tilts are baked at rest; do NOT tween rotation during entry. -// Tilt-then-pop reads as wobble; tilt-at-rest reads as deliberate. -// RULE: emphasis on chrome (label-pills, buttons) uses back.out(1.6) to -// echo the hard-shadow "stamp" feel. -// RULE: never blur shadows during motion — toggle box-shadow values, do -// not interpolate filter() or use shadow-blur tweens. - -const EASE = { - entry: "expo.out", // cards hit and stick — template uses 0.15s ease but punchier on video scale - emphasis: "back.out(1.6)", // chrome pops (pills, buttons, stat cards) — echoes the brutalist stamp - exit: "power2.in", // sharp exits — never linger - drift: "sine.inOut", // ambient (dot-grid fade-in, tilt micro-sway) only -}; - -const DUR = { - snap: 0.16, // chrome hover, label-pill in, button settle — mirrors template's 0.15s - med: 0.42, // card entry, headline reveal - slow: 0.9, // hero entry, close-frame reveal — the loudest moments -}; -``` - -### §E.5 Motion choreography - -- **Transition defaults:** hard cut between scenes is the spiritual default. If a transition is needed, punch-translate the incoming hero element with DUR.med + EASE.emphasis. -- **Type-in-motion:** display headlines reveal as a single unit (no per- - character split). Sub-headline reveals at +0.12s with `power2.out` + - DUR.snap. Label-pills always emphasis-pop, never linear-fade. -- **Stagger budget:** ≤6 elements in a single stagger; beyond that, group - visually (timeline-step row, stat-grid) and animate the group as one. - -## §G Voice transform recipe - -1. Strip articles and connectives (the / a / of / and / with / to). -2. Break into 2-4 word noun-verb-noun fragments or single dominant nouns. -3. UPPERCASE all on-screen text that lands in display, chip, or button - slots — sentence body stays sentence case. -4. Join fragments with `.` + linebreak for stacked impact, or em-dash - `—` for a single beat of emphasis. -5. End headlines on a noun, not a verb — the brutalist stamp lands on the - thing, not the action. -6. Brand name appears as the final clause, punctuated standalone. - -**Example:** - -- IN: `Higgsfield is the AI platform that helps creators generate stunning visuals in seconds` -- OUT: `CREATORS. GENERATE. STUNNING VISUALS — IN SECONDS. HIGGSFIELD.` - -## §I Page-level CSS - -```css -/* ── Preset-native typography vars (loaded via preset-meta.chromeFonts.googleFontsHref). - * These let the doc chrome render in Inter + Space Grotesk regardless of - * brand DNA. The §6 component preview and §T type-role atlas also read - * these via .preset-native-scope. - * - * Block Frame has no script face — the script slot points at Inter because the - * preset refuses a third face. The fallback chain ends in a heavy grotesque - * (Archivo Black / system-ui) that still carries the "neobrutalist mass" - * register. Mono slot is Space Grotesk (treated as quasi-mono via wide - * tracking + uppercase) with JetBrains Mono / IBM Plex Mono as deeper falls. */ -:root { - --f-disp-native: - "Inter", "Archivo Black", "Helvetica Neue", -apple-system, BlinkMacSystemFont, system-ui, - sans-serif; - --f-body-native: - "Inter", "IBM Plex Sans", -apple-system, BlinkMacSystemFont, system-ui, sans-serif; - --f-script-native: - "Inter", "Archivo Black", "Helvetica Neue", -apple-system, BlinkMacSystemFont, system-ui, - sans-serif; - --f-mono-native: - "Space Grotesk", "JetBrains Mono", "IBM Plex Mono", "Menlo", ui-monospace, monospace; -} - -/* .preset-native-scope: re-bind font tokens to preset-native families for §6 previews + §T atlas. */ -.preset-native-scope { - --font-display: var(--f-disp-native); - --font-body: var(--f-body-native); - --font-script: var(--f-script-native); - --font-mono: var(--f-mono-native); -} - -/* design.html chrome — borrows the preset's visual register */ -body { - background: var(--canvas, #fffdf5); - font-family: "Inter", sans-serif; - color: var(--ink, #000); -} - -h1, -h2, -h3 { - font-family: "Inter", sans-serif; - font-weight: 900; - text-transform: uppercase; - letter-spacing: -0.02em; -} - -h2 { - border-bottom: var(--bf-border-bold, 4px solid #000); - padding-bottom: 0.4em; - margin-top: 1.4em; -} - -code, -pre { - font-family: "Space Grotesk", monospace; - background: color-mix(in srgb, var(--brand-primary, #fe90e8) 12%, transparent); - border: var(--bf-border-mid, 3px solid #000); - padding: 0.1em 0.4em; -} - -pre { - padding: 1em; - box-shadow: var(--bf-shadow-sm, 4px 4px 0 #000); -} - -/* ── §T Type-role atlas. Container = bordered + shadowed canvas card. */ -.ds-trole-box { - display: flex; - flex-direction: column; - border: var(--bf-border-bold, 4px solid var(--ink)); - border-radius: 0; - background: var(--canvas, #fffdf5); - box-shadow: var(--bf-shadow, 8px 8px 0 var(--ink)); - overflow: hidden; - margin-top: 24px; -} -.ds-trole-row { - padding: 28px 32px; - border-bottom: var(--bf-border-thin, 2px solid var(--ink)); -} -.ds-trole-row:last-child { - border-bottom: 0; -} -.ds-trole-sample { - min-width: 0; - overflow-wrap: anywhere; -} -@media (max-width: 960px) { - .ds-trole-row { - padding: 24px; - } -} - -/* ── Type-role samples. var(--font-*) resolves to brand DNA; decoration is preset-native. */ -.t-trole-heading-xl { - font-family: var(--font-display); - font-weight: 900; - font-size: clamp(48px, 6vw, 96px); - line-height: 0.95; - letter-spacing: -0.03em; - text-transform: uppercase; - color: var(--ink); -} -.t-trole-heading-lg { - font-family: var(--font-display); - font-weight: 800; - font-size: clamp(32px, 4vw, 64px); - line-height: 1; - letter-spacing: -0.02em; - text-transform: uppercase; - color: var(--ink); -} -.t-trole-heading-md { - font-family: var(--font-display); - font-weight: 700; - font-size: clamp(24px, 2.5vw, 40px); - line-height: 1.1; - letter-spacing: -0.01em; - color: var(--ink); -} -.t-trole-close-title { - display: inline-block; - font-family: var(--font-display); - font-weight: 900; - font-size: clamp(40px, 5vw, 80px); - line-height: 0.95; - letter-spacing: -0.03em; - text-transform: uppercase; - color: var(--canvas, #fffdf5); - background: var(--ink); - border: 4px solid var(--canvas, #fffdf5); - box-shadow: 12px 12px 0 var(--brand-secondary, var(--brand-primary)); - padding: 24px 32px; - max-width: 22ch; -} -.t-trole-quote-text { - display: inline-block; - font-family: var(--font-display); - font-weight: 900; - font-size: clamp(28px, 3.5vw, 52px); - line-height: 1.15; - letter-spacing: -0.02em; - text-transform: uppercase; - color: var(--ink); - background: var(--canvas, #fffdf5); - border: 4px solid var(--ink); - box-shadow: 8px 8px 0 var(--ink); - padding: 24px 32px; - max-width: 28ch; -} -.t-trole-stat-number { - font-family: var(--font-display); - font-weight: 900; - font-size: clamp(36px, 4vw, 64px); - line-height: 1; - color: var(--ink); -} -.t-trole-card-title { - font-family: var(--font-display); - font-weight: 700; - font-size: 28px; - line-height: 1.2; - text-transform: uppercase; - color: var(--ink); -} -.t-trole-step-num { - font-family: var(--font-display); - font-weight: 900; - font-size: 48px; - line-height: 1; - color: var(--ink); - opacity: 0.6; -} -.t-trole-label-pill { - display: inline-block; - font-family: var(--font-mono); - font-weight: 600; - font-size: 24px; - line-height: 1; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--ink); - background: var(--brand-primary); - border: 3px solid var(--ink); - box-shadow: 4px 4px 0 var(--ink); - padding: 6px 16px; -} -.t-trole-mono-tag { - display: inline-block; - font-family: var(--font-mono); - font-weight: 600; - font-size: 24px; - line-height: 1; - letter-spacing: 0.05em; - text-transform: uppercase; - color: var(--ink); - background: var(--brand-accent, var(--brand-primary)); - border: 3px solid var(--ink); - padding: 10px 20px; -} -.t-trole-counter { - display: inline-block; - font-family: var(--font-mono); - font-weight: 700; - font-size: 24px; - line-height: 1; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--ink); - background: var(--canvas, #fffdf5); - border: 3px solid var(--ink); - box-shadow: 4px 4px 0 var(--ink); - padding: 10px 18px; -} -``` - - - diff --git a/skills/faceless-explainer/phases/design-system/style-presets/capsule/caption-skin.html b/skills/faceless-explainer/phases/design-system/style-presets/capsule/caption-skin.html deleted file mode 100644 index 74be62e576..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/capsule/caption-skin.html +++ /dev/null @@ -1,243 +0,0 @@ - - - - - - Capsule — Captions - - - - - - - - - -
- -
- - - - diff --git a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/bar-chart.md b/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/bar-chart.md deleted file mode 100644 index bfd17222f8..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/bar-chart.md +++ /dev/null @@ -1,92 +0,0 @@ -```html - -
-
-
{LABEL}
-
-
82%
-
-
{NUM}
-
-
-
{LABEL}
-
-
67%
-
-
{NUM}
-
-
-
{LABEL}
-
-
45%
-
-
{NUM}
-
-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/chip.md b/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/chip.md deleted file mode 100644 index 19e3e36e89..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/chip.md +++ /dev/null @@ -1,22 +0,0 @@ -```html -
{LABEL}
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/diagram-node.md b/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/diagram-node.md deleted file mode 100644 index 3153b02a61..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/diagram-node.md +++ /dev/null @@ -1,58 +0,0 @@ -```html - -
-
{LABEL}
- -
{LABEL}
-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/floating-pills.md b/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/floating-pills.md deleted file mode 100644 index 3bf7881a14..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/floating-pills.md +++ /dev/null @@ -1,92 +0,0 @@ -```html - - - - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/hero.md b/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/hero.md deleted file mode 100644 index e922cbe748..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/hero.md +++ /dev/null @@ -1,49 +0,0 @@ -```html -
-
{EYEBROW}
-

{HEADLINE}

-
{SUBHEAD}
-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/orbit-composition.md b/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/orbit-composition.md deleted file mode 100644 index 796e59f2a1..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/orbit-composition.md +++ /dev/null @@ -1,94 +0,0 @@ -```html - -
-
01
-
{LABEL}
-
{LABEL}
-
{LABEL}
-
{LABEL}
-
{LABEL}
-
{LABEL}
-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/pillar-card.md b/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/pillar-card.md deleted file mode 100644 index 48c2444ddc..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/pillar-card.md +++ /dev/null @@ -1,53 +0,0 @@ -```html - - -
-
I
-

{HEADLINE}

-

{LEDE}

-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/quote-highlight.md b/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/quote-highlight.md deleted file mode 100644 index 8d3580642d..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/quote-highlight.md +++ /dev/null @@ -1,54 +0,0 @@ -```html -
- - {QUOTE} -
{AUTHOR}
-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/stat-counter.md b/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/stat-counter.md deleted file mode 100644 index fc3a00f889..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/stat-counter.md +++ /dev/null @@ -1,47 +0,0 @@ -```html -
-
{NUM}
-
{LABEL}
-
-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/timeline-step.md b/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/timeline-step.md deleted file mode 100644 index f5dd3f43dd..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/timeline-step.md +++ /dev/null @@ -1,54 +0,0 @@ -```html - - -
-
1
-
{LABEL}
-
{LEDE}
-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/title-pill.md b/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/title-pill.md deleted file mode 100644 index 18dc4467cb..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/title-pill.md +++ /dev/null @@ -1,23 +0,0 @@ -```html - -
{LABEL}
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/visual-frame.md b/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/visual-frame.md deleted file mode 100644 index 62fdb72c83..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/capsule/components/visual-frame.md +++ /dev/null @@ -1,54 +0,0 @@ -```html - -
-
-
- {LABEL} -
-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/capsule/preset.md b/skills/faceless-explainer/phases/design-system/style-presets/capsule/preset.md deleted file mode 100644 index 72dcb4045b..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/capsule/preset.md +++ /dev/null @@ -1,381 +0,0 @@ -```preset-meta -{ - "name": "capsule", - "label": "Capsule", - "fingerprint": { - "geometry": "universal-pill", - "shadow": "soft-offset-low-opacity", - "type": "didone-serif-plus-grotesk", - "decoration": "floating-pill-wallpaper" - }, - "match_signals": [ - { "kind": "shadow_zero_blur", "weight": 0.3 }, - { "kind": "serif_display", "weight": 0.25 }, - { "kind": "medium_solid_border", "weight": 0.15 } - ], - "best_for": ["lifestyle brands", "creator portfolios", "DTC product launches", "beauty", "wellness", "playful tech demos"], - "avoid_for": ["institutional gravitas", "enterprise security", "edge-and-weight registers", "industrial / hardware"], - "chromeFonts": { - "googleFontsHref": "https://fonts.googleapis.com/css2?family=Bodoni+Moda:ital,opsz,wght@0,6..96,400..900;1,6..96,400..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:wght@400;500;700&display=swap", - "display": "Bodoni Moda", - "body": "Space Grotesk", - "script": "Bodoni Moda", - "mono": "Space Mono" - }, - "palette": { - "primary": { "value": "#E85D4E", "constraint": "warm coral — the lead 'voice-y' candy; first pill fill in cycles, the most frequent stat numeral and accent-line color; color lives inside pills, never on type" }, - "secondary": { "value": "#F2D160", "constraint": "warm yellow — the source's title-pill / hero-pill default fill and the 'second pop' in card-icon and pill cycles" }, - "accent": { "value": "#8BB4F7", "constraint": "cornflower sky — the cool 'third accent' that sits beside coral+yellow in 3-up grids; the single high-attention pop on chips and stat-bar accents" }, - "canvas": { "value": "#F5F5F0", "lock": "anchor", "constraint": "warm bone — sun-bleached magazine-paper ground; reads as paper, not screen-white; keep warm, never replace with stark white" }, - "surface": { "value": "#FFFFFF", "constraint": "clean white — pill-card / stat-tile fill that lifts off the bone canvas with a low-opacity hard-offset shadow" }, - "ink": { "value": "#1A1A1A", "constraint": "near-black — every Bodoni headline, body line, and the universal 2px pill outline; serifs always render in ink, never a candy color" } - } -} -``` - -> `chromeFonts` makes the doc chrome render in the preset's native fonts; brand fonts still apply to §6 components. Capsule refuses a third face — italic emphasis lives inside `` on the Bodoni axis, not a separate script family. - -## §A Director's intent - -Capsule is a playful editorial system: every text container is a pill (border-radius 9999px for small, 2rem for cards), every shape carries a 2px ink outline, every elevated surface casts a low-opacity hard-offset shadow at 4/6/8/12px. The voice is Memphis-meets-magazine — confident didone serifs (Bodoni-class) for every display moment, geometric grotesque (Space-Grotesk-class) for body and small uppercase tracked text. Color is a candy palette used as flat pill fills; serifs stay ink, color lives inside pills and on stat numerals. Motion is gentle and Material-eased — opacity-led, never bouncy, never elastic. - -The class prefix is `cap-` across all components. - -**Atmosphere baseline (material):** every scene carries the persistent grain overlay (4% opacity, multiply blend, fixed inset 0, z-index 9999) plus at least one soft candy radial glow (6–15% opacity). A bare cream canvas without atmosphere reads as broken. - -## §B Decoration tokens - -```css -/* universal pill stroke + radii */ ---cap-outline-w: 2px; ---cap-radius-pill: 9999px; ---cap-radius-card: 2rem; ---cap-radius-inner: 1.5rem; - -/* hard-offset shadows — ink at low opacity (follows site ink, not literal black) */ ---cap-shadow-color: color-mix(in srgb, var(--ink) 8%, transparent); ---cap-shadow-sm: 4px 4px 0 var(--cap-shadow-color); ---cap-shadow-md: 6px 6px 0 var(--cap-shadow-color); ---cap-shadow-lg: 8px 8px 0 var(--cap-shadow-color); ---cap-shadow-xl: 12px 12px 0 var(--cap-shadow-color); - -/* spacing */ ---cap-pad-card: 2.5rem 2rem; ---cap-pad-pill-lg: 1.5rem 3.5rem; ---cap-pad-pill-md: 1rem 2.5rem; ---cap-pad-pill-sm: 0.4rem 1.2rem; ---cap-pad-pill-xs: 0.35rem 1rem; ---cap-gap-lg: 3rem; ---cap-gap-md: 2rem; ---cap-gap-sm: 1.5rem; ---cap-gap-xs: 0.75rem; - -/* small accents */ ---cap-accent-line-w: 60px; ---cap-accent-line-h: 4px; ---cap-step-size: 56px; ---cap-icon-size: 60px; ---cap-orbit-size: 160px; - -/* tracking values for the small-text identity signal */ ---cap-track-tight: -0.02em; ---cap-track-loose: 0.1em; ---cap-track-pill: 0.12em; -``` - -## §D Font pairing fallback - -- **display**: `'Bodoni Moda'` · `'Playfair Display'` · `'Fraunces'` wght 700 -- **body**: `'Space Grotesk'` · `'Inter'` · `'DM Sans'` wght 400 -- **mono**: `'Space Mono'` · `'JetBrains Mono'` wght 500 - -## §T Type-role atlas (Phase 4b reads this to size text correctly) - -Sole authoring source for non-component text; do NOT invent ad-hoc sizes — Capsule's identity collapses if Bodoni drops below weight 700 at display scale or if small Space Grotesk text loses its uppercase + tracked treatment. - -```type-roles -[ - { - "id": "display", - "family": "display", - "purpose": "cover / opening display headline — Bodoni 800, ink on cream (never candy color)", - "px_min": 48, "px_max": 112, "weight": 800, "leading": "0.9", "tracking": "-0.02em", "case": "sentence", - "sample_html": "
Where vision meets execution
" - }, - { - "id": "closing-display", - "family": "display", - "purpose": "conclusive declarative headline — slightly smaller than cover display", - "px_min": 40, "px_max": 80, "weight": 800, "leading": "0.95", "tracking": "-0.03em", "case": "sentence", - "sample_html": "
Begin the next chapter
" - }, - { - "id": "headline", - "family": "display", - "purpose": "primary slide headline on split or two-column layouts (Bodoni 700)", - "px_min": 32, "px_max": 56, "weight": 700, "leading": "1.05", "tracking": "-0.02em", "case": "sentence", - "sample_html": "
A pill for every story
" - }, - { - "id": "section-headline", - "family": "display", - "purpose": "section-opening or centered headline above cards / charts", - "px_min": 29, "px_max": 48, "weight": 700, "leading": "1.05", "tracking": "-0.01em", "case": "sentence", - "sample_html": "
What we ship this season
" - }, - { - "id": "quote-display", - "family": "display", - "purpose": "pull-quote body (Bodoni 600; inline emphasis via quote-highlight pill, not bold)", - "px_min": 26, "px_max": 48, "weight": 600, "leading": "1.35", "tracking": "-0.01em", "case": "sentence", - "sample_html": "
The pill is the page. The serif is the voice.
" - }, - { - "id": "card-headline", - "family": "display", - "purpose": "card or pillar-block title (Bodoni 700) — sits just above card body so the serif title reads first", - "px_min": 24, "px_max": 28, "weight": 700, "leading": "1.1", "tracking": "0", "case": "sentence", - "sample_html": "
Studio essentials
" - }, - { - "id": "stat-number", - "family": "display", - "purpose": "large numerical stat figure — colored in a brand role, never ink", - "px_min": 32, "px_max": 48, "weight": 800, "leading": "1", "tracking": "-0.03em", "case": "sentence", - "sample_html": "
340%
" - }, - { - "id": "pill-text-md", - "family": "body", - "purpose": "text inside title / closing pills and pill-shaped callouts — uppercase Space Grotesk 600, 0.12em tracking", - "px_min": 24, "px_max": 24, "weight": 600, "leading": "1", "tracking": "0.12em", "case": "upper", - "sample_html": "
Now available
" - }, - { - "id": "label", - "family": "body", - "purpose": "header tag pills, eyebrows, attribution lines — uppercase tracked Space Grotesk 500 (the small-text identity signal)", - "px_min": 24, "px_max": 24, "weight": 500, "leading": "1", "tracking": "0.1em", "case": "upper", - "sample_html": "
Section · 03
" - } -] -``` - -## §E Motion (GSAP consts — REPLACES site ease) - -```js -// RULE: Capsule motion is opacity-led, never bouncy. Pill geometry is friendly enough that overshoot reads as childish. -// RULE: never use back/elastic/bounce on entry — Material standard out + drift sine.inOut is the entire motion vocabulary. -// RULE: decorative floating pills only drift (sine.inOut), never enter with translate. They fade. -// RULE: stat numerals count up with power2.out, never with steps() — Bodoni serifs do not look right at staircase intermediates. -const EASE = { - entry: "power2.out", // Material standard out (matches template's cubic-bezier(0.4, 0, 0.2, 1)) - emphasis: "expo.out", // for stat numeral count-ups + chart bar fills - exit: "power2.in", // fade-out exits, mirrors entry curve - drift: "sine.inOut", // ambient float on decorative pills -}; - -const DUR = { - snap: 0.18, // pill-pop, chip emphasis, mini-pill stagger - med: 0.5, // headline reveal, card entry, opacity fade (template uses 0.6s) - slow: 1.0, // chart-bar fill sweep, stat numeral count, orbit assembly -}; -``` - -### §E.5 Motion choreography - -- Allowed primitives: opacity fade, vertical translate ≤ 24px on entry, scale 0.96 → 1.0 on emphasis pills, width tween on bar fills, count-up on numerals, slow rotation drift (±2deg) on decorative pills. -- Forbidden gestures: back/elastic/bounce overshoots, horizontal sliding entrances, blur transitions, rotation entries above 8deg. -- Scene transitions default to 0.6s cross-fade (matches the template's `cubic-bezier(0.4, 0, 0.2, 1)` slide-to-slide). -- Stagger budget: 0.06s between sibling pills, 0.12s between cards in a grid, 0.18s between timeline steps. Keep total reveal under 1.2s per scene. -- Type-in-motion: headlines fade + translateY(16px → 0). Never word-by-word reveal — Bodoni is meant to be read as a single fashioned line, not assembled. -- The grain overlay never animates. It is a flat persistent layer at opacity 0.04, multiply blend, fixed inset 0, z-index 9999. - -## §G Voice transform recipe - -1. Keep articles and connectives (the / a / of / and) — Capsule is editorial, not telegraphic. -2. Sentence case for Bodoni display elements (headlines, stats, card titles, quote bodies). True title case only for proper nouns inside a headline. -3. UPPERCASE + 0.08em tracking for every Space-Grotesk small-text element (chips, labels, pill text, subtitles, attribution). This is the small-text identity signal — never break it. -4. Inside Bodoni quote bodies, wrap any phrase that wants emphasis in a `quote-highlight` pill (a candy-filled inline pill). Never bold; never italicize except via the Bodoni italic axis inside ``. -5. Strip the period from any chip / pill / subtitle / label. Keep the period on Bodoni quote bodies and paragraph copy. -6. Decorative floating pills carry a single uppercase word (5–9 chars). Pick atmospheric verbs and nouns ("VISION", "CREATE", "BEGIN", "NEXT"), not content-specific phrases. - -**Example:** - -- IN: `Higgsfield helps creative teams ship visual stories faster than ever before.` -- OUT (headline, Bodoni sentence case): `Where vision meets execution.` -- OUT (chip, Space Grotesk uppercase tracked): `CREATIVE STUDIO` -- OUT (decorative floating pill): `VISION` - -## §I Page-level CSS - -```css -/* ── Preset-native typography vars (loaded via preset-meta.chromeFonts.googleFontsHref). - * These let the doc chrome render in Bodoni Moda / Space Grotesk / Space Mono - * regardless of which brand DNA the preset is applied to. The §6 component preview - * and §T type-role atlas also read these via .preset-native-scope. - * - * Capsule has no script face — the script slot points at Bodoni Moda because - * italic emphasis rides the Bodoni opsz/italic axis inside , not a third - * family. Fallback chains end in a serif / sans / mono that still carries the - * editorial-pill register. Falling all the way to generic should never happen - * in practice. */ -:root { - --f-disp-native: "Bodoni Moda", "Playfair Display", "Fraunces", "Didot", "Georgia", serif; - --f-body-native: - "Space Grotesk", "Inter", "DM Sans", -apple-system, BlinkMacSystemFont, system-ui, sans-serif; - --f-script-native: "Bodoni Moda", "Playfair Display", "Fraunces", "Didot", "Georgia", serif; - --f-mono-native: - "Space Mono", "JetBrains Mono", "IBM Plex Mono", "Menlo", ui-monospace, monospace; -} - -/* .preset-native-scope: re-bind font tokens to preset-native families for §6 previews + §T atlas. */ -.preset-native-scope { - --font-display: var(--f-disp-native); - --font-body: var(--f-body-native); - --font-script: var(--f-script-native); - --font-mono: var(--f-mono-native); -} - -/* design.html preview — make the doc itself read as Capsule */ -body { - background: var(--canvas); -} -.ds-section h2, -.ds-section h3 { - font-family: var(--f-disp-native); - letter-spacing: -0.01em; -} -.ds-section h2::before { - content: ""; - display: inline-block; - width: 14px; - height: 14px; - border-radius: 9999px; - border: 2px solid var(--ink, #1a1a1a); - background: var(--brand-primary, #e85d4e); - margin-right: 0.6rem; - vertical-align: middle; -} -.ds-code, -pre.ds-code { - border-radius: 1.25rem; - border: 2px solid var(--ink, #1a1a1a); - box-shadow: 6px 6px 0 color-mix(in srgb, var(--ink) 8%, transparent); - background: var(--canvas); -} - -/* ── §T Type-role atlas. Container = 2rem-radius pill-card; rows by 2px ink hairlines. */ -.ds-trole-box { - display: flex; - flex-direction: column; - border: 2px solid var(--ink); - border-radius: 2rem; - background: #fff; - box-shadow: 8px 8px 0 color-mix(in srgb, var(--ink) 8%, transparent); - overflow: hidden; - margin-top: 24px; -} -.ds-trole-row { - padding: 28px 32px; - border-bottom: 2px solid color-mix(in srgb, var(--ink) 12%, transparent); -} -.ds-trole-row:last-child { - border-bottom: 0; -} -.ds-trole-sample { - min-width: 0; - overflow-wrap: anywhere; -} -@media (max-width: 960px) { - .ds-trole-row { - padding: 24px; - } -} - -/* ── Type-role samples. var(--font-*) resolves to brand DNA; decoration is preset-native. */ -.t-trole-display { - font-family: var(--font-display); - font-weight: 800; - font-size: clamp(48px, 8vw, 112px); - line-height: 0.9; - letter-spacing: -0.02em; - color: var(--ink); - max-width: 22ch; -} -.t-trole-closing-display { - font-family: var(--font-display); - font-weight: 800; - font-size: clamp(40px, 6vw, 80px); - line-height: 0.95; - letter-spacing: -0.03em; - color: var(--ink); - max-width: 22ch; -} -.t-trole-headline { - font-family: var(--font-display); - font-weight: 700; - font-size: clamp(32px, 4vw, 56px); - line-height: 1.05; - letter-spacing: -0.02em; - color: var(--ink); - max-width: 24ch; -} -.t-trole-section-headline { - font-family: var(--font-display); - font-weight: 700; - font-size: clamp(29px, 3.5vw, 48px); - line-height: 1.05; - letter-spacing: -0.01em; - color: var(--ink); - max-width: 26ch; -} -.t-trole-quote-display { - font-family: var(--font-display); - font-weight: 600; - font-size: clamp(26px, 3.5vw, 48px); - line-height: 1.35; - letter-spacing: -0.01em; - color: var(--ink); - max-width: 28ch; -} -.t-trole-card-headline { - font-family: var(--font-display); - font-weight: 700; - font-size: clamp(24px, 1.8vw, 28px); - line-height: 1.1; - color: var(--ink); -} -.t-trole-stat-number { - font-family: var(--font-display); - font-weight: 800; - font-size: clamp(32px, 3.5vw, 48px); - line-height: 1; - letter-spacing: -0.03em; - color: var(--brand-primary); -} -.t-trole-pill-text-md { - display: inline-block; - font-family: var(--font-body); - font-weight: 600; - font-size: 24px; - line-height: 1; - letter-spacing: 0.12em; - text-transform: uppercase; - color: var(--ink); - background: var(--brand-primary); - border: 2px solid var(--ink); - border-radius: 9999px; - padding: 1rem 2.5rem; - box-shadow: 6px 6px 0 color-mix(in srgb, var(--ink) 8%, transparent); -} -.t-trole-label { - font-family: var(--font-body); - font-weight: 500; - font-size: 24px; - line-height: 1; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--ink); -} -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/claude/caption-skin.html b/skills/faceless-explainer/phases/design-system/style-presets/claude/caption-skin.html deleted file mode 100644 index 5bc79b3613..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/claude/caption-skin.html +++ /dev/null @@ -1,247 +0,0 @@ - - - - - - Claude — Captions - - - - - - - - - -
- -
- - - - diff --git a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/button-pair.md b/skills/faceless-explainer/phases/design-system/style-presets/claude/components/button-pair.md deleted file mode 100644 index 885c0f9cc5..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/button-pair.md +++ /dev/null @@ -1,38 +0,0 @@ -```html -
- {LABEL} - {EYEBROW} -
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/chip-row.md b/skills/faceless-explainer/phases/design-system/style-presets/claude/components/chip-row.md deleted file mode 100644 index ffed7c526c..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/chip-row.md +++ /dev/null @@ -1,43 +0,0 @@ -```html -
- {LABEL} - {EYEBROW} - {SUBHEAD} -
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/code-window.md b/skills/faceless-explainer/phases/design-system/style-presets/claude/components/code-window.md deleted file mode 100644 index 231a27081f..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/code-window.md +++ /dev/null @@ -1,125 +0,0 @@ -```html - -
-
- - {LABEL} -
-
-
- 0102030405 -
-
# read the whole thing before answering
-def answer(question):
-    context = retrieve(question)
-    return model.reason(context, depth=3)
-# helpful, harmless, honest — in that order
-
-
ready
-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/coral-callout.md b/skills/faceless-explainer/phases/design-system/style-presets/claude/components/coral-callout.md deleted file mode 100644 index e699eef380..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/coral-callout.md +++ /dev/null @@ -1,83 +0,0 @@ -```html -
- {EYEBROW} -

{QUOTE}

-
- {LABEL} - {SUBHEAD} -
-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/editorial-column.md b/skills/faceless-explainer/phases/design-system/style-presets/claude/components/editorial-column.md deleted file mode 100644 index 1a17b8168f..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/editorial-column.md +++ /dev/null @@ -1,110 +0,0 @@ -```html -
-
- {EYEBROW} -

{HEADLINE}

-
-
-

{LETTER}{LEDE}

-

{SUBHEAD}

- -
-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/hero.md b/skills/faceless-explainer/phases/design-system/style-presets/claude/components/hero.md deleted file mode 100644 index 2aa044a08c..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/hero.md +++ /dev/null @@ -1,74 +0,0 @@ -```html -
- {EYEBROW} -

{HEADLINE}

-

{SUBHEAD}

- {LABEL} -
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/number-lockup.md b/skills/faceless-explainer/phases/design-system/style-presets/claude/components/number-lockup.md deleted file mode 100644 index dc22c3f4c0..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/number-lockup.md +++ /dev/null @@ -1,49 +0,0 @@ -```html -
-
{NUM}%
-

{LABEL}

-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/pull-quote.md b/skills/faceless-explainer/phases/design-system/style-presets/claude/components/pull-quote.md deleted file mode 100644 index 08e8922827..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/pull-quote.md +++ /dev/null @@ -1,48 +0,0 @@ -```html -
-
{QUOTE}
-
{AUTHOR}
-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/section-divider.md b/skills/faceless-explainer/phases/design-system/style-presets/claude/components/section-divider.md deleted file mode 100644 index d313aa5d40..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/section-divider.md +++ /dev/null @@ -1,52 +0,0 @@ -```html -
- {EYEBROW} -

{HEADLINE}

-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/spike-mark.md b/skills/faceless-explainer/phases/design-system/style-presets/claude/components/spike-mark.md deleted file mode 100644 index 1f0ca0eb08..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/spike-mark.md +++ /dev/null @@ -1,35 +0,0 @@ -```html -
- - {LABEL} -
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/stat-card.md b/skills/faceless-explainer/phases/design-system/style-presets/claude/components/stat-card.md deleted file mode 100644 index 001b0052e5..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/stat-card.md +++ /dev/null @@ -1,92 +0,0 @@ -```html -
- {EYEBROW} -
{NUM}M
-

{HEADLINE}

-

{LEDE}

-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/text-statement.md b/skills/faceless-explainer/phases/design-system/style-presets/claude/components/text-statement.md deleted file mode 100644 index 23ed8c0f65..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/claude/components/text-statement.md +++ /dev/null @@ -1,28 +0,0 @@ -```html -

{HEADLINE} {LABEL}{SUBHEAD}

- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/claude/preset.md b/skills/faceless-explainer/phases/design-system/style-presets/claude/preset.md deleted file mode 100644 index 28172f87fd..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/claude/preset.md +++ /dev/null @@ -1,610 +0,0 @@ -```preset-meta -{ - "name": "claude", - "label": "Claude", - "fingerprint": { - "surface": "warm-cream-editorial", - "shadow": "hairline-elevation-no-blur", - "border": "hairline-ink", - "voice": "serif-that-thinks", - "accent": "scarce-coral-voltage", - "motion": "quiet-considered" - }, - "match_signals": [ - { "kind": "hairline_border", "weight": 0.3 }, - { "kind": "serif_display", "weight": 0.25 }, - { "kind": "low_saturation", "weight": 0.15 } - ], - "best_for": ["editorial explainers", "research narratives", "considered product stories", "long-form concept pieces", "developer-facing walkthroughs"], - "avoid_for": ["high-energy hype promos", "neon / maximalist decks", "playful children's content", "dense real-time dashboards"], - "chromeFonts": { - "googleFontsHref": "https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,400;0,9..144,500;1,9..144,400&family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap", - "display": "Fraunces", - "body": "Inter", - "script": "Fraunces", - "mono": "JetBrains Mono" - }, - "palette": { - "primary": { "value": "#141413", "constraint": "warm near-black ink — the dominant fill and the floor everything stands on: every Fraunces headline, every Inter body word, every hairline border and 1px elevation shadow; never pure #000" }, - "accent": { "value": "#CC785C", "constraint": "terracotta coral — the scarce 'voltage'; reserved for the one primary CTA, the single full-bleed callout band, the inline link, and the ✱ spike mark; never body text, never a card fill" }, - "secondary": { "value": "#ECE3D4", "constraint": "deepest warm-paper tone (the pressed / strong tile) — claude is a warm cream→tile→coral family with no vibrant second hue, so secondary is the darkest paper step, not a fourth color; the navy product-chrome surface is a §B structural anchor (--cl-navy), declared there rather than as a brand slot" }, - "canvas": { "value": "#FAF9F5", "lock": "anchor", "constraint": "warm cream — the brand floor; NEVER pure white, never cool; the warm ground every scene stands on, tinted by brand DNA via color-mix() in §B but never replaced" }, - "surface": { "value": "#EFE9DE", "lock": "anchor", "constraint": "warm tile — one half-step darker than the cream floor; where feature cards live and content gathers; the demarcation is half a step, never a hard contrast" }, - "ink": { "alias": "primary" } - } -} -``` - -> `chromeFonts` makes the doc chrome render in the preset's native fonts; brand fonts still apply to §6 components. - -## §A Director's intent - -An editorial brand book come to life: warm cream paper, a serif that thinks, and a single coral that earns its place. The thesis is three colors held with discipline — **cream is the floor, ink is the voice, coral is the voltage** — and a fourth (navy) only where the product shows itself. The whole system reads as considered, literary, unhurried: it reasons in paragraphs, not slogans. - -Every surface is **warm cream** (`var(--canvas)`), never pure white and never cool gray. Content gathers on a **tile** surface half a step darker (`var(--cl-tile)`) — the demarcation is a half-step, never a hard contrast. Elevation is a **1px hairline** ink border at low alpha, optionally a single soft 1px shadow; there are no heavy drop shadows, no glows, no gradients on content. The restraint is the material. - -Three editorial voices, each in its own face: **Fraunces** carries every display moment — covers, headlines, pull-quotes, big stat numerals — set at large optical sizes with tight negative tracking; its **italic** is the expressive register (pull-quotes, emphasis). **Inter** carries body, leads, card titles, buttons, and every piece of UI chrome. **JetBrains Mono** carries the indexical layer — kickers, technical labels, the code window, status strips. Switching a voice's face collapses the register: a sans headline or a serif label reads as a different brand. - -**Brand DNA drives the chrome color, preset drives the structure.** `--brand-primary` maps to the ink role (every word, every border, every 1px shadow). `--brand-accent` maps to the coral voltage — rationed to at most ONE moment per scene (the CTA, OR the inline link, OR the full-bleed band). `--brand-secondary` maps to the warm-paper secondary slot: the pressed / strong tile tone shown in Brand DNA. The navy product-chrome surface (`--cl-navy`) where code and dark cards live is a structural anchor, not a brand slot. The cream + tile grounds are **technical-signature anchors** — without the warm paper the system reads as just another cool SaaS deck — declared in §B so brand DNA tints via `color-mix()` without losing the warm-paper reading. - -Motion is **quiet and considered** — short fades, no overshoot, no bounce. The brand reads first and answers second; the camera holds and the eye reads. Scene transitions are cross-dissolves; coral underlines may draw on; numbers count up; the code window types on line by line. - -**Density philosophy: editorial, not packed.** Claude reads as authoritative with ONE clear focal per scene and generous cream around it — a hero line and a single artifact, a stat and its caption, a quote and its attribution. A scene crammed edge-to-edge breaks the considered voice; a scene with a single centered line and acres of cream is exactly right. - -**Class prefix:** `cl-` across all components. - -## §B Decoration tokens (merge into design.html `:root`) - -Claude declares **structural** tokens here (warm tile surface, navy product-chrome family, ink scale, hairlines, hairline elevation, radii, spacing). The brand-aware contract: ink surfaces flow from `var(--brand-primary)`; the coral voltage flows from `var(--brand-accent)`; the warm-paper secondary role flows through the tile family. The navy chrome is declared as a fixed product-surface anchor. The cream + tile grounds are §8.2 anchor exceptions — declared because the warm-paper register is the preset's structural signature, not a palette choice; brand DNA tints them via `color-mix()`. - -```css -/* §8.2 exception: warm-paper anchors. Cream is "never the cool gray every other - model wears", tile is the half-step content surface — without them the system - collapses into a generic cool SaaS deck. Brand DNA tints via color-mix() so the - warm-paper register survives every brand palette. Declared once, never scattered. */ ---cl-cream: var(--canvas); /* the 60% floor */ ---cl-tile: color-mix( - in srgb, - var(--brand-primary) 5%, - #f3eee4 -); /* the 30% card surface, one warm step down */ ---cl-tile-strong: color-mix(in srgb, var(--brand-primary) 9%, #ece3d4); /* pressed / active tile */ - -/* Navy product-chrome family — the dark surface where the product shows itself. - Structural anchor, not a Brand DNA slot: secondary is the warm paper tone. */ ---cl-navy: #181715; /* code window / terminal / dark card ground */ ---cl-navy-soft: #1f1e1b; /* code-window body */ ---cl-navy-elev: #252320; /* title bars, status strips, elevated dark */ - -/* Ink scale — warm dark lightened toward the cream floor for body / muted / soft. - Derived from --ink so it shifts with brand DNA but stays warm. */ ---cl-ink-strong: color-mix(in srgb, var(--ink) 92%, var(--canvas)); ---cl-ink-body: color-mix(in srgb, var(--ink) 80%, var(--canvas)); ---cl-ink-muted: color-mix(in srgb, var(--ink) 58%, var(--canvas)); ---cl-ink-soft: color-mix(in srgb, var(--ink) 44%, var(--canvas)); - -/* Cream inks — text that sits on the navy product chrome */ ---cl-on-dark: var(--canvas); ---cl-on-dark-soft: color-mix(in srgb, var(--canvas) 62%, var(--cl-navy)); - -/* Functional accents — syntax tokens + status + button states. Decoration, NOT - brand hues (the brand stays the cream/coral/ink trinity). Coral states derive - from --brand-accent so they track a remixed coral. */ ---cl-coral-active: color-mix(in srgb, var(--brand-accent) 78%, var(--ink)); /* pressed coral */ ---cl-coral-disabled: color-mix(in srgb, var(--brand-accent) 18%, var(--cl-tile)); /* disabled */ ---cl-teal: #5db8a6; /* code strings · "ready" status */ ---cl-amber: #e8a55a; /* code numbers · "beta" badge */ ---cl-success: #5db872; /* connected status · checkmarks */ ---cl-warn: #c64545; /* refused / error status */ - -/* Hairlines — the only elevation language. Ink at low alpha on cream; cream at - low alpha on navy. */ ---cl-hairline: color-mix(in srgb, var(--ink) 12%, transparent); ---cl-hairline-soft: color-mix(in srgb, var(--ink) 6%, transparent); ---cl-hairline-dark: color-mix(in srgb, var(--cl-on-dark) 14%, transparent); ---cl-border-hairline: 1px solid var(--cl-hairline); - -/* Hairline elevation — one soft warm shadow, used rarely. NEVER a heavy drop. */ ---cl-shadow-card: - 0 1px 3px color-mix(in srgb, var(--ink) 8%, transparent), - 0 4px 16px color-mix(in srgb, var(--ink) 4%, transparent); - -/* Coral focus ring — for inputs / focused fields */ ---cl-focus-ring: 0 0 0 3px color-mix(in srgb, var(--brand-accent) 22%, transparent); - -/* Optional atmospheric gradient — coral deepening into ink. Used sparingly. */ ---brand-gradient: linear-gradient( - 135deg, - var(--brand-accent), - color-mix(in srgb, var(--brand-accent) 55%, var(--ink)) -); - -/* Radii — hierarchical, restrained (editorial, never pill-y except true pills) */ ---cl-radius-xs: 4px; ---cl-radius-sm: 6px; ---cl-radius-md: 8px; ---cl-radius-lg: 12px; ---cl-radius-xl: 16px; ---cl-radius-pill: 9999px; - -/* Spacing — 4px base */ ---cl-s-xs: 8px; ---cl-s-sm: 12px; ---cl-s-md: 16px; ---cl-s-lg: 24px; ---cl-s-xl: 32px; ---cl-s-xxl: 48px; - -/* The ✱ spike — the brand mark glyph, always coral */ ---cl-spike: "\2731"; -``` - -## §D Font pairing fallback (if brand fonts not on Google Fonts) - -Claude depends on the three-voice editorial pairing (optical serif display / humanist sans body / mono index). Fallbacks below are only used if the brand-derived face fails to load. - -- **display**: `'Fraunces'` · `'Tiempos Headline'` · `'Cormorant'` · Garamond · serif — opsz axis preferred -- **body**: `'Inter'` · `'Inter Tight'` · system-ui · `-apple-system` · sans-serif, wght 300–600 -- **mono**: `'JetBrains Mono'` · `'IBM Plex Mono'` · `'SFMono-Regular'` · Menlo · ui-monospace - -The serif is load-bearing: the entire thesis is "a serif reads like a person thought about it." If Fraunces fails, fall through to another high-contrast optical serif — never to a sans. Component CSS forces these families directly; brand DNA does not override them. - -## §T Type-role atlas (Phase 4b reads this to size text correctly) - -The atlas is the **sole authoring source** for non-component text. If a scene needs a `number-hero` numeral not covered by a §6 component, the worker reads role `number-hero` here and writes inline CSS from these values. Do NOT invent ad-hoc sizes — the editorial rhythm (Fraunces display / Inter body / mono index) collapses if sizes drift. - -```type-roles -[ - { - "id": "display-cover", - "family": "display", - "purpose": "cover hero — Fraunces 400, the brand line, ink on cream", - "px_min": 96, "px_max": 200, "weight": 400, "leading": "0.98", "tracking": "-0.035em", "case": "sentence", - "sample_html": "
Meet your thinking partner.
" - }, - { - "id": "display-section", - "family": "display", - "purpose": "section-divider headline, Fraunces 400 on cream or navy", - "px_min": 72, "px_max": 150, "weight": 400, "leading": "1.02", "tracking": "-0.028em", "case": "sentence", - "sample_html": "
A serif that thinks.
" - }, - { - "id": "display-italic", - "family": "script", - "purpose": "expressive register — Fraunces 400 italic, the editorial voice", - "px_min": 64, "px_max": 132, "weight": 400, "leading": "1.05", "tracking": "-0.018em", "case": "sentence", - "sample_html": "
An editorial AI.
" - }, - { - "id": "h2", - "family": "display", - "purpose": "standard slide headline — Fraunces 400 sentence case", - "px_min": 48, "px_max": 92, "weight": 400, "leading": "1.06", "tracking": "-0.02em", "case": "sentence", - "sample_html": "
Considered work, at the speed of typing.
" - }, - { - "id": "quote-pull", - "family": "script", - "purpose": "pull-quote — Fraunces 400 italic with a sans cite below", - "px_min": 48, "px_max": 100, "weight": 400, "leading": "1.12", "tracking": "-0.018em", "case": "sentence", - "sample_html": "
Read the whole thing before you say anything.House style
" - }, - { - "id": "number-hero", - "family": "display", - "purpose": "hero stat numeral — Fraunces 400 figure paired with a mono unit", - "px_min": 96, "px_max": 180, "weight": 400, "leading": "0.95", "tracking": "-0.03em", "case": "sentence", - "sample_html": "
200Ktokens of context
" - }, - { - "id": "card-title", - "family": "body", - "purpose": "feature / card title — Inter 500, ink on cream or tile", - "px_min": 28, "px_max": 48, "weight": 500, "leading": "1.25", "tracking": "-0.005em", "case": "sentence", - "sample_html": "
Connect the tools you already work in.
" - }, - { - "id": "lead", - "family": "body", - "purpose": "lede paragraph — Inter 400 large, set generously", - "px_min": 28, "px_max": 40, "weight": 400, "leading": "1.5", "tracking": "0", "case": "sentence", - "sample_html": "
A model that reads first and answers second — trained to be helpful, harmless, and honest, in that order.
" - }, - { - "id": "kicker", - "family": "mono", - "purpose": "eyebrow — JetBrains Mono 500 uppercase, ✱ spike prefix, coral mark", - "px_min": 24, "px_max": 28, "weight": 500, "leading": "1.2", "tracking": "0.16em", "case": "upper", - "sample_html": "
For the considered worker
" - }, - { - "id": "mono-label", - "family": "mono", - "purpose": "technical label / index strip — JetBrains Mono 500", - "px_min": 24, "px_max": 27, "weight": 500, "leading": "1.45", "tracking": "0.02em", "case": "sentence", - "sample_html": "
claude-opus · 200k ctx · vision · tools
" - }, - { - "id": "tag-upper", - "family": "body", - "purpose": "uppercase tracked tag — Inter 500", - "px_min": 24, "px_max": 27, "weight": 500, "leading": "1.4", "tracking": "0.18em", "case": "upper", - "sample_html": "
Research · Models · Pricing
" - }, - { - "id": "button", - "family": "body", - "purpose": "primary action — Inter 500 cream-on-coral", - "px_min": 24, "px_max": 28, "weight": 500, "leading": "1", "tracking": "0", "case": "sentence", - "sample_html": "
Start writing with Claude
" - }, - { - "id": "code", - "family": "mono", - "purpose": "code line — JetBrains Mono 400 with coral/teal/amber token spans, on navy", - "px_min": 24, "px_max": 32, "weight": 400, "leading": "1.6", "tracking": "0", "case": "sentence", - "sample_html": "
def answer(q): return claude.reason(q, n=3)
" - } -] -``` - -## §E Motion (GSAP consts — REPLACES site ease) - -```js -const EASE = { - entry: "power2.out", // soft arrival, no overshoot — a paragraph settling onto the page - emphasis: "power3.out", // a touch more authority on a coral-reveal / number-count beat - exit: "power2.in", // calm departure, no acceleration spike - drift: "sine.inOut", // the rare ambient drift (a glyph, a underline shimmer) -}; -const DUR = { - snap: 0.18, - med: 0.5, - slow: 0.9, -}; -// RULE: never back.out / elastic / bounce — the editorial register is quiet and -// considered. Overshoot breaks the "reads first" voice. -// RULE: scene transitions are cross-dissolves (DUR.med). NEVER slide, wipe, or -// zoom between scenes — they read as digital chrome and break the page feel. -// RULE: coral is the only thing that may "draw on" — an inline-link underline or a -// CTA edge reveals left→right (clip-path) at DUR.med. Everything else fades. -// RULE: numbers count up (Fraunces figure tween at DUR.slow, EASE.emphasis); the -// mono unit fades in at the end of the count. -// RULE: the code window types on line by line at DUR.snap per line; the terminal -// output appends one line at a time. Never animate individual glyphs elsewhere. -``` - -### §E.5 Motion choreography - -**Allowed primitives** - -- Soft fade-in + 8–14px y-drift on cards and text blocks (DUR.med, EASE.entry). -- Coral draw-on: an inline-link underline or CTA edge reveals via clip-path inset(0 100% 0 0) → inset(0 0 0 0) at DUR.med, EASE.entry. -- Number count-up: Fraunces figure tweens at DUR.slow with EASE.emphasis; the mono unit fades in at the end. -- Code type-on: line-by-line clip reveal, DUR.snap per line; terminal output lines append in sequence with EASE.entry. -- Hairline draw-on: a 1px rule extends left→right at DUR.med to introduce a section. -- The ✱ spike fades + scales 0.92 → 1 on a single emphasis beat (DUR.snap). Never spins. - -**Forbidden** - -- Slide-in / wipe / zoom between scenes (reads as digital chrome). -- Bounce / overshoot / elastic on any primary motion. -- Glyph-by-glyph reveals on Fraunces display — the serif is meant to be read as one set line, not assembled. -- Heavy drop-shadow grows, glow pulses, or gradient sweeps — the system has no light to emit. -- More than one coral motion per scene — the voltage is rationed in time as well as in space. - -**Stagger budget** - -180–260ms between elements. Total scene-in stagger ≤ 700ms. The eye should have time to read the kicker → headline → lede rhythm before the next block arrives. - -## §G Voice transform recipe (apply to the script's text) - -Take the scene's idea. Transform with: - -1. Display headlines: Fraunces, sentence case (NOT title case, NOT uppercase), 3–8 words. The period is optional — the line behaves like a book chapter title. Reach for the **italic** when the line is a stance or a definition ("An editorial AI."). -2. Kickers / eyebrows: JetBrains Mono UPPERCASE, 0.16em tracking, prefixed with the coral ✱ spike. Terse and indexical — a catalog tag, 2–5 words. -3. Coral is rationed: at most ONE coral moment per scene — the primary CTA, OR a single inline link inside a sentence, OR the full-bleed callout. Never two. Coral never sets a headline or a body run. -4. Numbers: a Fraunces figure with a JetBrains Mono unit suffix ("200K tokens", "$20 / month"). The figure is display; the unit is mono — never set the unit in the serif. -5. Body & leads: Inter, sentence case, full sentences with their qualifiers kept. The voice "reads first" — no hype, no exclamation, no telegraphed fragments. A lead may run a sentence longer than a slogan would. -6. Pull-quotes: Fraunces italic, with a small Inter uppercase cite below. The quote is the only place a long line is allowed to breathe across the scene. -7. Code & labels: JetBrains Mono. Keywords in coral, strings in teal, numbers in amber — the same palette outside the code window, so the chrome is not a foreign language. - -**Example:** - -- IN: `Our AI assistant connects to your tools and helps your team work faster.` -- OUT: kicker=`✱ FOR THE CONSIDERED TEAM` / headline=`The work, in the tools you already use.` / lead=`A model that reads the thread, the doc, and the repo before it answers — so the answer sounds like someone who read them.` / cta=`Start a project` - -## §I Page-level CSS (overrides design.html's neutral chrome — makes the doc itself read as Claude) - -```css -/* ── Preset-native typography vars (loaded via preset-meta.chromeFonts.googleFontsHref). - * These let the doc chrome render in Fraunces + Inter + JetBrains Mono regardless of - * which brand DNA the preset is applied to. The §6 component preview and §T type-role - * atlas also read these via .preset-native-scope. - * - * Fallback chains end in a face that still carries the preset's vibe — a high-contrast - * optical serif for display (never a sans), a humanist sans for body, a plex/menlo mono - * for the index layer. */ -:root { - --f-disp-native: "Fraunces", "Tiempos Headline", "Cormorant", Garamond, "Times New Roman", serif; - --f-body-native: "Inter", "Inter Tight", -apple-system, BlinkMacSystemFont, system-ui, sans-serif; - --f-script-native: "Fraunces", "Tiempos Headline", "Cormorant", Garamond, serif; - --f-mono-native: - "JetBrains Mono", "IBM Plex Mono", "SFMono-Regular", Menlo, ui-monospace, monospace; -} - -/* .preset-native-scope: re-bind brand DNA font tokens to preset-native families. - * Wraps §6 component previews and §T type-role samples so var(--font-*) resolves to - * Fraunces / Inter / JetBrains Mono regardless of the brand DNA tokens emitted in - * :root. The paste-ready component source is untouched — it forces these families - * directly. */ -.preset-native-scope { - --font-display: var(--f-disp-native); - --font-body: var(--f-body-native); - --font-script: var(--f-script-native); - --font-mono: var(--f-mono-native); -} - -body { - background: var(--canvas); - position: relative; - color: var(--brand-primary); - font-family: - "Inter", - -apple-system, - BlinkMacSystemFont, - system-ui, - sans-serif; -} -.title-card { - background: var(--canvas); - border-bottom: var(--cl-border-hairline); - padding: 96px 0 80px; -} -.title-display { - font-family: "Fraunces", "Tiempos Headline", Garamond, serif; - font-weight: 400; - letter-spacing: -0.035em; - color: var(--brand-primary); -} -.brand-name { - color: var(--brand-primary); - font-weight: 500; -} -.style-name { - font-family: "Fraunces", "Tiempos Headline", Garamond, serif; - font-weight: 400; - font-style: italic; - color: var(--brand-accent); - display: inline-block; -} -.ds-section { - border-top: var(--cl-border-hairline); - padding: 80px 0; -} -h2 { - font-family: "Fraunces", "Tiempos Headline", Garamond, serif; - font-weight: 400; - letter-spacing: -0.025em; - color: var(--brand-primary); -} -.eyebrow { - font-family: "JetBrains Mono", ui-monospace, monospace; - text-transform: uppercase; - letter-spacing: 0.16em; - color: var(--cl-ink-muted); - font-weight: 500; -} -.type-card, -.voice-pair, -.comp-card { - background: var(--cl-tile) !important; - border: var(--cl-border-hairline) !important; - border-radius: var(--cl-radius-lg) !important; - box-shadow: none !important; -} -/* dna-swatch keeps its inline brand-color background — only restyle border/radius */ -.dna-swatch { - border: var(--cl-border-hairline) !important; - border-radius: var(--cl-radius-lg) !important; - box-shadow: none !important; -} -.comp-head { - background: var(--cl-tile-strong) !important; - color: var(--cl-ink-muted) !important; - border-bottom: var(--cl-border-hairline) !important; - font-family: "JetBrains Mono", ui-monospace, monospace !important; - text-transform: uppercase; - letter-spacing: 0.12em; -} -.ds-code { - background: var(--cl-navy) !important; - border: 1px solid var(--cl-hairline-dark) !important; - border-radius: var(--cl-radius-md) !important; - color: var(--cl-on-dark) !important; - font-family: "JetBrains Mono", ui-monospace, monospace !important; -} - -/* ── §T Type-role atlas. Container = a single tile card with the hairline border. - * Each row is a single-column entry, padded only — no inner grid, no eyebrow column. - * Each .t-trole-* class encodes the role's family / size / weight / leading / tracking - * / case / decoration. Family selectors use var(--font-*) tokens so the atlas renders - * in the preset-native fonts via .preset-native-scope; decoration (color) stays on - * claude tokens. */ -.ds-trole-box { - display: flex; - flex-direction: column; - border: var(--cl-border-hairline); - border-radius: var(--cl-radius-lg); - background: var(--cl-tile); - box-shadow: none; - overflow: hidden; - margin-top: 24px; -} -.ds-trole-row { - padding: 28px 32px; - border-bottom: var(--cl-border-hairline); -} -.ds-trole-row:last-child { - border-bottom: 0; -} -.ds-trole-sample { - min-width: 0; - overflow-wrap: anywhere; -} -@media (max-width: 960px) { - .ds-trole-row { - padding: 24px; - } -} - -/* ── Type-role samples. Each .t-trole-* mirrors a §T entry but uses - * var(--font-display/body/script/mono); decoration (color) is claude-native via - * tokens — zero raw hex. */ -.t-trole-display-cover { - font-family: var(--font-display); - font-weight: 400; - font-size: clamp(64px, 10vw, 200px); - line-height: 0.98; - letter-spacing: -0.035em; - color: var(--brand-primary); -} -.t-trole-display-section { - font-family: var(--font-display); - font-weight: 400; - font-size: clamp(56px, 8vw, 150px); - line-height: 1.02; - letter-spacing: -0.028em; - color: var(--brand-primary); -} -.t-trole-display-italic { - font-family: var(--font-script); - font-weight: 400; - font-style: italic; - font-size: clamp(48px, 7vw, 132px); - line-height: 1.05; - letter-spacing: -0.018em; - color: var(--cl-ink-strong); -} -.t-trole-h2 { - font-family: var(--font-display); - font-weight: 400; - font-size: clamp(40px, 5vw, 92px); - line-height: 1.06; - letter-spacing: -0.02em; - color: var(--brand-primary); -} -.t-trole-quote-pull { - font-family: var(--font-script); - font-weight: 400; - font-style: italic; - font-size: clamp(40px, 5vw, 100px); - line-height: 1.12; - letter-spacing: -0.018em; - color: var(--cl-ink-strong); - max-width: 22ch; -} -.t-trole-quote-pull cite { - display: block; - margin-top: 18px; - font-style: normal; - font-family: var(--font-body); - font-weight: 500; - font-size: 24px; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--cl-ink-muted); -} -.t-trole-number-hero { - font-family: var(--font-display); - font-weight: 400; - font-size: clamp(80px, 9vw, 180px); - line-height: 0.95; - letter-spacing: -0.03em; - color: var(--brand-primary); -} -.t-trole-number-hero span { - font-family: var(--font-mono); - font-weight: 500; - font-size: 0.18em; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--cl-ink-muted); - margin-left: 0.12em; -} -.t-trole-card-title { - font-family: var(--font-body); - font-weight: 500; - font-size: clamp(28px, 2.6vw, 48px); - line-height: 1.25; - letter-spacing: -0.005em; - color: var(--brand-primary); -} -.t-trole-lead { - font-family: var(--font-body); - font-weight: 400; - font-size: clamp(28px, 2.4vw, 40px); - line-height: 1.5; - color: var(--cl-ink-body); - max-width: 32ch; -} -.t-trole-kicker { - font-family: var(--font-mono); - font-weight: 500; - font-size: clamp(24px, 1.5vw, 28px); - line-height: 1.2; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--cl-ink-muted); -} -.t-trole-kicker span { - color: var(--brand-accent); - margin-right: 0.4em; -} -.t-trole-mono-label { - font-family: var(--font-mono); - font-weight: 500; - font-size: clamp(24px, 1.4vw, 27px); - line-height: 1.45; - letter-spacing: 0.02em; - color: var(--cl-ink-strong); -} -.t-trole-tag-upper { - font-family: var(--font-body); - font-weight: 500; - font-size: clamp(24px, 1.4vw, 27px); - line-height: 1.4; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--cl-ink-strong); -} -.t-trole-button { - display: inline-block; - font-family: var(--font-body); - font-weight: 500; - font-size: clamp(24px, 1.6vw, 28px); - line-height: 1; - color: var(--cl-on-dark); - background: var(--brand-accent); - padding: 18px 32px; - border-radius: var(--cl-radius-md); -} -.t-trole-code { - font-family: var(--font-mono); - font-weight: 400; - font-size: clamp(24px, 2vw, 32px); - line-height: 1.6; - color: var(--cl-on-dark); - background: var(--cl-navy); - padding: 14px 20px; - border-radius: var(--cl-radius-sm); - display: inline-block; -} -.t-trole-code .k { - color: var(--brand-accent); -} -.t-trole-code .n { - color: var(--cl-amber); -} -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/caption-skin.html b/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/caption-skin.html deleted file mode 100644 index 1736d10c6b..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/caption-skin.html +++ /dev/null @@ -1,254 +0,0 @@ - - - - - - Pin & Paper — Captions - - - - - - - - - -
- -
- - - - diff --git a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/chip.md b/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/chip.md deleted file mode 100644 index fe7acdb70c..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/chip.md +++ /dev/null @@ -1,37 +0,0 @@ -```html -{LABEL} - - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/divider-loud.md b/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/divider-loud.md deleted file mode 100644 index a4d21e22a7..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/divider-loud.md +++ /dev/null @@ -1,33 +0,0 @@ -```html -
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/hero.md b/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/hero.md deleted file mode 100644 index 95ee199bbd..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/hero.md +++ /dev/null @@ -1,53 +0,0 @@ -```html -
- {EYEBROW} -

{HEADLINE}

-

{SUBHEAD}

-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/paper-grain-overlay.md b/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/paper-grain-overlay.md deleted file mode 100644 index 02a265cc52..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/paper-grain-overlay.md +++ /dev/null @@ -1,39 +0,0 @@ -```html - - - - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/pill-yes.md b/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/pill-yes.md deleted file mode 100644 index b42b369052..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/pill-yes.md +++ /dev/null @@ -1,50 +0,0 @@ -```html -{LABEL} - - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/pinned-card.md b/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/pinned-card.md deleted file mode 100644 index 8ee2474019..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/pinned-card.md +++ /dev/null @@ -1,78 +0,0 @@ -```html -
- - {KICKER} -

{HEADLINE}

-

{LEDE}

-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/process-step.md b/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/process-step.md deleted file mode 100644 index 1474d2cc7e..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/process-step.md +++ /dev/null @@ -1,68 +0,0 @@ -```html -
- - 1 -

{HEADLINE}

-

{SUBHEAD}

-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/quote-panel.md b/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/quote-panel.md deleted file mode 100644 index 845939b302..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/quote-panel.md +++ /dev/null @@ -1,61 +0,0 @@ -```html -
- -
-
{QUOTE}
-
{AUTHOR}
-
-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/safety-pin.md b/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/safety-pin.md deleted file mode 100644 index 36fe6b8cb2..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/safety-pin.md +++ /dev/null @@ -1,61 +0,0 @@ -```html - - - - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/scribble-note.md b/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/scribble-note.md deleted file mode 100644 index 3053e42ee3..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/scribble-note.md +++ /dev/null @@ -1,51 +0,0 @@ -```html -

- - {QUOTE} -

- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/stamp.md b/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/stamp.md deleted file mode 100644 index 9f6beefb9b..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/stamp.md +++ /dev/null @@ -1,44 +0,0 @@ -```html -{LABEL} - - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/stat-counter.md b/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/stat-counter.md deleted file mode 100644 index 42398a5758..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/components/stat-counter.md +++ /dev/null @@ -1,78 +0,0 @@ -```html -
-
- {NUM}M - -
-

{HEADLINE}

-

{LEDE}

-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/preset.md b/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/preset.md deleted file mode 100644 index fe50a1d0ce..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/pin-and-paper/preset.md +++ /dev/null @@ -1,614 +0,0 @@ -```preset-meta -{ - "name": "pin-and-paper", - "label": "Pin & Paper", - "fingerprint": { - "surface": "yellow-paper-with-grain", - "shadow": "hard-offset-ink-zero-blur", - "border": "hairline-ink", - "voice": "field-notebook-handwritten", - "motion": "quiet-considered", - "density": "populated-card-grid" - }, - "match_signals": [ - { "kind": "hairline_border", "weight": 0.3 }, - { "kind": "shadow_zero_blur", "weight": 0.25 }, - { "kind": "low_saturation", "weight": 0.1 } - ], - "best_for": ["qualitative research", "founder reflections", "longform brand stories", "hand-crafted decks", "literary brands"], - "avoid_for": ["digital-native polished", "rigorously data-driven", "corporate fintech", "high-energy launches"], - "chromeFonts": { - "googleFontsHref": "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Caveat:wght@500;600;700&family=DM+Mono:wght@400;500&display=swap", - "display": "Space Grotesk", - "body": "Space Grotesk", - "script": "Caveat", - "mono": "DM Mono" - }, - "palette": { - "primary": { "value": "#1F3A8A", "constraint": "deep desaturated ink — reads near-black on paper; carries every text fill, border, divider, pin illustration, and hard offset shadow" }, - "accent": { "value": "#C2342B", "constraint": "single vivid warm — used in at most two spots (the rotated rubber stamp + the negative pill); never as body text, card fill, or chip" }, - "secondary": { "value": "#C9A66B", "constraint": "muted earthy third tone (kraft / olive / orange from the source palette); structurally optional, used only when a scene needs a rare third tone" }, - "canvas": { "value": "#EFE56A", "lock": "anchor", "constraint": "warm saturated legal-pad yellow — the page ground; brand DNA tints it via color-mix() in §B, never replaces it, so the paper register survives every brand palette" }, - "surface": { "value": "#F8F1D6", "lock": "anchor", "constraint": "off-white cream — the pinned-card fill; brand DNA tints via color-mix() in §B, never replaces" }, - "ink": { "alias": "primary" } - } -} -``` - -> `chromeFonts` makes the doc chrome render in the preset's native fonts; brand fonts still apply to §6 components. - -## §A Director's intent - -Field notebook pinned to a corkboard, not a polished deck. Every surface is **yellow legal-pad paper** with a non-optional fractal-noise grain — without the texture the system collapses into flat cartoon-yellow. Cards are **cream paper pinned to the page** with a 1.5px hairline ink border, 4px micro-radius, and a hard ink-blue offset shadow (5–8px, zero blur). The shadow is the only depth language. - -Three editorial voices, each in its own face: **Space Grotesk 700** carries every printed headline (negative letter-spacing, mixed case); **Caveat** carries every handwritten moment — marginal notes, step numerals, "me" voice annotations; **DM Mono uppercase** carries every archival tag — top-chrome lockup, footer meta, date strips. Switching a voice's face collapses the register. - -**Brand DNA drives the chrome color, preset drives the structure.** `--brand-primary` maps to the ink-blue role (text, borders, dividers, pin illustrations, hard offset shadow). `--brand-accent` maps to the cinnabar-red stamp role (used in exactly two places: the rotated rubber stamp and the negative pill). The yellow paper base is a **technical signature anchor** — without it the layered radial gradients, grain overlay, and cream-on-yellow card contrast all fail; declared once in §B as `--anchor-paper-yellow` / `--anchor-cream` so brand DNA can tint via `color-mix()` without losing the paper reading. - -Motion is **quiet and considered** — short fades, no overshoot, no bounce. The hand-pinned aesthetic doesn't want kinetic theatrics; the camera holds and the eye reads. Ambient pin rotations drift on `sine.inOut` like paper settling. - -**Density philosophy: populated, not sparse.** Pin & Paper reads as authoritative when 3–6 cards are pinned across the page, each carrying a heading + body + marginal note. A scene with one centered headline and otherwise empty space reads as broken. - -## §B Decoration tokens (merge into design.html `:root`) - -Pin & Paper declares **structural** tokens here (hairline border, hard offset shadow, micro-radius, pin/paper anchors). The brand-aware contract: ink-blue surfaces flow from `var(--brand-primary)`; the cinnabar-red stamp flows from `var(--brand-accent)`. The two paper-anchor hexes below are §8.2 exceptions — declared because the yellow-paper + cream-card contrast is the preset's structural signature, not a palette choice; brand DNA tints these anchors via `color-mix()` so the warm-paper register survives every brand palette. - -```css -/* §8.2 exception: paper-anchor tokens. The yellow legal-pad surface and the - cream card fill are the structural signature — without them the layered - radial gradients, grain overlay, and cream-on-yellow card contrast all - fail. Brand DNA tints these via color-mix() so the paper register survives - every brand palette (dark / muted / pastel). Declared once here, never - scattered into component CSS. */ ---anchor-paper-yellow: #efe56a; /* saturated cadmium yellow — the page */ ---anchor-cream: #f8f1d6; /* off-white ivory — the cards */ - -/* Paper surfaces — brand-tinted from the anchor so they shift with the site - without losing the warm-paper reading. */ ---surface-paper: color-mix(in srgb, var(--brand-primary) 6%, var(--anchor-paper-yellow)); ---surface-paper-2: color-mix( - in srgb, - var(--brand-primary) 4%, - color-mix(in srgb, var(--anchor-paper-yellow) 70%, white) -); ---surface-paper-3: color-mix( - in srgb, - var(--brand-primary) 8%, - color-mix(in srgb, var(--anchor-paper-yellow) 85%, black 8%) -); ---surface-cream: color-mix(in srgb, var(--brand-primary) 4%, var(--anchor-cream)); - -/* The signature hard offset shadow — solid ink, zero blur. Three sizes for - compact / standard / hero cards. Color flows from brand-primary. */ ---shadow-pin-compact: 4px 5px 0 0 var(--brand-primary); ---shadow-pin-standard: 5px 6px 0 0 var(--brand-primary); ---shadow-pin-hero: 8px 9px 0 0 var(--brand-primary); - -/* Hairline + dashed borders */ ---border-hairline: 1.5px solid var(--brand-primary); ---border-dashed: 1.5px dashed color-mix(in srgb, var(--brand-primary) 45%, transparent); ---border-stamp: 3px solid var(--brand-accent); - -/* Micro-radius — the printed-corner signal. Larger values collapse into UI. */ ---radius-card: 4px; ---radius-pill: 999px; - -/* Edge / card padding */ ---pad-edge: 64px; ---pad-top: 110px; ---pad-bottom: 90px; ---card-pad: 28px; ---card-pad-lg: 36px 28px 28px; - -/* Grid gaps */ ---gap-card-sm: 22px; ---gap-card-md: 28px; ---gap-card-lg: 32px; - -/* Off-axis tilts — the pinned-askew signal. Used on alternate cards, pins, - scribbles, stamp. Never apply more than one tilt to a single element. */ ---tilt-pin: -10deg; ---tilt-pin-alt: 14deg; ---tilt-card-askew: 0.9deg; ---tilt-scribble: -2deg; ---tilt-stamp: -4deg; - -/* Paper-grain overlay — fractal noise data-URI, multiply blend. Non-optional - on every scene. The blend mode flips to screen on ink-blue surfaces. */ ---paper-grain: url("data:image/svg+xml;utf8,"); -``` - -## §D Font pairing fallback (if brand fonts not on Google Fonts) - -Pin & Paper depends on the three-voice editorial pairing (printed display / handwritten / archival). Fallbacks below are only used if the brand-derived face fails to load. - -- **display**: `'Space Grotesk'` · `'Inter Tight'` · `'Manrope'` wght 700 -- **body**: `'Space Grotesk'` · `'Inter'` · `'IBM Plex Sans'` wght 400 -- **mono**: `'DM Mono'` · `'JetBrains Mono'` · `'IBM Plex Mono'` wght 500 - -The handwritten layer (Caveat) is non-substitutable — if Caveat fails to load it falls through to `cursive`, which varies by OS. Component CSS forces Caveat directly; brand DNA does not override it. - -## §T Type-role atlas (Phase 4b reads this to size text correctly) - -The atlas is the **sole authoring source** for non-component text. If a scene needs a `number-hero` numeral that isn't covered by §6 components, the worker reads role `number-hero` here and writes inline CSS from these values. Do NOT invent ad-hoc sizes — the three-voice editorial rhythm (print headline / handwritten scribble / mono archival tag) collapses if sizes drift. - -```type-roles -[ - { - "id": "display-cover", - "family": "display", - "purpose": "cover hero — Space Grotesk 700 mixed case, ink-blue on yellow paper", - "px_min": 96, "px_max": 196, "weight": 700, "leading": "1.08", "tracking": "-0.04em", "case": "mixed", - "sample_html": "
{BRAND_NAME}
" - }, - { - "id": "display-section", - "family": "display", - "purpose": "section-divider headline on ink-blue surface (paper-yellow text)", - "px_min": 96, "px_max": 168, "weight": 700, "leading": "1.05", "tracking": "-0.04em", "case": "mixed", - "sample_html": "
Section title.
" - }, - { - "id": "number-hero", - "family": "display", - "purpose": "hero stat numeral — Space Grotesk 700, ink-blue, paired with Caveat unit suffix", - "px_min": 96, "px_max": 168, "weight": 700, "leading": "0.85", "tracking": "-0.04em", "case": "mixed", - "sample_html": "
63%
" - }, - { - "id": "h1", - "family": "display", - "purpose": "closing CTA headline / chart-slide headline", - "px_min": 84, "px_max": 130, "weight": 700, "leading": "1.05", "tracking": "-0.035em", "case": "mixed", - "sample_html": "
One canvas. Everyone home.
" - }, - { - "id": "h2", - "family": "display", - "purpose": "standard slide headline — Space Grotesk 700 mixed case", - "px_min": 64, "px_max": 96, "weight": 700, "leading": "1.05", "tracking": "-0.03em", "case": "mixed", - "sample_html": "
Designed together.
" - }, - { - "id": "card-h3", - "family": "display", - "purpose": "pinned-card title — Space Grotesk 700, ink-blue on cream", - "px_min": 28, "px_max": 38, "weight": 700, "leading": "1.02", "tracking": "-0.02em", "case": "mixed", - "sample_html": "
Field report
" - }, - { - "id": "quote-text", - "family": "display", - "purpose": "pull-quote body — Space Grotesk 500 mixed case", - "px_min": 36, "px_max": 50, "weight": 500, "leading": "1.1", "tracking": "-0.02em", "case": "mixed", - "sample_html": "
The work gets simpler as the team gets braver.
" - }, - { - "id": "scribble-lg", - "family": "script", - "purpose": "process step numeral / large hand-script accent — Caveat 700, ink-blue", - "px_min": 60, "px_max": 70, "weight": 700, "leading": "0.9", "tracking": "0", "case": "sentence", - "sample_html": "
3
" - }, - { - "id": "scribble-sm", - "family": "script", - "purpose": "marginal note / 'me' voice annotation — Caveat 600, slight rotation", - "px_min": 32, "px_max": 38, "weight": 600, "leading": "1.05", "tracking": "0", "case": "sentence", - "sample_html": "
finally — one canvas, everyone home
" - }, - { - "id": "label-top", - "family": "mono", - "purpose": "top-chrome brand lockup / archival tag — DM Mono 500 uppercase", - "px_min": 24, "px_max": 26, "weight": 500, "leading": "1.2", "tracking": "0.12em", "case": "upper", - "sample_html": "
Field report · Vol. 01
" - }, - { - "id": "label-footer", - "family": "mono", - "purpose": "footer chrome — DM Mono 500 uppercase, 65% opacity", - "px_min": 24, "px_max": 25, "weight": 500, "leading": "1.2", "tracking": "0.14em", "case": "upper", - "sample_html": "
Source · Internal study, 2026
" - }, - { - "id": "stamp-mark", - "family": "mono", - "purpose": "cinnabar-red rubber stamp — 3px solid red border, red mono uppercase, rotated -4deg", - "px_min": 24, "px_max": 26, "weight": 500, "leading": "1", "tracking": "0.18em", "case": "upper", - "sample_html": "
Received
" - }, - { - "id": "pill-yes", - "family": "script", - "purpose": "affirmative pill — solid ink fill with Caveat paper-yellow text inside a 999px pill", - "px_min": 24, "px_max": 28, "weight": 600, "leading": "1", "tracking": "0", "case": "sentence", - "sample_html": "
Yes
" - }, - { - "id": "pill-no", - "family": "mono", - "purpose": "negative pill — red mono uppercase inside a red-bordered transparent 999px pill", - "px_min": 24, "px_max": 26, "weight": 500, "leading": "1", "tracking": "0.14em", "case": "upper", - "sample_html": "
No
" - } -] -``` - -## §E Motion (GSAP consts — REPLACES site ease) - -```js -const EASE = { - entry: "power2.out", // soft arrival, no overshoot — paper settling onto the page - emphasis: "power3.out", // a touch more authority on pin-reveal / stamp-slam beats - exit: "power2.in", // calm departure, no acceleration spike - drift: "sine.inOut", // ambient pin / scribble tilt drift -}; -const DUR = { - snap: 0.18, - med: 0.5, - slow: 0.95, -}; -// RULE: never back.out / elastic / bounce — the field-notebook register is -// quiet and considered. Overshoot breaks the "paper settling" feel. -// RULE: pin illustrations and stamps may rotate within ±2° on entry — never -// counter-rotate to 0°. The off-axis tilt is the system's identity. -// RULE: scene transitions are short cross-dissolves (DUR.med). NEVER slide, -// wipe, or zoom — they read as digital chrome and break the paper aesthetic. -// RULE: scribble entries should write-on (clip-path reveal left→right) at -// DUR.med with EASE.entry. Don't fade — fade is for printed type. -``` - -### §E.5 Motion choreography - -**Allowed primitives** - -- Soft fade-in + 8–12px y-drift on cards (DUR.med, EASE.entry). -- Pin-rotate-in: pin illustration arrives from −20° / +25° tilt and settles to its rest tilt at DUR.med, EASE.entry. -- Stamp-slam: stamp scales 1.08 → 1 with rotation locked at −4° (DUR.snap, EASE.emphasis) — single percussive beat. -- Scribble write-on: clip-path inset(0 100% 0 0) → inset(0 0 0 0) at DUR.med, EASE.entry. -- Stat-counter numeric tween at DUR.slow with EASE.emphasis. Caveat `` suffix fades in at the end of the count. -- Ambient pin drift: rotation oscillation ±1.5° on a 4–6s sine.inOut loop. Subtle, not theatrical. - -**Forbidden** - -- Slide-in / wipe / zoom-between-scenes (reads as digital chrome). -- Bounce / overshoot / elastic on any primary motion. -- Sub-pixel positions — keep transforms on integer pixel offsets. -- Rotating a pin or scribble back to 0° on rest. The off-axis tilt is the identity. -- Counter-rotating the rubber stamp away from −4°. -- Particle systems / sparkles / glow filters — paper doesn't emit light. - -**Stagger budget** - -200–280ms between elements. Total scene-in stagger ≤ 700ms. The eye should have time to read each card's pin → heading → body → margin-note rhythm before the next one arrives. - -## §G Voice transform recipe (apply to brand's voice from §1 DNA) - -Take the brand's product description / value prop. Transform with: - -1. Hero headlines: 2–5 words, mixed case (NOT uppercase), Space Grotesk 700 with negative letter-spacing. Period optional — the cover behaves like a book title. -2. Top-chrome lockups, dates, source attributions: DM Mono UPPERCASE with 0.12–0.18em tracking. Terse, indexical — pretend it's a catalog-card tag. -3. Stamps: UPPERCASE DM Mono, 1–2 words ("CONFIDENTIAL", "RECEIVED", "DRAFT 04"). Always rotated −4°. -4. Marginal notes (Caveat scribble): sentence case, 4–10 words, conversational. This is the "me" voice — write as if annotating someone else's document. Use `word` for hand-drawn underline emphasis. -5. Step numerals: Caveat hand-script (1, 2, 3 — not "Step 1"). The script numeral is the system's ordering voice; never substitute a numeric font. -6. Card bodies: Space Grotesk sentence case, terse, full sentences. Never set body in Caveat — the script is for marginal notes, never paragraphs. - -**Example:** - -- IN: `Figma helps teams design products collaboratively in real time` -- OUT: hero=`Designed together.` / chip=`FIELD REPORT 04` / stamp=`RECEIVED` / margin-note=`finally — one canvas, everyone home` - -## §I Page-level CSS (overrides design.html's neutral chrome — makes the doc itself read as pin-and-paper) - -```css -/* ── Preset-native typography vars (loaded via preset-meta.chromeFonts.googleFontsHref). - * These let the doc chrome render in Space Grotesk + Caveat + DM Mono regardless - * of which brand DNA the preset is applied to. The §6 component preview and §T - * type-role atlas also read these via .preset-native-scope. - * - * Fallback chains end in a face that still carries the preset's vibe (Inter / - * Manrope for the printed display + body; system cursives for the script; - * IBM Plex Mono / Menlo for the archival mono). Caveat in particular is - * non-substitutable — its absence falls through to `cursive` which varies - * widely by OS, so the chain stays short and intentional. */ -:root { - --f-disp-native: - "Space Grotesk", "Inter Tight", "Manrope", -apple-system, BlinkMacSystemFont, system-ui, - sans-serif; - --f-body-native: - "Space Grotesk", "Inter", "IBM Plex Sans", -apple-system, BlinkMacSystemFont, system-ui, - sans-serif; - --f-script-native: "Caveat", "Kalam", "Shadows Into Light", "Brush Script MT", cursive; - --f-mono-native: - "DM Mono", "JetBrains Mono", "IBM Plex Mono", "Space Mono", "Menlo", ui-monospace, monospace; -} - -/* .preset-native-scope: re-bind brand DNA font tokens to preset-native families. - * Wraps §6 component previews and §T type-role samples so - * var(--font-*) resolves to Space Grotesk / Caveat / DM Mono regardless of the - * brand DNA tokens emitted in :root. The paste-ready component source is - * untouched — Phase 4b still grep + paste original `var(--font-display)` - * tokens, which resolve to brand DNA at scene-render time. */ -.preset-native-scope { - --font-display: var(--f-disp-native); - --font-body: var(--f-body-native); - --font-script: var(--f-script-native); - --font-mono: var(--f-mono-native); -} - -body { - background: var(--surface-paper); - position: relative; - color: var(--brand-primary); - font-family: "Space Grotesk", "Inter", sans-serif; -} -body::before { - /* Paper grain on design.html itself */ - content: ""; - position: fixed; - inset: 0; - background-image: var(--paper-grain); - opacity: 0.35; - mix-blend-mode: multiply; - pointer-events: none; - z-index: 9999; -} -.title-card { - background: var(--surface-paper); - border-bottom: var(--border-hairline); - padding: 96px 0 80px; -} -.title-display { - font-family: "Space Grotesk", sans-serif; - font-weight: 700; - letter-spacing: -0.04em; - color: var(--brand-primary); -} -.brand-name { - color: var(--brand-primary); - font-weight: 700; -} -.style-name { - font-family: "Caveat", cursive; - font-weight: 700; - color: var(--brand-primary); - transform: rotate(-2deg); - display: inline-block; -} -.ds-section { - border-top: var(--border-dashed); - padding: 80px 0; -} -h2 { - font-family: "Space Grotesk", sans-serif; - font-weight: 700; - letter-spacing: -0.03em; - color: var(--brand-primary); -} -.eyebrow { - font-family: "DM Mono", monospace; - text-transform: uppercase; - letter-spacing: 0.12em; - color: var(--brand-primary); - opacity: 0.7; - font-weight: 500; -} -.type-card, -.voice-pair, -.comp-card { - background: var(--surface-cream) !important; - border: var(--border-hairline) !important; - border-radius: var(--radius-card) !important; - box-shadow: var(--shadow-pin-standard) !important; -} -/* dna-swatch keeps its inline brand-color background — only restyle border/shadow */ -.dna-swatch { - border: var(--border-hairline) !important; - border-radius: var(--radius-card) !important; - box-shadow: var(--shadow-pin-standard) !important; -} -.comp-head { - background: var(--surface-paper-2) !important; - color: var(--brand-primary) !important; - border-bottom: var(--border-hairline) !important; - font-family: "DM Mono", monospace !important; - text-transform: uppercase; - letter-spacing: 0.14em; -} -.ds-code { - background: var(--surface-cream) !important; - border: var(--border-hairline) !important; - border-radius: var(--radius-card) !important; - color: var(--brand-primary) !important; - font-family: "DM Mono", monospace !important; -} - -/* ── §T Type-role atlas. Container = a single cream pinned-card with the - * universal hard ink offset shadow. Each row is a single-column entry padded - * only — no inner grid, no eyebrow column. Each .t-trole-* class encodes the - * role's family / size / weight / leading / tracking / case / decoration. - * Family selectors use var(--font-*) tokens so the atlas renders in BRAND DNA - * fonts; only the recipe is preset-declared. Decoration (color, border, - * shadow, rotation, stamp, pill) stays hard-coded to pin-and-paper tokens - * (var(--brand-primary), var(--brand-accent), var(--surface-paper) etc.). */ -.ds-trole-box { - display: flex; - flex-direction: column; - border: var(--border-hairline); - border-radius: var(--radius-card); - background: var(--surface-cream); - box-shadow: var(--shadow-pin-standard); - overflow: hidden; - margin-top: 24px; -} -.ds-trole-row { - padding: 28px 32px; - border-bottom: var(--border-dashed); -} -.ds-trole-row:last-child { - border-bottom: 0; -} -.ds-trole-sample { - min-width: 0; - overflow-wrap: anywhere; -} -@media (max-width: 960px) { - .ds-trole-row { - padding: 24px; - } -} - -/* ── Type-role samples. Each .t-trole-* class mirrors a pin-and-paper type-scale - * entry but uses var(--font-display/body/script/mono) so the actual typeface - * comes from brand DNA. Decoration (color, border, shadow, rotation, stamp, - * pill) is preset-native. */ -.t-trole-display-cover { - font-family: var(--font-display); - font-weight: 700; - font-size: clamp(64px, 10vw, 196px); - line-height: 1.08; - letter-spacing: -0.04em; - color: var(--brand-primary); -} -.t-trole-display-section { - display: inline-block; - font-family: var(--font-display); - font-weight: 700; - font-size: clamp(64px, 9vw, 168px); - line-height: 1.05; - letter-spacing: -0.04em; - background: var(--brand-primary); - color: var(--surface-paper); - padding: 24px 32px; - max-width: 16ch; -} -.t-trole-number-hero { - font-family: var(--font-display); - font-weight: 700; - font-size: clamp(80px, 9vw, 168px); - line-height: 0.85; - letter-spacing: -0.04em; - color: var(--brand-primary); -} -.t-trole-number-hero small { - font-family: var(--font-script); - font-weight: 700; - font-size: 0.36em; - line-height: 1; - letter-spacing: 0; - margin-left: 0.12em; -} -.t-trole-h1 { - font-family: var(--font-display); - font-weight: 700; - font-size: clamp(64px, 7vw, 130px); - line-height: 1.05; - letter-spacing: -0.035em; - color: var(--brand-primary); -} -.t-trole-h2 { - font-family: var(--font-display); - font-weight: 700; - font-size: clamp(48px, 5vw, 96px); - line-height: 1.05; - letter-spacing: -0.03em; - color: var(--brand-primary); -} -.t-trole-card-h3 { - font-family: var(--font-display); - font-weight: 700; - font-size: clamp(24px, 2.4vw, 38px); - line-height: 1.02; - letter-spacing: -0.02em; - color: var(--brand-primary); -} -.t-trole-quote-text { - font-family: var(--font-display); - font-weight: 500; - font-size: clamp(28px, 3.5vw, 50px); - line-height: 1.1; - letter-spacing: -0.02em; - color: var(--brand-primary); - max-width: 22ch; -} -.t-trole-scribble-lg { - font-family: var(--font-script); - font-weight: 700; - font-size: clamp(48px, 5.5vw, 70px); - line-height: 0.9; - color: var(--brand-primary); -} -.t-trole-scribble-sm { - display: inline-block; - font-family: var(--font-script); - font-weight: 600; - font-size: clamp(32px, 2.8vw, 38px); - line-height: 1.05; - color: var(--brand-primary); - transform: rotate(-2deg); -} -.t-trole-scribble-sm .pp-underline { - border-bottom: 2px solid var(--brand-primary); - padding-bottom: 1px; -} -.t-trole-label-top { - font-family: var(--font-mono); - font-weight: 500; - font-size: clamp(24px, 1.4vw, 26px); - line-height: 1.2; - letter-spacing: 0.12em; - text-transform: uppercase; - color: var(--brand-primary); -} -.t-trole-label-footer { - font-family: var(--font-mono); - font-weight: 500; - font-size: clamp(24px, 1.3vw, 25px); - line-height: 1.2; - letter-spacing: 0.14em; - text-transform: uppercase; - color: var(--brand-primary); - opacity: 0.65; -} -.t-trole-stamp-mark { - display: inline-block; - font-family: var(--font-mono); - font-weight: 500; - font-size: clamp(24px, 1.4vw, 26px); - line-height: 1; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--brand-accent); - background: transparent; - border: 3px solid var(--brand-accent); - padding: 6px 16px; - transform: rotate(-4deg); -} -.t-trole-pill-yes { - display: inline-block; - font-family: var(--font-script); - font-weight: 600; - font-size: clamp(24px, 2vw, 28px); - line-height: 1; - color: var(--surface-paper); - background: var(--brand-primary); - border: 1.5px solid var(--brand-primary); - border-radius: 999px; - padding: 4px 14px; -} -.t-trole-pill-no { - display: inline-block; - font-family: var(--font-mono); - font-weight: 500; - font-size: clamp(24px, 1.4vw, 26px); - line-height: 1; - letter-spacing: 0.14em; - text-transform: uppercase; - color: var(--brand-accent); - background: transparent; - border: 1.5px solid var(--brand-accent); - border-radius: 999px; - padding: 4px 14px; -} -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/caption-skin.html b/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/caption-skin.html deleted file mode 100644 index a574b8cd16..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/caption-skin.html +++ /dev/null @@ -1,263 +0,0 @@ - - - - - - Scatterbrain — Captions - - - - - - - - - -
- -
- - - - diff --git a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/bg-cork.md b/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/bg-cork.md deleted file mode 100644 index c286f322bf..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/bg-cork.md +++ /dev/null @@ -1,30 +0,0 @@ -```html -
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/bg-paper.md b/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/bg-paper.md deleted file mode 100644 index cca0bf6de0..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/bg-paper.md +++ /dev/null @@ -1,29 +0,0 @@ -```html -
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/bg-warm.md b/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/bg-warm.md deleted file mode 100644 index 11d7ec6741..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/bg-warm.md +++ /dev/null @@ -1,38 +0,0 @@ -```html -
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/chip.md b/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/chip.md deleted file mode 100644 index 8761a8e2c3..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/chip.md +++ /dev/null @@ -1,36 +0,0 @@ -```html -{LABEL} - - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/compare-card.md b/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/compare-card.md deleted file mode 100644 index 4e2bad116c..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/compare-card.md +++ /dev/null @@ -1,128 +0,0 @@ -```html -
-
-

{LEFT}

-
    -
  • {DO_1}
  • -
  • {DO_2}
  • -
  • {DO_3}
  • -
-
-
vs
-
-

{RIGHT}

-
    -
  • {DONT_1}
  • -
  • {DONT_2}
  • -
  • {DONT_3}
  • -
-
-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/doodle.md b/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/doodle.md deleted file mode 100644 index f24770e751..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/doodle.md +++ /dev/null @@ -1,65 +0,0 @@ -```html - - - - - - - - - - - - - - - - - - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/feature-card.md b/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/feature-card.md deleted file mode 100644 index 0c22cf1921..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/feature-card.md +++ /dev/null @@ -1,87 +0,0 @@ -```html - -
-
A
-

{HEADLINE}

-

{SUBHEAD}

-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/grain-overlay.md b/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/grain-overlay.md deleted file mode 100644 index 20af10e33a..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/grain-overlay.md +++ /dev/null @@ -1,22 +0,0 @@ -```html -
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/hero.md b/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/hero.md deleted file mode 100644 index 374ecd1294..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/hero.md +++ /dev/null @@ -1,104 +0,0 @@ -```html -
-
-

{HEADLINE}

-

{SUBHEAD}

-
-
{EYEBROW}
-
{LABEL}
-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/photo-frame.md b/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/photo-frame.md deleted file mode 100644 index c15a41701b..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/photo-frame.md +++ /dev/null @@ -1,95 +0,0 @@ -```html -
-
- {LABEL} -
-

{SUBHEAD}

-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/post-it.md b/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/post-it.md deleted file mode 100644 index 7f7f30e2cf..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/post-it.md +++ /dev/null @@ -1,173 +0,0 @@ -```html - -
- {EYEBROW} -

{HEADLINE}

-

{SUBHEAD}

-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/stat-counter.md b/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/stat-counter.md deleted file mode 100644 index a0685a6180..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/stat-counter.md +++ /dev/null @@ -1,84 +0,0 @@ -```html -
- {EYEBROW} -
- {LABEL} - {NUM} -
-
- {LEFT} - {NUM} -
-

{SUBHEAD}

-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/timeline-step.md b/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/timeline-step.md deleted file mode 100644 index 22c9fbfaf8..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/components/timeline-step.md +++ /dev/null @@ -1,102 +0,0 @@ -```html - -
-
-

{HEADLINE}

-

{LABEL}

-
-
-

{SUBHEAD}

-
-
- - -``` diff --git a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/preset.md b/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/preset.md deleted file mode 100644 index fa9bdcae70..0000000000 --- a/skills/faceless-explainer/phases/design-system/style-presets/scatterbrain/preset.md +++ /dev/null @@ -1,615 +0,0 @@ -```preset-meta -{ - "name": "scatterbrain", - "label": "Scatterbrain", - "fingerprint": { - "shadow": "soft-blur-paper-lift", - "border": "none-on-stickies", - "motion": "hand-placed-tilt", - "density": "casually-clustered", - "contrast": "warm-pastel-on-paper", - "palette-mode": "brand-tinted-with-anchors" - }, - "match_signals": [ - { "kind": "bouncy_easing", "weight": 0.25 }, - { "kind": "low_saturation", "weight": 0.15 }, - { "kind": "minimal_decoration", "weight": 0.05 } - ], - "best_for": ["creative agencies", "education", "indie tools", "workshop products", "warm friendly brands", "workshop / brainstorm decks"], - "avoid_for": ["cold corporate", "formal enterprise", "regulated industries", "high-polish premium"], - "chromeFonts": { - "googleFontsHref": "https://fonts.googleapis.com/css2?family=Shrikhand&family=Zilla+Slab:wght@300;400;500;600;700&family=Caveat:wght@400;500;600;700&display=swap", - "display": "Shrikhand", - "body": "Zilla Slab", - "script": "Caveat", - "mono": "Caveat" - }, - "palette": { - "primary": { "value": "#FFE066", "constraint": "butter yellow — the lead sticky hue; tints the butter + mint post-its via color-mix(), the doodle stroke, and the warm-bg gradient" }, - "secondary": { "value": "#A5D8FF", "constraint": "sky blue — the system's 'secondary' sticky; tints the sky + lavender post-its and the cool half of bg gradients" }, - "accent": { "value": "#FFC9C9", "constraint": "rose pink — the 'warm accent' sticky; tints the blush + peach post-its and warm-accent gradients" }, - "canvas": { "value": "#F7F5F0", "lock": "anchor", "constraint": "warm cream paper — the textured ground (mirrors --paper-cream in §B); pure white kills the tactile paper register, so never replace" }, - "surface": { "value": "#FAF8F3", "constraint": "lighter warm cream — the 'second surface' (polaroid / clean inner frames), a touch brighter than the paper canvas" }, - "ink": { "value": "#2D2A26", "constraint": "warm near-black (mirrors --ink-warm) — every headline, body line, border, and doodle; pure black reads cold on warm pastels" } - } -} -``` - -> `chromeFonts` makes the doc chrome render in the preset's native fonts; brand fonts still apply to §6 components. Scatterbrain has no machine-mono moment — the `mono` slot points at Caveat per §D's three-slot contract. - -## §A Director's intent - -Designer's whiteboard at 11am. Sticky notes pinned to cork, masking tape across the corner, marker doodles in the margins. Shrikhand display reads as chunky marker-pen lettering; Zilla Slab body sits like a printed handout; Caveat is the moment something got jotted down. - -Depth is **soft blurred drop-shadow** (`2px 3px 15px shadow, 0 1px 3px shadow-deep`) on every post-it — the rare preset that embraces blur because the visual depends on paper lifting off cork. Every sticky carries a small rotation (±1° to ±15°) and a thumbtack pin via `::before`; hero stickies add a translucent tape strip via `::after`. - -**Brand DNA tints the stickies; preset anchors keep the register playful.** Three site colors (`--brand-primary` / `--brand-secondary` / `--brand-accent`) mix with named pastel anchors (`--anchor-butter` / `--anchor-sky` / `--anchor-blush` / `--anchor-mint`) so dark or oversaturated brands still produce sticky-note pastels instead of muddy fills. Cream paper (`--paper-cream`) and warm ink (`--ink-warm`) are technical prerequisites — pure white kills the tactile register; pure black on warm pastels reads cold. - -**Color role contract**: post-its cycle through the four anchor-mixed pastels (butter, sky, blush, mint) for categorical variety; brand DNA flows in as gradient deepening, pin colors, and feature-icon accents. Ink-warm carries every headline, every body line, every border, every doodle — colored text on pastel stickies kills legibility. - -Motion is **hand-placed tilt**: short overshoot on entry (the sticky "lands" with a tiny bounce), no glide. Doodles drift on `sine.inOut`. Scene transitions are quick cuts with a single tape-rip beat — never crossfade. - -**Class prefix:** `sb-` (initialism, 3 chars per §8.6). - -**Atmosphere is non-negotiable.** Every scene gets the grain overlay + one of three background variants (cork / paper / warm). A post-it floating on plain white reads as broken — the textured ground IS the system. - -## §B Decoration tokens (merge into design.html `:root`) - -Scatterbrain declares **structural** tokens here (sticky-note shadow stack, rotation tilts, pin / tape geometry, doodle stroke). Color comes mostly from brand DNA mixed against named pastel anchors so the playful register survives any brand palette. - -The cream paper base (`--paper-cream`) and warm ink (`--ink-warm`) are technical exceptions: pure white loses the paper texture, pure black on warm pastels feels cold. The four anchor hues are §8.2 hue-anchor tokens — declared once, mixed against brand vars in every component — so the system stays "sticky-note" regardless of brand DNA. - -```css -/* §8.2 technical exception — warm paper + warm ink. Pure white destroys the - tactile register; pure black reads cold on warm pastels. */ ---paper-cream: #f7f5f0; ---paper-cream-deep: #f5f2ec; ---ink-warm: #2d2a26; ---ink-warm-light: #5c5750; - -/* §8.2 hue-anchor tokens — declared once so every sticky-fill mix produces a - consistent pastel register regardless of brand DNA. Without these anchors, - dark or oversaturated brands would push the stickies into muddy or fluorescent - territory and the workshop voice breaks. */ ---anchor-butter: #ffe066; /* yellow sticky */ ---anchor-butter-deep: #ffd43b; ---anchor-sky: #a5d8ff; /* blue sticky */ ---anchor-sky-deep: #74c0fc; ---anchor-blush: #ffc9c9; /* pink sticky */ ---anchor-blush-deep: #ff9f9f; ---anchor-mint: #b2f2bb; /* green sticky */ ---anchor-mint-deep: #8ce99a; ---anchor-peach: #ffcc80; /* orange sticky (flat fill) */ ---anchor-lavender: #d0bfff; /* purple sticky (flat fill) */ - -/* §8.2 tactile-prop anchors — physical objects the brand DNA does not own. - Thumbtacks are physical-object red / gold / blue / green beads; cork is wood; - polaroid paper is white. Mixing brand DNA into these would break the - workshop metaphor (a red thumbtack should look like a red thumbtack). */ ---pin-red-light: #ff6b6b; ---pin-red-deep: #c92a2a; ---pin-gold-light: #ffd43b; ---pin-gold-deep: #f59f00; ---pin-green-light: #69db7c; ---pin-green-deep: #2f9e44; ---pin-blue-light: #4dabf7; ---pin-blue-deep: #1864ab; -/* Cork-wood tones for bg-cork tonal gradient */ ---cork-light: #e8ddd0; ---cork-mid: #d4c5b0; ---cork-deep: #c9b8a0; -/* Polaroid photo paper + placeholder photo tones */ ---photo-paper: #fff; ---photo-placeholder-1: #e9ecef; ---photo-placeholder-2: #dee2e6; ---photo-placeholder-3: #ced4da; -/* Background-texture tones — cork glow ellipses + paper graph-grid line. - Declared as tokens so bg-cork / bg-paper reference them instead of bare rgba. */ ---cork-glow-warm: rgba(210, 170, 120, 0.3); ---cork-glow-deep: rgba(190, 150, 100, 0.2); ---paper-grid-line: rgba(200, 190, 175, 0.08); - -/* Sticky surface mixes — 70% brand-tinted anchor + 30% brand-primary lifts the - anchor toward the brand without overwhelming it. Components reference these - tokens by name so the cluster recolors cleanly across brands. */ ---sticky-butter: color-mix(in srgb, var(--brand-primary) 18%, var(--anchor-butter)); ---sticky-butter-deep: color-mix(in srgb, var(--brand-primary) 18%, var(--anchor-butter-deep)); ---sticky-sky: color-mix(in srgb, var(--brand-secondary) 18%, var(--anchor-sky)); ---sticky-sky-deep: color-mix(in srgb, var(--brand-secondary) 18%, var(--anchor-sky-deep)); ---sticky-blush: color-mix(in srgb, var(--brand-accent) 18%, var(--anchor-blush)); ---sticky-blush-deep: color-mix(in srgb, var(--brand-accent) 18%, var(--anchor-blush-deep)); ---sticky-mint: color-mix(in srgb, var(--brand-primary) 14%, var(--anchor-mint)); ---sticky-mint-deep: color-mix(in srgb, var(--brand-primary) 14%, var(--anchor-mint-deep)); - -/* Shadow stack — signature soft paper-lift. The 15px-blur outer + 3px-blur inner - makes the sticky hover off cork. This is the rare preset that uses blur. */ ---shadow-paper: rgba(45, 42, 38, 0.15); ---shadow-paper-deep: rgba(45, 42, 38, 0.25); ---shadow-sticky: 2px 3px 15px var(--shadow-paper), 0 1px 3px var(--shadow-paper-deep); ---shadow-pin: 0 2px 4px var(--shadow-paper-deep), inset -2px -2px 4px rgba(0, 0, 0, 0.2); - -/* §8.2 tactile-prop anchors — translucent masking-tape strip (a physical prop - the brand DNA does not own, like the thumbtacks above). Declared once so the - hero / post-it / photo-frame tape ::after references read as tokens, never - bare rgba. */ ---tape-fill: rgba(255, 255, 255, 0.4); ---tape-edge: rgba(255, 255, 255, 0.3); ---tape-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - -/* Dashed-ink hairlines — warm ink at low alpha for sticky dividers / dashed - rules. Tokenized so components and §I chrome share one value (no bare rgba). */ ---ink-hairline: rgba(45, 42, 38, 0.2); ---ink-hairline-soft: rgba(45, 42, 38, 0.1); - -/* Tilt presets — apply via transform on the sticky element. Hero / statement: - small (±1-3°). Accent / floating / closing: larger (±5-15°). */ ---tilt-quiet-l: -1.5deg; ---tilt-quiet-r: 1.5deg; ---tilt-loud-l: -8deg; ---tilt-loud-r: 8deg; ---tilt-wild-l: -14deg; ---tilt-wild-r: 14deg; - -/* Geometry — pin, tape, feature-icon, doodle stroke */ ---pin-size: 16px; ---pin-top: -12px; ---tape-w: 80px; ---tape-h: 25px; ---tape-top: -15px; ---tape-rot: -2deg; ---feature-icon-size: 60px; ---feature-icon-border: 3px solid var(--ink-warm); ---doodle-stroke: 3px; ---doodle-opacity: 0.15; - -/* Spacing — clamp the post-it padding scale */ ---gap-slide: 3rem; ---pad-postit-lg: 3rem 4rem; ---pad-postit-md: 2.5rem; ---pad-postit-sm: 1.5rem; ---pad-postit-statement: 3.5rem 4rem; ---gap-cluster: 2.5rem; -``` - -## §D Font pairing fallback (if brand fonts not on Google Fonts) - -Scatterbrain forces its display / body / script regardless of site DNA — the workshop voice depends on Shrikhand's chunky decorative shapes, Zilla Slab's warm slabs, and Caveat's casual cursive. Fallbacks below are only used if the primary face fails to load. - -- **display**: `'Shrikhand'` · `'Fraunces'` · `'Lobster'` wght 400 -- **body**: `'Zilla Slab'` · `'Roboto Slab'` · `'Bitter'` wght 400 -- **mono**: `'Caveat'` · `'Patrick Hand'` · `'Kalam'` wght 500 - -(The "mono" slot is reused for the script face — Scatterbrain has no monospace need, but `resolveFont()` reads three roles. The fallback chain stays on hand-script.) - -## §T Type-role atlas (Phase 4b reads this to size text correctly) - -The atlas is the **sole authoring source** for non-component text. If a scene needs a `stat-value` numeral that isn't covered by §6 components, the worker reads role `stat-value` here and writes inline CSS from these values. Do NOT invent ad-hoc sizes — Scatterbrain's identity collapses if Shrikhand drops out of headline roles or if body copy slips into Shrikhand. - -```type-roles -[ - { - "id": "display-hero", - "family": "display", - "purpose": "cover / closing oversized headline — Shrikhand on a hero sticky", - "px_min": 40, "px_max": 72, "weight": 400, "leading": "1.1", "tracking": "0.02em", "case": "sentence", - "sample_html": "
Design together.
" - }, - { - "id": "statement", - "family": "display", - "purpose": "centered manifesto / pulled-quote statement", - "px_min": 32, "px_max": 56, "weight": 400, "leading": "1.1", "tracking": "0.02em", "case": "sentence", - "sample_html": "
Pin it. Share it. Ship it.
" - }, - { - "id": "headline", - "family": "display", - "purpose": "primary slide / section headline inside a post-it", - "px_min": 28, "px_max": 48, "weight": 400, "leading": "1.1", "tracking": "0.02em", "case": "sentence", - "sample_html": "
Section title
" - }, - { - "id": "title", - "family": "display", - "purpose": "sub-region or feature-card title", - "px_min": 24, "px_max": 32, "weight": 400, "leading": "1.1", "tracking": "0.02em", "case": "sentence", - "sample_html": "
Feature title
" - }, - { - "id": "stat-value", - "family": "display", - "purpose": "numeric stat value inside a stat-row (Shrikhand)", - "px_min": 28, "px_max": 44, "weight": 400, "leading": "1.1", "tracking": "0.02em", "case": "upper", - "sample_html": "
128K USERS
" - }, - { - "id": "list-item", - "family": "body", - "purpose": "bullet / check-marked list row inside a sticky", - "px_min": 24, "px_max": 26, "weight": 400, "leading": "1.6", "tracking": "0", "case": "sentence", - "sample_html": "
  • One sticky per idea.
  • Pin it. Tape it.
  • Step back. Look.
" - }, - { - "id": "handwritten", - "family": "script", - "purpose": "casual side quip / decorative annotation (Caveat 400)", - "px_min": 24, "px_max": 30, "weight": 400, "leading": "1.4", "tracking": "0", "case": "sentence", - "sample_html": "
jot it down before you forget :)
" - }, - { - "id": "handwritten-lg", - "family": "script", - "purpose": "larger handwritten subtitle / hero quip (Caveat 600)", - "px_min": 24, "px_max": 32, "weight": 600, "leading": "1.3", "tracking": "0", "case": "sentence", - "sample_html": "
like a whiteboard, but online
" - }, - { - "id": "label-script", - "family": "script", - "purpose": "tracked-caps eyebrow above a card headline (Caveat uppercase, 0.15em)", - "px_min": 24, "px_max": 26, "weight": 400, "leading": "1.2", "tracking": "0.15em", "case": "upper", - "sample_html": "
The brief — chapter one
" - }, - { - "id": "feature-icon-glyph", - "family": "display", - "purpose": "single-character glyph inside a 60px round ink-bordered feature icon", - "px_min": 24, "px_max": 30, "weight": 400, "leading": "1", "tracking": "0", "case": "upper", - "sample_html": "
A
" - }, - { - "id": "versus-mark", - "family": "display", - "purpose": "compare-circle connector text (cream on ink, Shrikhand)", - "px_min": 24, "px_max": 28, "weight": 400, "leading": "1", "tracking": "0.02em", "case": "lower", - "sample_html": "
vs
" - } -] -``` - -## §E Motion (GSAP consts — REPLACES site ease) - -```js -const EASE = { - entry: "back.out(1.6)", // sticky "lands" with a tiny bounce — hand-placed feel - emphasis: "back.out(1.4)", // pin/tape attach beats, doodle reveals - exit: "power2.in", // sticky lifts off cleanly on exit - drift: "sine.inOut", // doodle / handwritten quip ambient sway -}; -const DUR = { - snap: 0.18, - med: 0.5, - slow: 0.9, -}; -// RULE: entry uses back.out — the sticky must land with a small overshoot. -// Never ease-in-out for primary motion; the sticky reads as floating instead of pinned. -// RULE: tilt is part of the resting state, NOT animated. Set transform: rotate() -// at scene start; do not tween rotation between values. The whole point is "hand-placed -// and left alone" — a sticky that wiggles reads as broken. -// RULE: scene transitions are quick cuts (≤ 0.18s) with a tape-rip beat. No crossfade, -// no slide between scenes — crossfade kills the tactile register. -// RULE: pin attaches AFTER the sticky lands. Stagger pin reveal ~80ms behind the sticky -// so the eye reads "sticky placed → pin pressed in". -``` - -### §E.5 Motion choreography - -**Allowed primitives** - -- Sticky entry: `back.out(1.6)`, ~0.5s, from offset (translateY +24px, opacity 0). Tilt is set at landing, not animated. -- Pin attach: `back.out(1.4)`, ~0.18s, scale 0 → 1, ~80ms after sticky lands. -- Tape attach: same as pin but ~120ms later, with a tiny rotation jiggle (-3° → -2°). -- Handwritten Caveat lines reveal with a brief x-offset (translateX -8px → 0) on `power2.out`. -- Doodle stroke drift: `sine.inOut`, ~3s, ±2° rotation around its center — ambient only. -- Stat numerals: count-up on `power2.out`, ~0.6s, snap to final value. - -**Forbidden** - -- Crossfade, dissolve, blur transitions between scenes. -- Sub-degree rotation tweens on a sticky (a sticky that wiggles reads as broken). -- Glow, neon, hard-offset zero-blur shadows (the wrong preset). -- Border-radius on post-its (every sticky is a rectangle, only icons / pins / versus-circles are round). -- More than 6 post-its visible at once — the playful energy collapses into chaos. -- Uniform tilt direction across adjacent stickies — alternate ± per neighbor. - -**Stagger budget** - -120-160ms between cluster items (sticky → pin → tape, then next sticky). Total scene-in stagger ≤ 700ms. Doodles always last, after all stickies have landed. - -## §G Voice transform recipe - -Take the brand's product description / value prop. Transform with: - -1. Strip corporate hedges ("solution", "platform", "leverage", "synergy"). Keep concrete nouns + verbs. -2. Hero headlines: 2-5 words, **mixed case** (NOT uppercase — Shrikhand is loud enough; uppercase reads as shouting and kills the workshop register). -3. Eyebrow labels: short categorical words in Caveat, UPPERCASE with 0.15em tracking (`THE BRIEF`, `CHAPTER ONE`, `01 / DISCOVERY`). This is the only place uppercase appears. -4. Body paragraphs: Zilla Slab sentence case, conversational. Write like a designer explaining their notes to a peer, not like marketing copy. -5. Personal quips in Caveat: 2-6 words, lowercase, with personality ("jot it down before you forget", "pin this somewhere safe", "ok :)"). One per scene maximum. -6. Stat values: numeric + UPPERCASE one-word unit (`128K USERS`, `4.8 STARS`). Stat labels in Zilla Slab sentence case. - -**Example:** - -- IN: `Figma helps teams design products collaboratively in real time` -- OUT: hero=`Design together.` / eyebrow=`THE TEAM SPACE` / body=`Every cursor, every comment, every revision — visible to the whole crew.` / quip=`like a whiteboard, but online :)` - -## §I Page-level CSS (overrides design.html's neutral chrome — makes the doc itself read as scatterbrain) - -```css -/* ── Preset-native typography vars (loaded via preset-meta.chromeFonts.googleFontsHref). - * These let the doc chrome render in Shrikhand / Zilla Slab / Caveat regardless - * of brand DNA. The §6 component preview and §T type-role atlas - * also read these via .preset-native-scope. - * - * Scatterbrain has no machine-mono moment — the mono slot falls back to Caveat - * (the system's hand-script) per §D's three-slot contract. Fallback chains end - * in a face that still carries the preset's vibe (Fraunces / Lobster display; - * Roboto Slab / Bitter body; Patrick Hand / Kalam script). Falling all the way - * to generic should never happen in practice. */ -:root { - --f-disp-native: "Shrikhand", "Fraunces", "Lobster", "Georgia", "Times New Roman", serif; - --f-body-native: "Zilla Slab", "Roboto Slab", "Bitter", "Georgia", "Times New Roman", serif; - --f-script-native: "Caveat", "Patrick Hand", "Kalam", "Brush Script MT", "Comic Sans MS", cursive; - --f-mono-native: "Caveat", "Patrick Hand", "Kalam", "Brush Script MT", "Comic Sans MS", cursive; -} - -/* .preset-native-scope: re-bind brand DNA font tokens to preset-native families. - * Wraps §6 component previews and §T type-role atlas so - * var(--font-*) resolves to Shrikhand / Zilla Slab / Caveat regardless of the - * brand DNA tokens emitted in :root. The paste-ready component source is - * untouched — Phase 4b still grep + paste original var(--font-display) tokens, - * which resolve to brand DNA at scene-render time. */ -.preset-native-scope { - --font-display: var(--f-disp-native); - --font-body: var(--f-body-native); - --font-script: var(--f-script-native); - --font-mono: var(--f-mono-native); -} - -body { - background: var(--paper-cream); - position: relative; -} -body::before { - /* Paper grain on design.html itself */ - content: ""; - position: fixed; - inset: 0; - pointer-events: none; - z-index: 9999; - opacity: 0.04; - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); - background-repeat: repeat; - background-size: 200px 200px; -} -.title-card { - background: var(--paper-cream-deep); - border-bottom: none; - padding: 96px 0 80px; -} -.title-display { - font-family: "Shrikhand", cursive; - font-weight: 400; - letter-spacing: 0.02em; - color: var(--ink-warm); -} -.brand-name { - color: var(--brand-primary); - font-weight: 400; -} -.style-name { - color: var(--brand-secondary); - font-weight: 400; -} -.ds-section { - border-top: 1px dashed var(--ink-hairline); - padding: 80px 0; -} -h2 { - font-family: "Shrikhand", cursive; - color: var(--ink-warm); - letter-spacing: 0.02em; -} -.eyebrow { - font-family: "Caveat", cursive; - text-transform: uppercase; - letter-spacing: 0.15em; - color: var(--ink-warm-light); - font-weight: 500; -} -.type-card, -.voice-pair, -.comp-card { - background: var(--sticky-butter) !important; - border: none !important; - border-radius: 0 !important; - box-shadow: var(--shadow-sticky) !important; - transform: rotate(-0.5deg); -} -.type-card:nth-child(even), -.voice-pair:nth-child(even), -.comp-card:nth-child(even) { - background: var(--sticky-sky) !important; - transform: rotate(0.8deg); -} -/* dna-swatch keeps inline brand-color background — only sticky-tilt + shadow */ -.dna-swatch { - border: none !important; - border-radius: 0 !important; - box-shadow: var(--shadow-sticky) !important; - transform: rotate(-0.5deg); -} -.dna-swatch:nth-child(even) { - transform: rotate(0.8deg); -} -.comp-head { - background: transparent !important; - color: var(--ink-warm) !important; - border-bottom: 1px dashed var(--ink-hairline) !important; - font-family: "Shrikhand", cursive; -} -.ds-code { - background: var(--photo-paper) !important; - border: none !important; - border-radius: 0 !important; - box-shadow: var(--shadow-sticky); - color: var(--ink-warm) !important; - font-family: "Caveat", "Courier New", monospace; -} - -/* ── §T Type-role atlas. Container = cream sticky-card. Each .t-trole-* class - * encodes the role's family / size / weight / leading / tracking / case / - * decoration. Family selectors use var(--font-*) tokens so the atlas renders - * in BRAND DNA fonts; only the recipe is preset-declared. Decoration (color, - * shadow, rotation, feature-icon round border, versus-circle fill) is - * preset-native and stays declared with hard-coded scatterbrain tokens - * (var(--ink-warm), var(--paper-cream), etc). */ -.ds-trole-box { - display: flex; - flex-direction: column; - border: none; - border-radius: 0; - background: var(--paper-cream-deep); - box-shadow: var(--shadow-sticky); - overflow: hidden; - margin-top: 24px; -} -.ds-trole-row { - padding: 28px 32px; -} -.ds-trole-row:not(:last-child) { - border-bottom: 1px dashed var(--ink-hairline); -} -.ds-trole-sample { - min-width: 0; - overflow-wrap: anywhere; -} -@media (max-width: 960px) { - .ds-trole-row { - padding: 24px; - } -} - -/* ── Type-role samples. Each .t-trole-* class mirrors a scatterbrain type-scale - * entry but uses var(--font-display/body/script) so the actual typeface comes - * from brand DNA. Color stays preset-native (ink-warm on pastel-ish surfaces). */ -.t-trole-display-hero { - font-family: var(--font-display); - font-weight: 400; - font-size: clamp(40px, 5vw, 72px); - line-height: 1.1; - letter-spacing: 0.02em; - color: var(--ink-warm); -} -.t-trole-statement { - font-family: var(--font-display); - font-weight: 400; - font-size: clamp(32px, 4vw, 56px); - line-height: 1.1; - letter-spacing: 0.02em; - color: var(--ink-warm); -} -.t-trole-headline { - font-family: var(--font-display); - font-weight: 400; - font-size: clamp(28px, 3.5vw, 48px); - line-height: 1.1; - letter-spacing: 0.02em; - color: var(--ink-warm); -} -.t-trole-title { - font-family: var(--font-display); - font-weight: 400; - font-size: clamp(24px, 2.5vw, 32px); - line-height: 1.1; - letter-spacing: 0.02em; - color: var(--ink-warm); -} -.t-trole-stat-value { - font-family: var(--font-display); - font-weight: 400; - font-size: clamp(28px, 3vw, 44px); - line-height: 1.1; - letter-spacing: 0.02em; - text-transform: uppercase; - color: var(--ink-warm); -} -.t-trole-list-item { - list-style: none; - padding: 0; - margin: 0; - display: flex; - flex-direction: column; - gap: 8px; -} -.t-trole-list-item li { - font-family: var(--font-body); - font-weight: 400; - font-size: clamp(24px, 1.4vw, 26px); - line-height: 1.6; - color: var(--ink-warm); - padding-left: 1.3em; - position: relative; -} -.t-trole-list-item li::before { - content: "•"; - position: absolute; - left: 0; - color: var(--ink-warm); - font-weight: 700; -} -.t-trole-handwritten { - font-family: var(--font-script); - font-weight: 400; - font-size: clamp(24px, 2vw, 30px); - line-height: 1.4; - color: var(--ink-warm); -} -.t-trole-handwritten-lg { - font-family: var(--font-script); - font-weight: 600; - font-size: clamp(24px, 2.5vw, 32px); - line-height: 1.3; - color: var(--ink-warm); -} -.t-trole-label-script { - display: inline-block; - font-family: var(--font-script); - font-weight: 400; - font-size: clamp(24px, 1.4vw, 26px); - line-height: 1.2; - letter-spacing: 0.15em; - text-transform: uppercase; - color: var(--ink-warm-light); -} -.t-trole-feature-icon-glyph { - display: inline-flex; - align-items: center; - justify-content: center; - width: 60px; - height: 60px; - border: 3px solid var(--ink-warm); - border-radius: 50%; - font-family: var(--font-display); - font-weight: 400; - font-size: clamp(24px, 2vw, 30px); - line-height: 1; - color: var(--ink-warm); - background: transparent; -} -.t-trole-versus-mark { - display: inline-flex; - align-items: center; - justify-content: center; - width: 60px; - height: 60px; - border-radius: 50%; - font-family: var(--font-display); - font-weight: 400; - font-size: clamp(24px, 1.6vw, 28px); - line-height: 1; - letter-spacing: 0.02em; - background: var(--ink-warm); - color: var(--paper-cream); - box-shadow: 0 2px 8px var(--shadow-paper-deep); -} -``` diff --git a/skills/faceless-explainer/phases/scriptwriting/guide.md b/skills/faceless-explainer/phases/scriptwriting/guide.md deleted file mode 100644 index e546fd206d..0000000000 --- a/skills/faceless-explainer/phases/scriptwriting/guide.md +++ /dev/null @@ -1,296 +0,0 @@ -## Core Principles - -The video narrative is independent from the input text's layout. An article / brief / set of notes is an information dump; a video is a guided act of understanding. - -- Scene sequence comes from narrative design, not from the input text's paragraph order. -- A text may run `intro -> background -> detail -> detail -> caveat -> conclusion`; a video may run `hook -> question -> concept -> mechanism -> example -> takeaway`, or `setup -> tension -> turn -> resolution -> lesson`, or `promise -> step -> step -> step -> payoff`, depending on the structure. -- Reorder, merge, omit, or compress the source text as needed. Strip the asides; surface the spine. The single most common failure is paraphrasing the article in order — do not do that. -- The input text is the source of **information**, not a story template. - -The planning standard: **write the emotional beat alongside the structural type**, **name the specific rhetorical / clarity technique** (do not merely write "explain the idea"), and **specify a transition for every seam**. What carries the viewer's eye from scene N to scene N+1 is part of the story itself, not something to defer to the visual phase. - -## Pick the Style Preset (you choose it; it sets the whole look) - -This workflow does **not** hardcode a preset. Read the input, **pick one of the 5 shipped presets**, emit it as the top-level `stylePreset` in `narrator_scripts.json`, and match the narration register to it. The deterministic design-system step runs right after you return and builds the entire visual system from your choice — so `stylePreset` is the single lever that sets the film's look. There is **no `inference.json` to read** at this phase (design-system has not run yet); your choice _is_ the register signal. Default to `pin-and-paper` when nothing clearly fits. - -| `stylePreset` | Look | Pick it when the topic is… | Register | -| --------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------- | -| `pin-and-paper` | Yellow field-notebook paper, hard ink offset shadows, hairline ink — warm, handmade, considered (default) | reflective, educational, notes-like, humane — safe for almost any | warm, plain, considered; no hype | -| `block-frame` | 4px solid ink borders, hard black offset shadows, saturated pastel cycle — bold, poster-like, punchy | confident, energetic, declarative; bold claims, "loud" explainers | crisp, confident, declarative | -| `capsule` | Universal pill geometry, soft low shadows, Didone serif + grotesk — rounded, modern-editorial, friendly | approachable, lifestyle, product-adjacent, polished | friendly, polished | -| `scatterbrain` | Cork / paper with post-its, hand-placed tilt, soft paper-lift — playful, messy-desk, brainstorm | casual, fun, ideation, list-y, "my scattered notes" | light, conversational | -| `claude` | Warm cream editorial surface, hairline elevation, **ships a code-window** — literary, technical-but-human | technical / dev-ish / thoughtful longform; anything that shows code | warm, plain, considered | - -The preset tunes the **voice**, not the structure: scene segmentation is driven by the input text + the structure decision below. - -## Explainer Structures - -Before segmenting scenes, choose **one** explainer structure (or explicitly name a compound; see "Compound structures" below). Read its `overview.md` for guidance and study its samples. Do not splice phases from different structures, because each one is a complete, coherent path through understanding. - - - -**Concept Explainer** — "what is X, and why does it matter." Open a curiosity gap, name the core concept, build understanding one layer at a time (definition -> mechanism -> implication), land a takeaway. Best for: a single idea, term, technology, or phenomenon the audience has heard of but does not truly grasp. The concept is usually named early (after the hook) and revisited at the takeaway. - - - -**How-To / Process** — "here is how to do X" or "here is how X works," as an ordered sequence of steps / stages. Best for: tutorials, workflows, recipes, pipelines, mechanisms with a clear start→finish. The core is a **3–6 step sequence on a consistent visual stage**, each step advancing one move. When a shared motif (the object being acted on, the position marker, the running tally) carries across several adjacent steps as one continuous shot, group those steps as a `continue` run (one worker, up to 3 scenes) and hint `morph`. - - - -**Listicle** — "N things about X" — a hook, then N roughly co-equal items, then a wrap. Best for: tips, mistakes, features-of-a-field, reasons, comparisons where items are parallel rather than sequential. Items are usually `cut`/`slide` between (parallel, not continuous); use `morph` only when a genuine throughline element survives from one item to the next. Rule-of-three is the strongest item count when the source allows compression. - - - -**Story Explainer** — teach through narrative: a setup, a tension or turn, a resolution, and the lesson it carries. Best for: case studies, histories, "how this came to be," cautionary tales, anything where a concrete arc makes an abstract point land. The emotional arc has real shape (calm -> tension -> turn -> relief/insight); the takeaway generalizes the story into a transferable idea. - - -### Choosing the structure - -Read the input text once, then ask: - -- **Is the payload one idea to be understood?** → concept-explainer. -- **Is the payload an ordered procedure or mechanism with steps?** → how-to-process. -- **Is the payload a set of parallel, co-equal items?** → listicle. -- **Is the payload best carried by a concrete narrative / case / history?** → story-explainer. - -When the text genuinely mixes modes, name a compound (below) rather than splicing. Default tie-breakers: if the text is an argument about one concept that happens to list supporting reasons, prefer concept-explainer with a listicle inner rhythm over a bare listicle. If a process is wrapped in a story (someone learns the steps the hard way), prefer story-explainer with a how-to inner rhythm. - -### Compound structures - -Real explainers often _layer_ structures. Pattern: - -- **Outer structure** = macro arc the viewer rides (concept / process / list / story). -- **Inner rhythm** = the tactical rhythm inside the body phase. Common inner rhythms: a **process** rhythm (ordered steps) nested inside a concept-explainer's mechanism phase; a **listicle** rhythm (parallel items) inside a concept-explainer's "why it matters" phase. - -Write `narrativeArchetype` as `" with "`, e.g. `"concept-explainer with process"` or `"story-explainer with how-to"`. The downstream visual phase reads it for pacing; a process / step inner rhythm means tighter `morph` / `slide` seams on a consistent stage and shorter scenes. - -> The field name is `narrativeArchetype` (schema-fixed). For FE it names the chosen explainer **structure**, not a sales archetype. - -## Narrative Architecture - -Define each scene's role in the explanation. Every scene has five narrative fields (type, narrativeRole, keyMessage, persuasion, emotionalBeat), plus a separate transition spec: - -- **Type** — one of the enum values `hook` / `pain_point` / `product_intro` / `feature_showcase` / `benefit_highlight` / `social_proof` / `branding` / `cta`. The enum is schema-fixed (validate-narrator.mjs enforces it), so FE **repurposes** these labels for teaching rather than selling. Use the mapping table below; pick the value whose downstream pacing matches the scene's job. -- **Narrative Role** — what this scene does in the explanation (its _job_, e.g. "Concretizes compound interest as a snowball rolling downhill", not "Shows a chart"). -- **Key Message** — the one thing the viewer should walk away understanding (one sentence). -- **Persuasion** — a _named_ rhetorical / clarity technique (see catalog below). "Explain the idea" / "show benefits" is a failure mode; the standard is "Analogy: tax brackets as a staircase, not a cliff" / "Progressive disclosure: reveal the formula one term at a time" / "Worked example with concrete round numbers." -- **Emotional Beat** — target feeling (see vocabulary below). One word or a short compound phrase (e.g. "Curiosity and clarity"). Avoid generic "positive" / "interested". -- **Transition** — `{ continuity, intent, description, sharedMotif? }`, defining how this scene arrives from the **previous** scene. Every scene must have one, including scene 1 (use `continuity: "break"` + `intent: "cut"`). This is a **narrative-layer judgment** (whether the seam is continuous and what kind of connection it is), not visual implementation detail (specific ease / blur / direction is translated downstream by visual-design according to preset/palette). See Transition taxonomy below. - -### Type-enum repurposing (schema-fixed enum → explainer roles) - -The enum values cannot change (validate-narrator.mjs enforces them; at least one scene must be `feature_showcase` or `product_intro`). Map your explainer roles onto them as follows: - -| Explainer role you want | Use enum `type` | Why this value | -| -------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------ | -| Hook / curiosity gap | `hook` | Same job: the high-leverage opening 3–5s. | -| Pain / problem / why-care | `pain_point` | The friction or gap the explanation resolves ("you've probably wondered…", "this keeps going wrong"). | -| Name the core concept | `product_intro` | The "introduce the protagonist" beat — here the protagonist is the **idea** being named/defined. | -| Mechanism / step / stage | `feature_showcase` | A unit of the explanation's body — one move of a process, one mechanism, one list item. | -| Implication / payoff / "so what" | `benefit_highlight` | The consequence or value of understanding — what it gets you, what now becomes possible. | -| Evidence / example / data point | `social_proof` | A concrete grounding: a real number, a worked example, a citation, a comparison that proves the point. | -| Thesis / takeaway / principle | `branding` | The _philosophical_ landing beat — the generalizable idea, the rule, the one line to remember. | -| Call to think / try / act | `cta` | The closing ask — try it, watch for it, question it, do the thing. | - -Use this mapping consistently. The explainer body is usually a run of `feature_showcase` (steps/mechanisms/items) optionally interleaved with `benefit_highlight` (implications) and `social_proof` (examples/data). At least one `feature_showcase` or `product_intro` must exist (every explainer has a body and a named idea, so this is automatic). - -### Hook Strategy Taxonomy - -Choose one. The hook is the highest-leverage 3–5 seconds. For explainers it opens a cognitive gap or stakes: - -| Strategy | When to use it | Example | -| ----------------------------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------- | -| **Shocking statistic** | You have a credible data point that quantifies the stakes | "90% of plastic ever made has never been recycled." | -| **Rhetorical question** | Create an immediate cognitive gap | "Why does time seem to speed up as you get older?" | -| **Counterintuitive claim** | The truth contradicts common belief | "Adding more lanes to a highway makes traffic worse." | -| **Pain validation** | The audience already feels the confusion; say it back to them | "Everyone tells you to 'just diversify' — nobody says what that means." | -| **Visceral metaphor** | The idea is abstract and needs to become concrete / embodied | "Your attention is a spotlight, and apps are fighting over the switch." | -| **Concept announcement** | The term itself is the subject; make it memorable | "There's a word for this: the bystander effect." | -| **Direct address / character hail** | Audience is clearly defined | "If you've ever rage-quit a recipe halfway through — this is for you." | -| **Imagine / scenario** | A new perspective or thought experiment frames the whole piece | "Imagine money that loses value if you don't spend it." | -| **Stakes / consequence** | The "why care now" is a real cost or risk | "Get this one step wrong and the whole batch is ruined." | - -### Rhetorical / Clarity Technique Catalog - -Each scene's `persuasion` field is a _named technique_, not a vague intent. For explainers, the field carries **how this scene makes the idea land or clear** — a clarity / rhetoric mechanism, not a sales mechanism. Choose from this catalog (combine when several are active, e.g. "Analogy + progressive disclosure"): - -| Family | Techniques | -| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| **Make-concrete** | Analogy / metaphor • Concretization (abstract → tangible object) • Worked example with real numbers • Anchoring on a familiar referent | -| **Reveal-in-order** | Progressive disclosure (one term/layer at a time) • Build-up (simple case → general case) • Signposting ("first… then… finally") | -| **Contrast** | Before/after contrast • Common-belief vs reality • Comparison of two options • Counterexample (here is when it breaks) | -| **Structure** | Rule of three (triplet) • Numbered enumeration • Question→answer pairing • Frame-then-fill (state the shape, then populate it) | -| **Evidence** | Statistical proof / hard metric • Citation / source attribution • Demonstration (show the mechanism running) • Causal chain (A → B → C) | -| **Memory & landing** | Callback (return to the hook's image) • Distillation (compress to one line) • Mnemonic / coined term • Generalization (specific → principle) | - -When a scene's technique is not in the catalog, you may name a new one inline, but you must explain its _mechanism_ (e.g. "Subtractive framing: define the concept by what it is _not_ before saying what it is"). Do not write generic "explain the idea" / "show benefits." - -### Emotional Beat Vocabulary - -`emotionalBeat` should be one word or a short compound phrase (e.g. "Curiosity and clarity", "Tension and recognition"). Avoid generic "positive" / "happy" / "interested." Explainers ride a comprehension arc: - -**Negative valley** — _open the gap_ (hook / pain_point scenes): curiosity • puzzlement • surprise • tension • concern • skepticism • recognition • intrigue - -**Pivot** — _orient_ (product_intro / concept-naming scenes): clarity • orientation • anticipation • focus - -**Build** — _build understanding_ (feature_showcase / benefit_highlight / social_proof scenes): comprehension • "aha" • confidence • fascination • foresight • momentum • conviction • delight • unease (for a caveat) • mastery - -**Resolution** — _land_ (branding / cta / final beats): clarity • satisfaction • resolve • inspiration • inevitability • "now I get it" - -> The structure pages (`structures/*/overview.md`) refer to these four groups by their register names — **Negative valley**, **Pivot**, **Build**, **Resolution** — so a beat-trajectory link from a structure page resolves to the matching group above. - -Scenes with compound beats are often strongest, e.g. "Surprise _and_ recognition", "Comprehension _and_ delight". When two feelings are active, write both. - -### Transition Taxonomy - -Every scene's `transition` describes **how it arrives from the previous scene**, using two machine fields + prose + (for morph) a shared element name: - -#### `continuity` — `"break"` | `"continue"` (**drives worker grouping**) - -The only machine consequence of `continuity` is grouping: `prep.mjs` puts adjacent `continue` scenes into the **same scene worker** (cap=3 — a `continue` run is up to 3 scenes). The one worker that owns a run controls every DOM in it, so it authors the visual continuity across all its scenes itself and the seams read as one continuous shot. - -- **`continue` = "same worker as the previous scene."** Use it for a run of 2-3 adjacent scenes that should flow as one continuous shot — a growing diagram, a persistent object, a camera that keeps moving, a counter that advances. The worker authors the flow (and any shared-element morph) directly inside one continuous visual stage. -- **`break` = a new worker** + an inter-scene Tier-B transition (`cut` / `slide` / `dissolve` / `zoom`) injected by the harness onto the clip wrappers after assembly. -- **Scene 1 is always `break`** (there is no previous scene to continue from). - -> `continuity` is **decoupled from `intent`** (the old morph⟺continue biconditional was removed). `continue` no longer requires `morph`; it just means "keep these scenes on one worker for continuity." A `continue` **run is at most 3 scenes** (cap=3): `break → continue → continue` groups three scenes in one worker; a 4th consecutive scene must start a new run with a `break`. Use `continue` only where the scenes genuinely share a continuous stage — a seam that merely "feels continuous" should stay `break`. Many short runs are welcome: `run(1,2,3) → break → run(4,5) → break → 6 → 7`. - -#### `intent` — 5 narrative seam intentions (**not** visual implementation) - -Choose one of these 5. This is "narrative-level" vocabulary — it expresses what kind of connection the seam is, **not** blur amount / direction / duration (visual-design translates those according to preset/palette): - -| Intent | Narrative meaning | Pairs with (soft hint) | Downstream translation direction (visual-design decides values) | -| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------- | ----------------------------------------------------------------------------------------- | -| `morph` | **One shared element transforms across scenes** (the shared element is open-ended: a diagram node that becomes a chart bar, a word that becomes an icon — only examples) | `continue` | worker carries a shared element across the continue run (it owns the shared visual stage) | -| `cut` | Clean switch; scenes are not continuous (topic/tone shift, new list item, high-energy beat) | **`break`** | hard cut / crossfade | -| `slide` | Directional slide / push (matches narrative flow: next step, next point) | **`break`** | push-slide (direction set by visual-design) | -| `dissolve` | Soft dissolve / focus shift (enter atmosphere, emotional transition, time passing) | **`break`** | crossfade / blur-crossfade (when colors clash) | -| `zoom` | Camera pushes / scales through to the next focal point (zoom into a detail, pull back to the big picture) | **`break`** | zoom-through | - -`continuity` is **decoupled from `intent`** — `intent` is a soft hint. `morph` naturally pairs with `continue` (same worker carries the element); `cut` / `slide` / `dissolve` / `zoom` naturally pair with `break`. Nothing enforces this; choose `continuity` by whether the scenes share a continuous stage. - -#### `sharedMotif` — optional hint (names the carried element) - -Name the **element / motif that carries through this seam** (what morphs at the narrative layer), ≤8 words. Examples: `"the running tally"` / `"the central diagram node"` / `"the timeline marker"` / `"the key term"`. **Only name what it is; do not describe geometry/implementation** — the downstream worker uses this as the persistent subject inside the continue run. Omit this field when `intent` is not morph. - -**What makes a good shared element (pass these three tests before choosing `morph`)**: do not invent a shared element just to have one; identify which element that already belongs in both scenes can connect them best. - -- **Load-bearing in both scenes:** it is the visual protagonist or key information carrier in both outgoing and incoming scenes (the object the process acts on, the central diagram, the data series, the named concept's icon), **not** a decorative object inserted temporarily just to enable a morph. -- **Naturally co-present:** first ask "Is there an element that would naturally appear in both scenes?" If yes, use morph to connect it; if no, use Tier-B. -- **The transformation advances the explanation:** the element's morph must **carry** the conceptual jump (the same diagram gains a layer, the same number flows from formula into result, the same shape reorganizes from problem to solution), rather than making the explanation pause for a flashy animation. - -Hint `morph` (with `continue`) when all three are true; otherwise the scenes don't share a continuous stage — use `break` + a Tier-B seam (`dissolve` / `slide`), which still reads clean. - -#### `description` — 10–30 word visual direction (keep) - -Concrete direction for downstream: what morphs/slides/dissolves, where the eye lands, and what color/shape guides it. For `morph`, be especially clear about the handoff point (what shape is handed to the next scene). - -> **Why 5 intentions, not visual types:** the model lets scriptwriting express only **narrative intent + continuity**, leaving "which exact transition + blur/direction/duration" to visual-design, which has preset/palette context. `morph` covers shared-element continuity inside a continue run; the rest are Tier-B between-scene transitions per the table. - -### Script Voice Quality Bar - -Strong explainer scripts have these traits. The failure mode is reading the article aloud, or bullet-point prose. - -**Strong:** _Concretization_: "Compound interest isn't addition, it's a snowball — every turn picks up the snow from the last turn, then more." — turns an abstract formula into a moving image. - -**Weak:** _Article-paraphrase in order_: "The study, published in 2019, examined three cohorts and found that…" — that is reading, not explaining. Compress to the one fact that matters and lead with it. - -### Empty / Silent Scripts Are Allowed - -When the visual itself carries the information, set `script: ""` and keep the scene silent. This is common and good in explainers: - -- A diagram assembling itself (each part appearing on beat) — the build _is_ the message; let it breathe. -- A worked-example animation (numbers flowing through a formula) — the motion teaches; narration would only narrate the obvious. -- A beat of held tension before the turn in a story-explainer — silence is the device. - -If you set an empty script, `narrativeIntent` must be especially strong, because `narrativeRole` and `persuasion` must carry what the script does not say. - -## The Explainer Body Is a Sequence, Not a Single Scene - -An explainer's core is almost always **3–6 body scenes on a consistent visual stage**, each advancing one mechanism / step / item / layer, building understanding cumulatively (for connection rules, see the hard constraint below). The body runs `feature_showcase` / `benefit_highlight` / `product_intro` — these may interleave per the structure (a concept- or story-explainer typically goes `product_intro → feature_showcase → benefit_highlight` rather than 3 consecutive of one type); the only floor is the schema's `≥1 feature_showcase` or `product_intro`. Patterns by structure: - -- **concept-explainer:** name the concept → reveal mechanism layer by layer → land implications. The body is `product_intro` then a run of `feature_showcase` (sometimes interleaved with `benefit_highlight` for "so what" beats and `social_proof` for a grounding example). -- **how-to-process:** `feature_showcase` per step, ordered, on one stage. The object being acted on is often a genuine shared motif → pair adjacent steps with `morph` where the throughline carries. -- **listicle:** `feature_showcase` per item; items are usually parallel, so default to `cut` / `slide` between them. Use `morph` only when a real element survives item→item. -- **story-explainer:** scenes follow the narrative beats (setup / tension / turn / resolution / lesson); types map per the table (`pain_point` for tension, `branding` for the lesson). - -A single isolated body scene rarely teaches anything. Group adjacent scenes that share a continuous stage into a `continue` run (`continuity: "continue"`) — up to 3 scenes per run, all owned by one worker that authors the flow (and any shared-element morph) directly; **between runs, use a `break`** with a Tier-B transition (`cut` / `slide` / `dissolve` / `zoom`). A run is at most 3 scenes; a 4th consecutive scene starts a new run with a `break`. Shape: `run(s1,s2,s3) -> break -> run(s4,s5) -> break -> ...`. Use `continue` only where the scenes genuinely share a continuous stage; a parallel listicle may legitimately use `break` throughout. - -Identify a body sequence by: - -- Scene type is `feature_showcase`, `product_intro`, or `benefit_highlight` -- `narrativeRole` contains words such as "Defines", "Demonstrates", "Reveals", "Walks through", "Concretizes", "Builds on" -- `script` advances one mechanism / step / item / layer per scene, cumulatively -- Adjacent scenes that share a continuous stage are grouped as a `continue` run (up to 3); between runs (and across parallel items) is a `break` + Tier-B (`cut` / `slide` / `dissolve` / `zoom`) - -## Faceless Visuals — assetCandidates is `[]` by Default - -FE is a **faceless** explainer: there are no captured assets, no product screenshots, no asset inventory. Downstream (visual-design + scene workers) invents the visuals — typography, abstract graphics, diagrams, and data-viz — from each scene's `narrativeRole` / `keyMessage` / `script`. Both typographic/abstract treatments and diagram/data-viz treatments are first-class; downstream picks per scene by content. Your job here is the **narrative**, not the visual asset list. - -Therefore: - -- **`assetCandidates` is `[]` for every scene by default.** This is the normal, correct value — it tells downstream "this scene is invented from the brief." -- **The only exception:** the user explicitly provided a real image and placed it in `public/`. Then add one entry `{ "path": "public/", "description": "<≤25 words: what it is + visual notes>" }`. Do not invent paths, do not reference `capture/`, do not fabricate basenames — a path to a nonexistent file is a downstream fatal. -- Do **not** describe the intended diagram/typography here as if it were an asset. Visual intent belongs in `narrativeRole` + the transition `description`; the visual phase reads those. - -## Validation Checklist - -- Does every scene have complete Narrative Intent (all 5 fields)? -- Does every scene have `transition` — `continuity` (break/continue), `intent` (one of the 5, a soft hint), and `description` (10–30 words)? Is scene 1 `continuity: break`? Is every `continue` run at most 3 scenes? -- Is `assetCandidates` present on every scene as an array? Is it `[]` everywhere except where the user supplied a real `public/`? -- Does the emotional arc have meaningful variation (not monotone)? Does it match the structure (concept = gap → comprehension; story = calm → tension → insight)? -- Is the sequence driven by narrative, not by the input text's paragraph order? -- Is there a coherent body that builds cumulatively (a run of `feature_showcase` / `benefit_highlight` / `product_intro`, interleaving allowed per structure) — not a single isolated body scene? Are `continue` runs used only where scenes share a continuous stage, each run ≤3 scenes, separated by a `break`? -- Are Persuasion fields named rhetorical/clarity techniques from the catalog rather than vague "explain the idea"? -- Are Emotional beats specific (word or short compound phrase), not generic "positive"? -- Does the hook use a named strategy from the taxonomy? -- Is there only one outer structure (no splicing top-level frameworks)? Explicitly named inner-rhythm compounds are allowed. -- Is the type-enum used per the repurposing table (so the file stays schema-valid, with at least one `feature_showcase`/`product_intro`)? -- Is a top-level `stylePreset` set to one of the 5 shipped presets (`pin-and-paper` | `block-frame` | `capsule` | `scatterbrain` | `claude`)? - -## `narrator_scripts.json`: Canonical Schema - -Downstream agents expect these **exact** field names. Wrong names (e.g. `scene_id` instead of `sceneNumber`, `narration` instead of `script`, or flattened intent fields) are fatal in `validate-narrator.mjs`. - -```json -{ - "project": "Project name", - "narrativeArchetype": "Explainer structure (concept-explainer | how-to-process | listicle | story-explainer), or compound \" with \"", - "stylePreset": "One of: pin-and-paper | block-frame | capsule | scatterbrain | claude — drives the entire visual system (default pin-and-paper)", - "orientation": "Canvas aspect, echoed verbatim from the dispatch Orientation line: landscape (16:9, default) | portrait (9:16) | square (1:1). Dictated by the user's aspect, not chosen. prep maps it to group_spec.width/height. Omit → landscape.", - "emotionalArc": "Comprehension journey description (e.g. 'Puzzlement at why time speeds up shifting to clarity and a small delight as memory density explains it.')", - "scenes": [ - { - "sceneNumber": 1, - "sceneName": "Scene name", - "transition": { - "continuity": "break|continue", - "intent": "morph|cut|slide|dissolve|zoom", - "sharedMotif": "Only when intent=morph: name of the element carried across scenes (<=8 words, e.g. 'the running tally'); omit this key for other intents", - "description": "10-30 word concrete visual direction explaining what morphs/slides/dissolves and where the eye should land" - }, - "narrativeIntent": { - "type": "hook|pain_point|product_intro|feature_showcase|benefit_highlight|social_proof|branding|cta", - "narrativeRole": "The scene's job in the explanation (not what appears on screen)", - "keyMessage": "What the viewer should understand after this scene (one sentence)", - "persuasion": "Named rhetorical/clarity technique from the catalog (combine if multiple are active)", - "emotionalBeat": "Word or short compound phrase from the vocabulary" - }, - "assetCandidates": [], - "script": "Plain-text narration. May include /// tags as authoring-time annotations (TTS strips them). Can be an empty string when visuals carry the information.", - "estimatedDuration": "5-6s" - } - ] -} -``` - -Field rules (use exact field names above; wrong names are fatal in `validate-narrator.mjs`): - -- Every scene must have a `transition` field (`continuity` + `intent` + `description`; add `sharedMotif` for morph), including scene 1 (`continuity: "break"` + `intent: "cut"`). **Scene 1 has no previous scene, so its `transition` does not generate any transition downstream (downstream ignores it) — `intent: "cut"` is just a placeholder.** -- `continuity` is **decoupled from `intent`** (a soft hint). `continue` = same worker (a run of up to 3 scenes); `break` = new worker. `validate-narrator.mjs` checks only enum membership + scene 1 = `break`. -- `assetCandidates` is a **required** field and must be an array. For FE it is `[]` on essentially every scene; only a user-provided `public/` image yields a `{path, description}` entry. -- `narrativeArchetype` names one of the four explainer structures (or a `" with "` compound). At least one scene must be `type: feature_showcase` or `product_intro`. - -### Captions (not owned by scriptwriting) - -Do not write a `captions: string[]` field. `///` tags inside `script` are stripped by TTS; whether you include them does not drive downstream visuals. diff --git a/skills/faceless-explainer/phases/scriptwriting/structures/concept-explainer/overview.md b/skills/faceless-explainer/phases/scriptwriting/structures/concept-explainer/overview.md deleted file mode 100644 index 4097d389ad..0000000000 --- a/skills/faceless-explainer/phases/scriptwriting/structures/concept-explainer/overview.md +++ /dev/null @@ -1,102 +0,0 @@ -# Concept Explainer - -## Core Logic - -Explain **one** idea, mechanism, or term until it clicks. Open a curiosity gap ("what is X / how does X actually work"), ground the question in something the viewer already feels, then reveal the core concept by name, expose its mechanism one moving part at a time, anchor it with a concrete example or analogy, and close on the "so what" — why the idea now matters to the viewer. The persuasion is **comprehension itself**: the viewer is won the moment the thing makes sense. There is no pain to agitate and nothing to sell; the reward is the click of understanding. - -This is the default structure for a single-topic faceless explainer. It is built on **progressive disclosure** (one new idea per scene, never two) and **analogy** (a familiar object stands in for the unfamiliar mechanism). The concept is named **early-to-mid** — late enough that the question lands first, early enough that the rest of the film has a name to attach to. - -## Emotional Arc Pattern - -``` -Curiosity ──▶ Recognition ──▶ Intrigue ──▶ Clarity ──▶ Confidence ──▶ Conviction - (hook) (ground) (reveal) (mechanism) (example) ("so what") -``` - -This is a **staircase, not a V-curve**. Explainers do not dip into a named negative beat the way sales PAS does; the dominant motion is **curiosity → clarity → confidence**, each scene resolving one notch of confusion into one notch of understanding. The intrigue beat at the reveal is the emotional peak (the gap is widest just before it closes); the mechanism and example scenes pay it off as steady clarity. Verbalize the arc as something like: _"Curiosity about an unfamiliar mechanism resolves into clarity as the moving parts are exposed one at a time, ending in confidence that the idea is now genuinely understood."_ - -A shallow valley is _allowed_ but optional: a "why the obvious answer is wrong" beat (mild surprise / dissonance) can sit between ground and reveal to sharpen the gap. Keep it cognitive (surprise, dissonance), not emotional (anxiety, frustration) — that valley belongs to the story-explainer and how-to structures, not here. - -## Typical Scene Sequence - -Types are drawn from the fixed enum `hook | pain_point | product_intro | feature_showcase | benefit_highlight | social_proof | branding | cta`. For an explainer they are **repurposed** — the column below states what each MEANS here. - -| Order | Type | Means here (explainer) | Approx % | Job | -| ----- | ------------------- | ----------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------- | -| 1 | `hook` | The curiosity-gap question — pose what the viewer doesn't yet know | 8-15% | Open the gap; make the unknown feel worth chasing | -| 2 | `pain_point` | Ground the question — the friction / confusion / "why this matters" the gap sits on | 10-15% | Make the question the viewer's own, not an abstract trivia prompt | -| 3 | `product_intro` | Name and define the concept — the term + a one-line plain-language definition | 12-18% | Plant the flag; give the rest of the film a name to hang on | -| 4 | `feature_showcase` | Mechanism beat — show ONE moving part of how it works | 15-22% | Begin progressive disclosure; one component, no more | -| 5 | `feature_showcase` | Next mechanism beat — the next moving part, building on scene 4 | 12-18% | Assemble the mechanism; the diagram/throughline grows | -| 6 | `benefit_highlight` | The concrete example / analogy — map the mechanism onto something familiar | 15-22% | "Oh, it's _like_ that" — the comprehension click | -| 7 | `branding` | The "so what" takeaway — the one durable sentence the viewer keeps | 10-15% | Land the idea as a principle, not a product; resolve the gap | - -5-8 slots is the working range. Compress 4+5 into one mechanism scene for a single-mechanism topic; expand to 2-3 mechanism scenes (a `feature_showcase` sequence) for a multi-part mechanism — the same "3+ consecutive mechanism scenes on one growing diagram" rhythm the showcase sequence uses elsewhere. A `cta`-typed final scene is **optional and soft** here: an explainer rarely asks for action, but if the brief wants a "learn more / read the full piece" close, use `cta` for scene 7+ instead of `branding`. - -**Concept-naming timing** — name the concept at **scene 3 (≈12-30% in)**. Earlier than PAS's late product reveal, later than Cascade's scene-1 reveal: the question must land first (scenes 1-2) so the name arrives as the _answer to a question already asked_, not as a cold definition. Naming it in scene 1 wastes the curiosity gap; naming it past 40% leaves too little runtime to actually explain it. - -## When to Use - -- "What is X" / "how does X work" topics — a single idea, mechanism, term, or phenomenon -- Science, tech, finance, biology — anything with a _mechanism_ that can be disclosed in steps -- The payoff is the viewer **understanding** something, not feeling a pain relieved or wanting a product -- One clean analogy is available (or inventable) to carry the example beat -- The topic genuinely fits in one throughline — a single concept, not a survey - -## When NOT to Use - -- The text is a sequence of steps the viewer must _perform_ (→ use **how-to-process**) -- The text is several parallel items / a ranking with no single mechanism (→ use **listicle**) -- The payoff depends on a character, stakes, or a turn of events (→ use **story-explainer**) -- The topic is actually three concepts wearing one title — split, or pick the load-bearing one; concept-explainer disclosing two ideas per scene collapses into noise -- There is no mechanism to expose, only a definition — then it's a 15-second card, not a full ~1-3 min film - -## Hook Strategy Bias - -From the inherited Hook Strategy Taxonomy, concept-explainer leans on the **gap-opening** hooks: - -- **Rhetorical question** — the canonical concept-explainer open. "Why does your coffee go cold faster than your tea?" — names the gap and makes the viewer want it closed. Default choice. -- **Shocking statistic** — when a credible number _is_ the gap. "Your brain uses 20% of your energy doing nothing." The number is the hook because it defies expectation. -- **Imagine / future-pacing** — for forward-looking or counterfactual concepts. "Imagine a battery that charges in the time it takes to read this sentence." Use when the concept's _implication_ is more gripping than its definition. -- **Category announcement** — when the _term_ itself is the draw and is unfamiliar enough to be intriguing. "This is dollar-cost averaging." Use sparingly: only when the name carries mystery on its own. -- **Visceral metaphor** — when the concept is abstract and needs an embodied entry point that the example beat later pays off ("Think of your immune system as a city under siege"). - -Avoid the sales-coded opens — **pain validation** and **direct address / character hail** — unless the brief is genuinely pain-shaped; they signal "this is a fix for you" and tilt the film toward PAS, undercutting the curiosity arc. Avoid **visual spectacle for its own sake**: spectacle without a question is a screensaver, not a hook. - -## Pacing & Transition Guidance - -The natural throughline of a concept-explainer is **one shared visual** — the diagram, the analogy object, the term card — that _grows_ across the mechanism scenes. That growing element is exactly what a **continue run** is for: one worker owns the run and authors the visual continuity itself. - -- **Where continue runs land:** across consecutive **mechanism** beats that share the same growing diagram/object, or from the **definition → first-mechanism** seam (scenes 3↔4 above) when the term card unfolds into the first moving part. A continue RUN can be **2-3 scenes in one worker (cap=3)** — use `continue` when 2-3 adjacent scenes share a growing element / continuous stage, `break` otherwise. The growing diagram usually justifies a run because progressive disclosure _is_ one element accreting detail. `morph` is now just a soft hint that the worker carries a shared element across a continue seam; `sharedMotif` (`"the concept diagram"`, `"the analogy object"`, `"the term card"`) names that element as a hint, but nothing validates the two against continuity. -- **Where break + Tier-B is right:** the **register shifts**. Scene 1→2 (question → grounding): `slide` or `dissolve`. Scene 2→3 (grounding → naming the concept): `cut` or `zoom` — a deliberate beat change that says "here's the answer." Mechanism → **example/analogy** (5→6): `dissolve` or `zoom`, because the analogy is a _new visual world_ (kitchen, city, river) handed to a new worker, not a continuation of the diagram. Example → "so what" (6→7): `slide` or `cut` to land the takeaway cleanly. - -Respect the rules: **scene 1 is always `break`.** A continue seam gets a short crossfade (smooths the same-worker cut); a break seam gets the intent-driven Tier-B type (`cut`/`slide`/`dissolve`/`zoom`). `intent` and `sharedMotif` are soft hints only — pick `continue` when 2-3 adjacent scenes genuinely share a growing element (≤3 per run), `break` otherwise. A growing diagram across scenes 4-5-6 can be one **3-scene continue run** owned by a single worker; a 4th scene must start a new run with a `break`. - -## Emotional-Beat Trajectory - -Use the inherited Emotional Beat Vocabulary. Concept-explainer rides the curiosity→clarity→confidence channel, drawing mostly from the **Pivot** and **Build** registers; it touches the **Negative valley** only lightly (cognitive, not emotional): - -| Scene | Beat | Note | -| ----------------- | --------------------------- | ------------------------------------------------------------------------ | -| hook | curiosity | the gap opens; sometimes _curiosity and surprise_ for a statistic hook | -| ground | curiosity / mild dissonance | "wait, why?" — keep it cognitive, not anxious | -| reveal (naming) | intrigue / clarity | the emotional peak; the name arrives as relief-of-the-question | -| mechanism | clarity / focus | one notch of understanding per beat; build, don't dazzle | -| example / analogy | clarity and recognition | the "oh, it's _like_ that" click — the strongest single beat of the film | -| "so what" | confidence / conviction | the idea is owned; ends settled, not urgent | - -Compound beats are strongest: _"intrigue and clarity"_ at the reveal, _"clarity and recognition"_ at the analogy. Avoid generic "interested" / "positive". Do not borrow PAS's anxiety→relief — an explainer that manufactures dread to sell understanding reads as clickbait. - -## Worked Example - -**Topic:** _How does compound interest actually work?_ (~60s, 7 scenes). Style: pin-and-paper. Throughline shared element: a **stack of paper coins** that grows. - -1. **The Snowball Question** · `hook` · _"Why does a little money left alone turn into a lot — without you adding a thing?"_ · **break / cut** (scene 1; placeholder) -2. **The Boring Savings Account** · `pain_point` · _"Most of us picture savings as a flat pile that just… sits there. It doesn't."_ · **break / slide** (push into the misconception) -3. **Meet Compound Interest** · `product_intro` · _"This is compound interest — interest that earns interest on the interest."_ · **break / zoom** (push through to the named answer) -4. **Year One: The First Layer** · `feature_showcase` · _"Year one, your money earns a little. That little gets added to the pile."_ · **continue / morph** — sharedMotif: `"the growing coin stack"` (term card unfolds into the first coin layer) -5. **Year Two: It Earns On Itself** · `feature_showcase` · *"Year two, you earn on the original *and* on last year's gain. The pile grows faster than before."* · **break / dissolve** (close the morph pair; quiet beat as the curve bends) -6. **The Rolling Snowball** · `benefit_highlight` · _"It's a snowball rolling downhill — every turn picks up more snow than the last, on its own."_ · **break / dissolve** (cut to a new analogy world: the hillside) -7. **Start Early, Not Big** · `branding` · _"So the trick was never how much you start with. It's how long you let it roll."_ · **break / slide** (land the durable takeaway) - -Notice: one new idea per scene; the concept named at scene 3; scenes 3-4 form one continue run on the growing coin stack (one worker, `morph` as a soft hint that it carries the stack across the seam), then `break` before the analogy jump; the analogy enters via `dissolve` into a new worker's new visual world; the arc runs curiosity → intrigue → clarity → recognition → conviction with no negative valley. `narrativeArchetype` for this file would be `"concept-explainer"` (or `"concept-explainer with mechanism showcase"` if the mechanism runs 3+ scenes). diff --git a/skills/faceless-explainer/phases/scriptwriting/structures/how-to-process/overview.md b/skills/faceless-explainer/phases/scriptwriting/structures/how-to-process/overview.md deleted file mode 100644 index b6c48a6a48..0000000000 --- a/skills/faceless-explainer/phases/scriptwriting/structures/how-to-process/overview.md +++ /dev/null @@ -1,100 +0,0 @@ -# How-To / Process - -## Core Logic - -Teach a sequence of **steps** that get the viewer from nothing to a finished outcome. Promise the result up front, justify why it is worth the effort, then walk the steps in order on a **consistent stage** — same surface, same framing, the artifact-being-built visibly accumulating — and close by showing the finished result and the one thing to remember. The persuasion is **procedural clarity**: the viewer trusts the method because they watched it assemble, step by visible step. There is no pain valley to climb out of; the engine is forward motion through numbered stages. - -This is the explainer analog of a sales walkthrough, but the "product" is the **process itself**. The artifact under construction (the recipe dish, the diagram, the spreadsheet, the config) is the throughline element and the natural shared motif for morphs between consecutive steps. - -## Emotional Arc Pattern - -``` -Curiosity ──▶ Motivation ──▶ Clarity ──▶ Momentum ──▶ Confidence ──▶ Satisfaction - (hook) (why) (Step 1) (Steps 2..N) (result) (takeaway) -``` - -A **steady ascent**, not a V-curve. The viewer never dips into anxiety; they move from "I want that outcome" to "oh, that's all it is" to "I could do this myself." Each step scene resolves a small uncertainty and hands momentum to the next. The signature feeling at the end is _earned competence_ — the opposite of being sold to. Some how-to topics open with a light valley ("most people get this wrong / it looks intimidating") to sharpen the relief of the first clean step, but keep it shallow: the structure's promise is _easy_, and a deep valley undercuts that. - -## Typical Scene Sequence - -`feature_showcase` is the workhorse type here — each step is a _showcase of the method doing one thing_. The validator requires at least one `feature_showcase` or `product_intro` scene; a how-to with 3+ steps satisfies this naturally. There is no real "product," so `product_intro` is repurposed as the **setup/ingredients** scene (what you start with), and `branding` as the closing **takeaway/principle** scene. - -| Order | `type` | Means here (explainer reading) | Approx % | Job | -| ----- | ----------------- | -------------------------------------------------------------------- | -------- | --------------------------------------------------------------------- | -| 1 | hook | Outcome promise — show the finished result or the payoff in one line | 8-12% | Make the viewer want the outcome; create the "show me how" gap | -| 2 | pain_point | Why it matters / why most get it wrong (shallow, optional) | 8-12% | Stakes — justify spending the next 60s; can be folded into the hook | -| 3 | product_intro | Setup — the starting materials / tools / prerequisites laid out | 8-12% | Establish the stage and the artifact in its "before" state | -| 4 | feature_showcase | **Step 1** — first concrete action, numbered, on the stage | 12-18% | Lowest-friction first move; prove the method is approachable | -| 5 | feature_showcase | **Step 2** — next action, artifact visibly advances | 12-18% | Build momentum; the artifact is now recognizably forming | -| 6 | feature_showcase | **Step 3..N** — continue numbered steps on the same stage | 12-18%ea | Each scene = exactly one step; never cram two steps into one scene | -| N+1 | benefit_highlight | Result / recap — the finished artifact, all steps visible at once | 10-15% | Payoff: the promise from scene 1 is now fulfilled and inspectable | -| N+2 | branding / cta | Takeaway — the one principle to remember, or "now go try it" | 8-12% | Crystallize the method into a portable rule; invite the viewer to act | - -Keep step scenes **uniform in length and framing** — visual consistency _is_ the signposting. A step that suddenly changes stage or runs 2× longer reads as "this one is hard," which contradicts the structure's promise. Always **number the steps on-screen** (Step 1 / Step 2 / 1·2·3): the count is the spine. Target 3-5 steps for a ~60-90s video (up to ~6-7 when you're using the full ~3 min); beyond that, merge adjacent steps or split into a `listicle`. - -## When to Use - -- The topic is genuinely **sequential** — order matters and step N depends on step N-1 (recipes, tutorials, setup guides, workflows, "how to do X"). -- There is a single, showable **outcome** the viewer wants. -- The artifact accumulates visibly, so morphs between steps have a real throughline. -- The audience is at the "I'm ready to do this, just show me how" stage (mid-intent). - -## When NOT to Use - -- The items have **no required order** — they're parallel tips or options (→ use `listicle`). -- You're explaining _what something is_ or _why it works_ rather than _how to make it_ (→ use `concept-explainer`). -- The journey is driven by a character or anecdote, not a procedure (→ use `story-explainer`). -- There are 8+ micro-steps with no natural grouping — the spine collapses into a checklist; merge or re-scope. - -## Hook Strategy Bias - -How-to lives or dies on the **outcome promise**. Favored hooks (from the hook taxonomy): - -- **Imagine / future-pacing** — show the viewer holding the finished result first: "By the end of this you'll have a one-page budget that updates itself." The strongest how-to hook; it sets the destination before the journey. -- **Rhetorical question** — "Ever wondered how to X in under five minutes?" — opens the curiosity gap the steps will close. -- **Shocking statistic** — "Most people spend 3 hours on this. Here's the 4-step version." — justifies the method by contrast with the slow way. -- **Category announcement** — name the method itself: "The two-pot method." — when the _technique_ is the memorable thing. - -Avoid deep **pain validation** openers — this is not PAS; lingering on frustration delays the promise and flattens the ascent. A one-line "most people overcomplicate this" is the most pain you want. - -## Pacing & Transition Guidance - -The artifact-being-built is the canonical **shared motif**, so consecutive steps are the prime location for **morph pairs**: the half-built thing in Step 2's exit frame _is_ the same thing in Step 3's entry frame, transformed by one action. This is the most polished seam available and it reinforces "same stage, continuous work." - -Apply the rules exactly: - -- **Scene 1 is always `break`** (placeholder `intent: cut`; no real opening transition). -- **continue runs (up to 3 scenes):** when the artifact carries continuously across 2-3 adjacent steps, group them as one `continue` run — a single worker owns all of them and authors the flow. `intent: morph` + `sharedMotif` (naming the artifact) are soft hints. A run is ≤3 scenes; a 4th step starts a new run with a `break`. -- Pattern the step run as continue-runs separated by Tier-B breaks: `continue-run(Step1,Step2,Step3) → break → continue-run(Step4,Step5) → break → …`. Group steps that share the acted-on artifact; start a new run (a `break`) when the stage resets. -- Use **Tier-B** at _register changes_, not between continuous steps: `slide` from setup into Step 1 (forward motion, "let's begin"); `cut` or `zoom` from the last step into the **result** recap (energy shift, pull back to see the whole); `dissolve` into the **takeaway** (reflective close). The hook→why and why→setup seams are typically `slide` or `cut`. -- The `sharedMotif` must be **load-bearing in both scenes** (the actual artifact, not a decorative numeral). The step counter incrementing is good supporting motion but the _thing being built_ is the morph subject. - -## Emotional-Beat Trajectory - -Lean **curiosity → clarity → confidence**, not anxiety → relief. Suggested per-slot beats (use the emotional-beat vocabulary; compound beats are strongest): - -- Hook: **curiosity** / "curiosity and aspiration" -- Why-it-matters: **motivation** (or a shallow **skepticism** if you open with "most get this wrong") -- Setup: **clarity** / "clarity and readiness" -- Step 1: **ease** / "ease and reassurance" (prove it's approachable) -- Steps 2..N: **momentum** → **confidence** (rising as the artifact forms; "confidence and control") -- Result/recap: **satisfaction** / "satisfaction and pride" -- Takeaway/CTA: **empowerment** / "motivation to act" - -The arc should _feel_ monotonically rising. Variation comes from the texture of each beat (ease vs. momentum vs. pride), not from a dip. - -## Worked Example - -**Topic:** _How to brew better coffee at home in 4 steps_ (≈70s, text + diagram visuals, pin-and-paper). - -1. **The Promise** — `hook` — _"Café-quality coffee, four steps, one machine you already own."_ — `break` / `cut` (scene 1 placeholder; a hand-drawn finished cup fades up). -2. **Why Yours Tastes Flat** — `pain_point` (shallow) — _"Most home coffee fails on one thing: control. Fix four variables and it's transformed."_ — `slide` / `break` (push left into the four-variable list). -3. **What You'll Need** — `product_intro` (setup) — _"Beans, a grinder, a scale, and hot water just off the boil."_ — `slide` / `break` (the four tools slide onto a paper workbench — the **stage**). -4. **Step 1 · Grind Fresh** — `feature_showcase` — _"Grind right before you brew. Medium-coarse, like coarse sand."_ — `slide` / `break` (the bean motif slides in over the workbench). -5. **Step 2 · Weigh & Wet** — `feature_showcase` — _"Sixteen grams of water for every gram of coffee. Bloom for thirty seconds."_ — `morph` / `continue` · sharedMotif: **the coffee grounds bed** (the ground pile from Step 1 morphs into the wetted bloom). -6. **Step 3 · Pour Slow** — `feature_showcase` — _"Pour in slow circles. Steady is everything."_ — `break` / `cut` (resets the pair; pour diagram cuts in). -7. **Step 4 · Time It** — `feature_showcase` — _"Aim for a three-minute brew. Too fast is sour, too slow is bitter."_ — `morph` / `continue` · sharedMotif: **the brew timer dial** (the pour arc from Step 3 morphs into a sweeping timer). -8. **Now Taste It** — `benefit_highlight` (result/recap) — _"Four variables, one great cup. Grind, weigh, pour, time."_ — `zoom` / `break` (pull back: all four numbered steps and the finished cup on one page). -9. **Tweak One Thing** — `branding` (takeaway) — _"Change one variable at a time and your coffee gets better forever."_ — `dissolve` / `break` (soft fade to the single closing principle). - -Continue runs land where the artifact carries through — (Step 1→2) grounds-bed, (Step 3→4) pour→timer — each a same-worker run separated by a `break`, and the recap `zoom` deliberately steps outside the step-stage to let the viewer see the whole method at once. diff --git a/skills/faceless-explainer/phases/scriptwriting/structures/listicle/overview.md b/skills/faceless-explainer/phases/scriptwriting/structures/listicle/overview.md deleted file mode 100644 index c002eda619..0000000000 --- a/skills/faceless-explainer/phases/scriptwriting/structures/listicle/overview.md +++ /dev/null @@ -1,93 +0,0 @@ -# Listicle — Promise the Number → Deliver N Punchy Items - -## Core Logic - -Promise a finite count up front ("5 ways to…", "3 myths about…", "the 4 signs of…") → deliver each item as its own compact, parallel-structured scene → close with a wrap, ranking, or single takeaway. The number is the contract: it caps the runtime, sets the viewer's expectation, and supplies the spine. Momentum comes from **parallel composition** — every item beat looks and sounds like its sibling (same layout skeleton, same script rhythm), so the count itself becomes the through-line. There is no agitation phase and no late reveal; the value is delivered immediately and repeatedly. The persuasion is **completion** — once the viewer is told there are five, they stay to collect all five. - -## Emotional Arc Pattern - -``` -Curiosity ──▶ Clarity (item 1) ──▶ Rhythm (items 2…N) ──▶ Satisfaction ──▶ Confidence - (hook) (first beat) (parallel cascade) (full set) (takeaway) -``` - -This is a **steady, segmented climb** — no V-curve, no negative valley. Each item is a self-contained micro-payoff: curiosity opens it, clarity closes it, and the running counter ticks one notch up. The arc accumulates by repetition rather than by escalating one continuous tension. A mild valley is _optional_ in myth-busting / "mistakes you're making" framings (each item briefly names a wrong-belief before correcting it), but the dominant feeling is **collect-and-confirm**, not anxiety→relief. - -## Typical Scene Sequence - -For a 5-item list (scale the body up or down with N; aim for 5-8 total scenes): - -| Order | `type` | What it means here (explainer) | Approx % | -| ----- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -| 1 | `hook` | **Promise the number.** State the count + the payoff ("5 habits that quietly drain your day"). Set the counter motif. | 8-12% | -| 2 | `feature_showcase` | **Item 1.** First point delivered in the parallel template. ("feature" = one list item / tip / claim, _not_ a product feature.) | 15-18% | -| 3 | `feature_showcase` | **Item 2.** Same skeleton, new content. Counter ticks 1→2. | 15-18% | -| 4 | `feature_showcase` | **Item 3.** Rule-of-three midpoint; often the strongest/most surprising item. | 15-18% | -| 5 | `feature_showcase` | **Item 4.** Maintain rhythm; vary the data-viz/typography so it doesn't read as a clone. | 12-15% | -| 6 | `benefit_highlight` | **Item 5 / the payoff item.** Land the final, highest-value point ("benefit" = the why-it-matters of the last item, the ranking winner, or the synthesizing insight). | 12-15% | -| 7 | `cta` | **Wrap / takeaway.** Recap the full set, name #1, or give one action. ("cta" = the closing nudge or single sentence to remember — explainers rarely "sell.") | 8-12% | - -Notes: - -- **All body items use `feature_showcase`** because each is a _demonstration of one discrete point_ — the closest enum fit. Promote the final or strongest item to `benefit_highlight` when you want the why-it-matters to land harder than the others (a ranked "#1" or a synthesizing takeaway). -- **`product_intro` is unused** in pure listicles (there is no product). The validator requires at least one `feature_showcase` _or_ `product_intro` scene — the item scenes satisfy this automatically. -- `social_proof` may replace one item when the list is evidence-driven ("3 studies that…"), and `branding` is generally skipped. - -## When to Use - -- The content is naturally enumerable: tips, do's/don'ts, "top N", comparisons, signs/symptoms, myth-busting, steps you can shuffle without breaking meaning. -- The source text already contains a count or an obvious set of parallel points. -- You want high information density and forward momentum over a single emotional journey. -- The audience wants to _collect_ takeaways quickly (skimmable, shareable explainers). - -## When NOT to Use - -- The points are sequential and order-dependent (each depends on the previous) → use **how-to-process**. -- There is one core idea to unpack in depth, not many parallel ones → use **concept-explainer**. -- The material is inherently a single human/narrative throughline → use **story-explainer**. -- N would be 2 or 8+: two items isn't a list (it's a comparison — fold into concept-explainer); eight+ items overwhelm a short explainer — cut to the best 4-6 (up to ~6-7 only if you're filling the full ~3 min), or split the topic. -- The items aren't genuinely parallel (forcing dissimilar ideas into identical templates feels mechanical). - -## Hook Strategy Bias - -Listicles live or die on the opening promise. From the hook taxonomy, the strongest fits: - -- **Category announcement** — name the list as the thing: "The 5 logical fallacies that win arguments." The count _is_ the hook. -- **Shocking statistic** — open on the number that justifies the list: "73% of these are made in the first 10 seconds. Here are the four." Statistic → enumeration is a tight pairing. -- **Rhetorical question** — pose the gap the list closes: "Why do some emails always get replies? Five reasons." -- **Imagine / future-pacing** — for aspirational lists: "Imagine never forgetting a name again — here are three tricks." - -Avoid pain-validation / visceral-metaphor openers (those belong to anxiety-led structures); the listicle hook is brisk and promissory, not heavy. **Always state or strongly imply the count in the hook** — a listicle whose number arrives late forfeits its central mechanic. - -## Pacing & Transition Guidance - -The default seam between items is **Tier-B**: each item is its own beat, a clean parallel restart, so most item→item transitions are `cut` or `slide` (`continuity: break`). Reach for `slide` to express "next point" directional momentum (the counter advancing left-to-right), `cut` for high-energy snap between equal siblings, `dissolve` when the visual register shifts (typography item → data-viz item), and `zoom` to push into a number or a single focal stat. Scene 1 is always `break`. - -**Where morph pairs naturally occur:** the **counter / number motif** is the listicle's built-in shared element — a running "1 of 5 → 2 of 5" badge, a progress dot row, or a recurring numeral frame that physically advances. When two adjacent items genuinely hand off that motif (the "3" digit morphs into the "4", or the progress bar fills from one segment to the next as the visual protagonist of _both_ scenes), use `intent: morph` (`continuity: continue`) with `sharedMotif: "the item counter"` (or `"the progress bar"`) as soft hints. This is the polished move — use it sparingly: - -- **Continue runs ≤3 scenes:** a listicle is mostly parallel items, so most seams are `break` (cut/slide). Use `continue` only where the counter/motif genuinely hands off across 2-3 adjacent items; a run is ≤3 scenes, then a `break`. `morph`/`sharedMotif` are soft hints for that one worker. -- Don't morph _every_ seam — the listicle's signature is the parallel **restart**, and too many continuous morphs blur the discrete-item feel that makes it a list. One or two counter-morph pairs across the body is the sweet spot; keep the rest Tier-B. -- The hook→item-1 seam is usually Tier-B (`zoom` into the first item, or `cut`), since item 1 establishes the template the rest restart from. - -## Emotional-Beat Trajectory - -Lean **curiosity → clarity → confidence**, accumulating per item rather than swinging through a valley: - -- **Hook:** `curiosity` (or `intrigue` when the count is surprising). -- **Items 1…N:** alternate `clarity` and `confidence`; sprinkle `intrigue` on a counter-intuitive item and `playfulness`/`ease` on a light one to keep the parallel cascade from flattening. Myth-busting variants may open each item on `skepticism` and resolve it to `clarity`. -- **Payoff item:** `confidence` or `reassurance` — the set feels complete. -- **Wrap/CTA:** `motivation` or `inevitability` — "now you know all five." - -Avoid a monotone arc: identical beats on every item read as a list of facts, not a journey. Vary the _texture_ of clarity (surprise on one, ease on another) even though the macro-feeling stays positive. - -## Worked Example - -**Topic:** "5 ways to fall asleep faster" (sleep-hygiene explainer, faceless, typography + simple data-viz). 7 scenes, ~60s. - -1. **Promise the Five** — `hook` — _"You'll waste 9 days a year just lying awake. Five fixes — starting tonight."_ — Transition: `break` / `cut` (scene 1 placeholder; counter badge "1 of 5" assembles on screen). -2. **Cool the Room** — `feature_showcase` — _"Drop the thermostat to 65. Your core temperature has to fall before sleep even begins."_ — Transition: `break` / `zoom` (push from the badge into item 1's thermometer graphic). -3. **Kill the Blue Light** — `feature_showcase` — _"Screens off an hour before bed — blue light tells your brain it's still noon."_ — Transition: `break` / `slide` LEFT (next-point momentum; counter ticks 1→2). -4. **The 4-7-8 Breath** — `feature_showcase` — _"Inhale four, hold seven, exhale eight. Repeat four times. It slows your heart on command."_ — Transition: `continue` / `morph`, `sharedMotif: "the item counter"` (the "2" numeral morphs into "3" as the breathing-timer ring draws — counter is the protagonist of both scenes). -5. **No Late Caffeine** — `feature_showcase` — _"Caffeine has a six-hour half-life. That 4pm coffee is still half-awake at 10."_ — Transition: `break` / `dissolve` (register shift to a decay-curve data-viz). -6. **Same Time, Every Day** — `benefit_highlight` — _"The single biggest lever: a fixed wake time. Anchor that, and the other four compound."_ — Transition: `break` / `slide` LEFT (final item; framed as the ranked #1 that ties the set together). -7. **Tonight's List** — `cta` — _"Cool, dark, breathe, cut the caffeine, fix your wake time. Pick one — start tonight."_ — Transition: `break` / `cut` (all five counter dots fill; recap of the full set as the takeaway). diff --git a/skills/faceless-explainer/phases/scriptwriting/structures/story-explainer/overview.md b/skills/faceless-explainer/phases/scriptwriting/structures/story-explainer/overview.md deleted file mode 100644 index f966d0dc06..0000000000 --- a/skills/faceless-explainer/phases/scriptwriting/structures/story-explainer/overview.md +++ /dev/null @@ -1,103 +0,0 @@ -# Story Explainer - -## Core Logic - -Explain a concept by living through it. Open on a relatable situation with a named character or scenario → let a real problem surface inside that situation → introduce the insight/concept as the turning point → show how it plays out → land the lesson. The viewer learns the idea because they watched it _matter_ to someone, not because it was defined for them. The concept earns its entrance only after the scenario has made the viewer want an answer. - -This is the only FE structure with a genuine **valley** — the scenario gets worse before the insight arrives. Use that valley deliberately; it is what makes the resolution legible. But the valley is _narrative tension_, not sales-anxiety: the viewer is curious about how it resolves, not afraid of a cost they'll bear. - -## Emotional / Cognitive Arc Pattern - -``` -Recognition ──▶ Tension ──▶ Insight ──▶ Clarity ──▶ Confidence - (scenario) (problem) (concept) (it plays out) (lesson) -``` - -A **shallow V**: dip into the problem-in-context, pivot on the concept, climb through resolution to the takeaway. Unlike a sales V-curve (anxiety → relief), the emotional spine is **curiosity → intrigue → clarity → conviction** — the negative beat is _tension/stuck_, never dread. The character (or recurring object) is the throughline; the audience's understanding rises as the character's situation resolves. - -## Typical Scene Sequence - -5–8 scene slots. `type` is from the fixed enum `hook | pain_point | product_intro | feature_showcase | benefit_highlight | social_proof | branding | cta`; for an explainer each is repurposed as noted. - -| Order | Type (enum) | Means here (explainer) | Approx % | Job | -| ----- | ------------------- | ----------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------- | -| 1 | `hook` | Scene-setting — drop the viewer into a concrete, relatable situation | 8–12% | Introduce the character/scenario; make the viewer recognize themselves | -| 2 | `pain_point` | The problem-in-context — the friction/stakes inside the scenario | 12–18% | Raise tension: what's going wrong, what's at stake, why the obvious move fails | -| 3 | `product_intro` | The turning point — the **insight / concept / mechanism** enters as the hero | 12–18% | Name the idea. This is the pivot; the concept arrives as the answer to the tension | -| 4 | `feature_showcase` | It plays out — the concept applied step-by-step inside the same scenario | 15–22% | Show the idea working on the character's actual problem (not abstract definition) | -| 5 | `benefit_highlight` | Why it works — the principle behind the resolution; what changed and why it matters | 12–18% | Generalize from this one case to the underlying truth | -| 6 | `social_proof` | _(optional)_ Corroboration — a second instance, a real number, a "this is common" | 8–12% | Show the lesson isn't a one-off; widen from the character to the pattern | -| 7 | `branding` / `cta` | The lesson — the one-sentence takeaway, or a "next time you see X, remember Y" | 10–15% | Crystallize the principle; optionally invite the viewer to apply it | - -The "insight enters" pivot (`product_intro`) is the structural heartbeat — it lands at **roughly 30–45%**. Don't reveal the concept in scene 1: a story explainer that defines the idea up front collapses into a `concept-explainer` and throws away the tension that makes the lesson stick. - -> `product_intro` here is **conceptual**, not a product. It names the idea/mechanism that resolves the scenario (e.g. "compound interest", "the bystander effect", "rubber-ducking"). `feature_showcase` = the concept _doing the work_; `benefit_highlight` = the generalizable principle. FE projects are text-only by default, so `assetCandidates` is `[]` on every scene unless a real file lives under `public/`. - -## When to Use - -- Case studies and "why X matters" topics — the stakes are easiest to feel through a single concrete instance. -- Behavior / psychology / finance / history — domains where an abstract principle is best taught through one person's situation. -- Any topic where the input already contains a protagonist, an example, or a "here's what happened" anecdote — let the source's narrative drive the structure. -- When the lesson is counterintuitive: a story disarms skepticism by letting the viewer reach the insight alongside the character. - -## When NOT to Use - -- A flat reference topic with no inherent scenario ("what is a hash map") → use `concept-explainer`. -- A sequence of independent items with no shared protagonist ("7 productivity apps") → use `listicle`. -- A literal procedure the viewer will follow themselves ("how to file taxes") → use `how-to-process`. -- Very short runtimes where you can't afford to spend 30% building a scenario before the payoff — the valley needs room to breathe. - -## Hook Strategy Bias - -From the hook taxonomy (`../../guide.md`), Story Explainer leans on hooks that _drop you into a moment_: - -- **Relatable scenario / character hail** — the native opener. "It's 11pm and Maya is still staring at the same paragraph." Concrete time, place, and a name beat any abstract claim. -- **Rhetorical question** — frame the scenario as a puzzle the viewer wants solved. "Why did the smartest person in the room make the worst call?" -- **Imagine / future-pacing** — when the scenario is hypothetical-but-vivid. "Imagine you're handed $1,000 and told not to touch it for 40 years." -- **Shocking statistic** — only as a _doorway into_ the scenario, not the scenario itself. "90% of these end in regret — here's one of them." Then immediately cut to the character. - -Avoid **category announcement** and pure **visual-spectacle** openers — naming the concept or leading with abstraction in scene 1 dissolves the tension this structure depends on. - -## Pacing & Transition Guidance - -Transition vocabulary and the continue-run model (continue = same worker, up to 3 scenes; `morph`/`sharedMotif` are soft hints) live in `../../guide.md` — follow them exactly. Story-Explainer specifics: - -- **Scene 1 is always `break`** (`intent: cut`, placeholder — no real opening transition). -- **The recurring motif is a built-in morph throughline.** A character avatar, an object the story orbits (a jar of coins, a locked door, a single chart line), or the protagonist's "state" is naturally co-present across adjacent scenes — that is exactly what a `continue` run is for: group those adjacent scenes under one worker so it authors the throughline. `morph`/`sharedMotif` are soft hints naming the recurring object. -- **Best morph seams:** - - `pain_point → product_intro` — the pivot. The motif that represented the _problem_ transforms into the thing that represents the _insight_ (the cluttered desk's single sticky note morphs into the lit idea). This is the most powerful seam in the structure; spend a morph pair here. - - `feature_showcase → benefit_highlight` — the same applied object (the jar now full) morphs from the concrete case into the principle. -- **Use Tier-B (`cut` / `slide` / `dissolve` / `zoom`) where the register shifts:** - - `hook → pain_point` — usually a `slide` or `dissolve`: same scenario, tension rising; no shared element transforms, so don't force a morph. - - Entering `social_proof` — a `cut` signals "stepping outside the story to the wider world." - - Into the final lesson — a `zoom` (pull back from the case to the principle) or a `dissolve` reads as "the camera rises above the story." -- **Continue runs ≤3 scenes:** group 2-3 adjacent scenes that share a recurring object/character (the jar, the curve, the protagonist) as one `continue` run owned by a single worker; separate runs with a `break`. Shape e.g. `break(1) → slide(2) → continue(3) → break(4) → continue(5) → break(6) → break(7)`. scene 1 is always `break`. - -## Emotional-Beat Trajectory - -Use the emotional-beat vocabulary (`../../guide.md`). Story Explainer is the one FE structure that earns a real **valley → resolution**, but keep it on the curiosity register, not the anxiety register: - -``` -curiosity → tension/stuck → intrigue → clarity → confidence → conviction - (scene) (problem) (pivot) (plays out) (principle) (lesson) -``` - -- Scene 1 `hook`: **curiosity** (or **recognition** — "that's me"). -- `pain_point`: **tension** / **stuck** / **frustration** — the narrative valley. This is the only place a mildly negative beat belongs. -- `product_intro` pivot: **intrigue** / **clarity** — the "oh" moment. -- `feature_showcase` / `benefit_highlight`: **clarity** → **confidence** → (compound) **understanding and satisfaction**. -- Final lesson: **conviction** / **resolve** — "I'll remember this," not "buy now." - -Write compound beats where two feelings are live ("intrigue and relief" at the pivot, "clarity and satisfaction" at the resolution). - -## Worked Example - -**Topic:** Why you should start saving in your 20s (compound interest). Recurring motif: **a coin jar**. - -1. **Two friends, one choice** — `hook` — "Sam and Alex both turned 25 this year. Sam starts putting $100 a month into a jar. Alex waits." · _Transition: `break` / `cut` (scene 1 placeholder)._ -2. **The 10-year head start** — `pain_point` — "By 35, Alex finally starts too — same $100 a month. Alex figures a decade is easy to make up." · _Transition: `break` / `slide` (tension rises inside the same scenario; no shared element morphs)._ -3. **Enter compounding** — `product_intro` — "But money doesn't grow in a line. It grows on what it already grew — that's compound interest." · _Transition: `continue` / `morph`, sharedMotif: "the coin jar" (Sam's jar of coins morphs into a curve climbing off its own height — the problem-object becomes the insight)._ -4. **The jars at 65** — `feature_showcase` — "At 65, Sam's jar holds far more than Alex's — even though Alex paid in nearly as much." · _Transition: `break` / `dissolve` (time-jump to the outcome; register shift)._ -5. **Time did the work, not the deposits** — `benefit_highlight` — "Sam's edge wasn't more money. It was more time for the growth to feed itself." · _Transition: `continue` / `morph`, sharedMotif: "the growth curve" (the two jars' curves morph into a single labeled principle)._ -6. **It's the same for everyone** — `social_proof` — "Run the math on any amount: the early start almost always wins." · _Transition: `break` / `cut` (step outside the two-friend story to the general case)._ -7. **The lesson** — `branding` — "The best time to start was a decade ago. The second best is today." · _Transition: `break` / `zoom` (pull back from the example to the takeaway)._ diff --git a/skills/faceless-explainer/phases/visual-design/effects-catalog.md b/skills/faceless-explainer/phases/visual-design/effects-catalog.md deleted file mode 100644 index cf7289bbed..0000000000 --- a/skills/faceless-explainer/phases/visual-design/effects-catalog.md +++ /dev/null @@ -1,60 +0,0 @@ -Reference these effects in `section_plan.md` by **name** (wrapped in backticks). The build agent (Phase 4) translates each name into the corresponding `hyperframes-animation/rules/.md` recipe. - -The actual source of truth checked by Phase 3 `validate-section.mjs` is the set of `.md` files that exist under **`hyperframes-animation/rules/`** (the validator builds a set from `readdirSync` on that directory), not this catalog. This catalog is the curated subset that planners should cite. Two differences: the rules directory contains two rules missing frontmatter (`css-marker-patterns`, `gsap-effects`, listed in the "Skipped" section at the end of this file). They pass validator because files exist under rules/, but they are not in this catalog, so **do not cite them**. They still resolve as real recipes in Phase 4, so validator intentionally does not block them. Normally, choose only from this catalog. - -## SVG & Icons - -| Effect | Description | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `svg-icon-enrichment` | Animate internal SVG elements (rotating needles, opening leaves, pulsing dots, dashed-line flow), making icons feel alive without replacing them. | -| `svg-path-draw` | Use stroke-dasharray and stroke-dashoffset to progressively draw SVG paths. | - -## Camera & Viewport - -| Effect | Description | -| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `camera-cursor-tracking` | Two-stage virtual camera that locks the viewport onto a moving focus point, with configurable initial position. | -| `coordinate-target-zoom` | Zoom into an off-center element by combining scale with counter-translation, so the target ends centered in the viewport. | -| `multi-phase-camera` | Sequential camera zoom with 2-3 distinct phases (pull back / focus / push in) plus continuous micro-drift for organic cinematic feel. | -| `viewport-change` | Virtual camera: transform a wrapper around all scene content to simulate zoom / pan / focus lock. Camera moves right -> world translates left. | - -## Interaction & Click - -| Effect | Description | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `cursor-click-ripple` | Animate a mouse cursor moving to a target, with scale-down press feedback and an outward ripple ring on click. | -| `physics-press-reaction` | Cursor + element press together through subtractive spring force: the cursor lands on the element, both compress, then release. Different from press-release-spring (which has no cursor). | -| `press-release-spring` | Tactile button press: linear compression, spring-based recovery, and layered feedback (shadow compression + release burst + background glow). | - -## Text & Typography - -| Effect | Description | -| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `3d-text-depth-layers` | Stack multiple offset text layers to create a 3D shadow / extrusion effect on large type - more impactful than CSS text-shadow because each layer is a full DOM element. | -| `asr-keyword-glow` | Keywords glow and scale when "spoken" - attack/sustain/release envelopes sync to each word timestamp. Even without real audio, hard-coded timing creates a narrator-emphasis effect. | -| `context-sensitive-cursor` | Cursor color and style change with the currently typed text segment - accent color on highlighted segments, dimmed on placeholders, etc. | -| `counting-dynamic-scale` | During count animation, font size grows with the count value, giving numbers increasing visual weight. | -| `discrete-text-sequence` | Replace whole text states at frame thresholds for nonlinear typing effects - typos, bulk add, pauses, backspace, simulated thinking. | -| `hacker-flip-3d` | Character-level 3D rotation plus random glyph substitution for a decrypted-reveal effect. | -| `vertical-spring-ticker` | Slot-machine-style vertical scrolling inside a masked container using additive spring physics - each spring contributes one scroll "step." | - -## Layout & 3D - -| Effect | Description | -| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| `3d-page-scroll` | Render an entire webpage as a tilted 3D card, then scroll it to reveal a specific region. | -| `ai-tracking-box` | Animated bounding box with L-shaped corners follows an oscillating path, simulating AI object detection / tracking. | -| `avatar-cloud-network` | Avatars distributed on an elliptical ring, connected to a central hub with SVG dashed lines - staggered social-proof "community" reveal. | -| `center-outward-expansion` | Elements start clustered at screen center, then expand outward to final positions driven by a shared progress value. | -| `orbit-3d-entry` | Elements flip in from 3D space, then settle into continuous elliptical orbits around a focal point. | -| `split-tilt-cards` | Two cards placed side by side with opposite Y-axis rotations, creating a symmetrical 3D split-screen layout for comparisons or paired features. | - -## Transition & Motion - -| Effect | Description | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `card-morph-anchor` | Container morphs size and border radius across shots, acting as a visual transition anchor. | -| `dynamic-content-sequencing` | Automatically computes timeline start/end times from content length + per-item duration; longer content receives more time without hard-coded numbers. | -| `reactive-displacement` | Physical collision: incoming element's spring drives outgoing element displacement - one source of truth creates causal motion. | -| `scale-swap-transition` | Coordinated shrink-out + spring-in between two elements, creating a morph-like transition without SVG path interpolation. | -| `sine-wave-loop` | Continuous breathing / idle ambient motion using trigonometry - keeps elements alive after entry. Pairs well with almost any entry rule. | diff --git a/skills/faceless-explainer/phases/visual-design/guide.md b/skills/faceless-explainer/phases/visual-design/guide.md deleted file mode 100644 index f9261c2005..0000000000 --- a/skills/faceless-explainer/phases/visual-design/guide.md +++ /dev/null @@ -1,343 +0,0 @@ -# Visual Design (Phase 3) - -Input story (Phase 2 - `narrator_scripts.json`) + brand design system (Phase 1b - `design-system/chunks/`). Design visual treatment and animation choreography for each scene, outputting `section_plan.md`. - -This guide describes **creative intent**, not code. The downstream build agent (`/hyperframes-core` + `/hyperframes-animation`) translates it into HTML composition + GSAP timeline. - -## Flow Overview - -1. **All inputs are already inlined in dispatch** — use them directly -2. Write `## Film Direction` once (the film-level invariants — §4.1), then for each scene: choose effects from `## Effects catalog` (timeline layering order; count rules in §2), decide Continuity, write anchor block + **lean delta prose** (≤150 words; §4.2); in prose, describe desired visual components by **role** ("a stat block", "a framed quote"), while the worker chooses concrete components from the `## Design chunks` library -3. Run validator until exit 0 - ---- - -## 1. Inputs - -### `narrator_scripts.json` - -- Scene-level: `sceneNumber`, `sceneName`, `narrativeIntent.{type, narrativeRole, keyMessage, persuasion, emotionalBeat}`, `transition.{continuity, intent, sharedMotif?, description}` (`continuity` copies directly to `**Continuity:**`; `intent` translates to `**Transition:**` registry type using the "Transition: translation" table only for `break` scenes; when `intent: morph` on a `continue` scene, `sharedMotif` is a prose hint for the carried element, not an anchor), `assetCandidates[]` (each has `path` + `description`), `estimatedDuration` (strip trailing `"s"` -> float) -- Top-level: `narrativeArchetype` + `emotionalArc`, which influence whole-film pacing - -### `## Design chunks` - Brand Input (inlined; do not read `design.html`) - -Chunks are split by Phase 1b `emit-chunks.mjs` and **already inlined in the dispatch `## Design chunks` block**: full `index.json` + actually present `composition-hints.md` / `voice.md` / `tokens.css` / `easings.js` (chunks absent from the preset have `*_file=null` and do not appear in the block). - -Plan does not touch `design.html` or component HTML bodies. - -> **Positioning (core):** `## Design chunks` is the brand's **style reference library**, not a contract for the plan. It only answers "what does this brand look like" - palette (tokens), motion curves (easings), DOM text register (voice), and a set of **paste-ready components**. Visual **authority lives in `## Effects catalog` (animation) and `## Design rules` (design judgment)**; chunks only make the result **look like this brand**. **Plan does not pre-cite components, declare surfaces, or filter components** - it describes desired structures by **role / purpose / intent** in prose, and Phase 4b worker chooses concrete components from the full library by visual judgment. - -Plan uses chunks in these ways: - -1. Inspect `chunks/index.json` (~1-2 KB) -> get `preset` name + component library list (`components[]`, each `{id, file}`). **Only use this to know what components exist in the preset**, so prose can refer to them by role ("use a stat-stamp-like number block"). No need to map each one, cite ids, or compute surfaces - worker chooses after seeing actual render. -2. Optionally inspect `chunks/composition-hints.md` (only when `index.json.hints_file != null`) -> preset's own **composition / material / color preferences** (background preference, 60-30-10 distribution, signature materials). Fold it into prose as style reference for palette / composition; worker uses it when implementing colors. This is taste guidance, **not** a hard "violation = render failure" contract. -3. Optionally inspect `chunks/tokens.css` (~1-2 KB) -> available role tokens in `:root` (`--canvas` / `--ink` / `--brand-*` / preset-private aliases like `--paper` / `--blue` / `--cream`) - informs descriptions of 30% middle layer and pain-scene palette. -4. Optionally inspect `chunks/easings.js` (~0.5 KB) -> whether role keys `EASE.entry / emphasis / exit / drift` are present, deciding which ease intents to cite in prose. -5. Optionally inspect `chunks/voice.md` (~0.5 KB, only when `voice_file != null`) -> this preset's DOM text register (strip / case / line breaks / inline ``...). **Worker receives full voice.md through a dedicated channel and applies it by default**, so plan **does not need** to promise it scene by scene; mention it only for a **special application / risk** in that scene (e.g. "hero resolves as one-line UPPERCASE stacked words"). Plan does not write rewritten English copy (that is worker work). - -No need to read `chunks/type-roles.md` -> named text role directory (worker lookup table for inline text styling). Plan does not cite role ids; it describes by role name ("hero display", "body lede"). - -**Do not read:** component HTML bodies (`chunks/components/.html`) - Phase 4b worker owns that. **Do not read** legacy `design.html` (replaced by chunks). - -**Plan references by role / purpose / intent, not literal values.** See §3 guidance: - -| Name this | Do not copy | -| ------------------------------------------------------ | ---------------------------------------------- | -| **Role** (canvas / surface / accent / ink) | concrete hex (`#e4ff97`) | -| **Purpose** (display / body / mono) | concrete font name (`Instrument Serif`) | -| **Intent** (`EASE.entry` / `DUR.med`) | concrete curve (`power3.out`) | -| **Component role** ("a stat block" / "a framed quote") | component id / internal HTML / ` @@ -404,7 +330,7 @@ ${headStyle.join("\n")} id="root" data-composition-id="main" data-start="0" - data-duration="${totalDuration}" + data-duration="${TOTAL}" data-width="${WIDTH}" data-height="${HEIGHT}" > @@ -421,31 +347,16 @@ ${body.join("\n")} writeFileSync(outPath, html); -// ---------- caption-overrides.json shim ---------- -// The captions runtime fetches this file at validate time; absence yields a -// noisy validate ✗ that previously sent finalize on a ~30s debug chase. An -// empty array is a no-op override list — semantically identical to absent — -// but the file existing silences validate. preflight-finalize.mjs writes the -// same shim defensively in case this engine is bypassed on a resume path. -const captionOverridesPath = join(hyperframesDir, "caption-overrides.json"); -let captionOverridesCreated = false; -if (!existsSync(captionOverridesPath)) { - writeFileSync(captionOverridesPath, "[]\n"); - captionOverridesCreated = true; -} - // ---------- summary ---------- console.log(`✓ wrote ${outPath}`); -console.log(` visual (track 0): ${visualClips.length} clip(s) for ${playOrder.length} scene(s)`); -console.log(` voice (track 10): ${voiceCount}`); -console.log(` bgm (track 11): ${bgmEmitted ? `yes (vol ${BGM_VOLUME})` : "no"}`); -console.log(` captions (track 12): ${captionsEmitted ? "yes" : "no"}`); -console.log( - ` sfx (track 20+): ${sfxEmitted}${sfx.length !== sfxEmitted ? ` (${sfx.length - sfxEmitted} skipped)` : ""}`, -); -console.log(` total duration: ${totalDuration}s`); -console.log(` @font-face: ${fontFaceCss ? `${fontFaceCss.length}B injected` : "none"}`); -if (captionOverridesCreated) console.log(` caption-overrides.json: created empty [] shim`); +console.log(` canvas: ${WIDTH}×${HEIGHT}`); +console.log(` frames (track 1): ${mounted.length}`); +console.log(` voice (track 10): ${voiceCount}`); +console.log(` bgm (track 11): ${bgmEmitted ? "yes" : "no"}`); +console.log(` captions (track 2): ${captionsEmitted ? "yes" : "no"}`); +console.log(` sfx (track 20+): ${sfxEmitted}`); +console.log(` assets staged: ${staged}/${wanted.size}`); +console.log(` total duration: ${TOTAL}s`); if (anomalies.length) { console.log(`\nanomalies (non-fatal):`); for (const a of anomalies) console.log(` - ${a}`); diff --git a/skills/faceless-explainer/scripts/audio.mjs b/skills/faceless-explainer/scripts/audio.mjs index bc643e00d8..5640fd1065 100644 --- a/skills/faceless-explainer/scripts/audio.mjs +++ b/skills/faceless-explainer/scripts/audio.mjs @@ -1,856 +1,253 @@ #!/usr/bin/env node -// Phase 2.5 — audio (deterministic replacement for the audio subagent). +// audio.mjs — product-launch audio ADAPTER. The TTS / BGM / SFX implementation +// no longer lives here: it is the shared engine at +// ../../hyperframes-media/scripts/audio.mjs. This file only (a) maps the +// product-launch model (SCRIPT.md frames + STORYBOARD.md music/sfx) into the +// engine's neutral audio_request.json, (b) converts the engine's id-keyed +// audio_meta back into the frame-keyed shape captions.mjs / assemble-index.mjs +// already consume, and (c) keeps the local `sync-durations` pass (it rewrites +// STORYBOARD.md, which is product-launch-specific). // -// Reads: narrator_scripts.json (Phase 2). -// Writes: assets/voice/scene_*.wav, assets/voice/scene_*_words.json, -// ./audio_meta.json, and (eventually) assets/bgm.wav inside the -// HyperFrames project root passed via --hyperframes. +// Three modes (unchanged CLI surface): +// (default) generate — engine --only tts,bgm. BGM mode is "retrieve" (strict: +// no HeyGen credential ⇒ skip, never a detached generate, since this +// workflow has no wait-bgm step). Runs in the background during Step 4. +// sync-durations — write real voice durations into STORYBOARD.md (local). +// fetch-sfx — engine --only sfx, merged into the existing meta (Step 5, +// after the frames' `sfx:` cues exist). // -// Performance contract: -// * Per-scene TTS is chained into per-scene transcribe (a scene's whisper run -// starts the moment its own TTS finishes — does NOT wait for sibling scenes). -// * BGM (Lyria) is spawned **detached** in parallel with voice work. This -// script exits as soon as voice + transcribe are done; BGM keeps rendering -// in the background. audio_meta.json sets `bgm_pending: true` so prep.mjs -// trusts the path and Phase 4c runs wait-bgm.mjs before assemble/render. -// * Local MusicGen fallback generates ONE ~28s seed clip (one generate() -// call, kept under the model's ~30s positional limit), then trims it down -// if the target is shorter, or loops it with short crossfades up to the -// target length. This avoids the seams of the old per-segment concatenation. -// -// BGM prompt inference: the script reads concatenated `script` + `keyMessage` -// fields from narrator_scripts.json (no tokens.json side file — Phase 1 -// hyperframes capture writes asset/section data into capture/extracted/ -// which this script doesn't need). Override with --bgm-prompt "..." if the -// auto-inferred mood is wrong. -// -// Usage: -// node audio.mjs \ -// --narrator-scripts ./narrator_scripts.json \ -// --hyperframes . \ -// --out ./audio_meta.json \ -// [--lyria-recipe /phases/audio/lyria-recipe.py] \ -// [--voice ] [--lang en] \ -// [--provider heygen|elevenlabs|kokoro] \ -// [--no-bgm] [--bgm-prompt ""] \ -// [--bgm-seed-seconds 28] +// node audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json +// node audio.mjs sync-durations --audio-meta ./audio_meta.json --storyboard ./STORYBOARD.md +// node audio.mjs fetch-sfx --storyboard ./STORYBOARD.md --hyperframes . -import { spawn, spawnSync } from "node:child_process"; -import { - closeSync, - existsSync, - mkdirSync, - mkdtempSync, - openSync, - readFileSync, - renameSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { homedir, tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseStoryboard } from "./lib/storyboard.mjs"; -import { scratchPath } from "./lib/scratch-dir.mjs"; +const HERE = dirname(fileURLToPath(import.meta.url)); +const DEFAULT_ENGINE = join(HERE, "..", "..", "hyperframes-media", "scripts", "audio.mjs"); -// ---------- argv ---------- -const argv = process.argv.slice(2); -function flag(name, def) { +const flag = (argv, name, def) => { const i = argv.indexOf(`--${name}`); - if (i < 0) return def; - if (i + 1 >= argv.length) return true; - const v = argv[i + 1]; - return v.startsWith("--") ? true : v; -} -function die(msg) { - console.error(`✗ audio.mjs: ${msg}`); - process.exit(1); -} - -const narratorPath = resolve(flag("narrator-scripts", "./narrator_scripts.json")); -const hyperframesDir = resolve(flag("hyperframes", ".")); -const outPath = resolve(flag("out", "./audio_meta.json")); -const lyriaRecipe = flag("lyria-recipe") ? resolve(flag("lyria-recipe")) : null; -const userVoice = typeof flag("voice") === "string" ? flag("voice") : null; -const userBgmPrompt = typeof flag("bgm-prompt") === "string" ? flag("bgm-prompt") : null; -const noBgm = flag("no-bgm") === true; -const userProvider = typeof flag("provider") === "string" ? flag("provider") : null; -const lang = typeof flag("lang") === "string" ? flag("lang") : "en"; -// Seed length for the local MusicGen path: generate ONE clip this long, then -// loop-with-crossfade up to the target (or trim down if the target is shorter). -// musicgen-small's positional limit is ~1500 tokens ≈ 30s, so we cap at 28s -// (1400 tokens) to keep a safety margin and avoid `IndexError: index out of range`. -const bgmSeedSecondsRaw = - typeof flag("bgm-seed-seconds") === "string" ? Number(flag("bgm-seed-seconds")) : 28; -const bgmSeedSeconds = - isFinite(bgmSeedSecondsRaw) && bgmSeedSecondsRaw > 0 - ? Math.min(Math.max(bgmSeedSecondsRaw, 10), 30) - : 28; - -// ---------- load .env ---------- -// Mirrors the CLI's loadEnvFile (packages/cli/src/capture/scaffolding.ts): -// walk up from hyperframesDir ≤ 5 dirs, first .env wins, shell env always -// takes priority (we never override an already-set key). -function loadEnvFromDir(startDir) { - let dir = resolve(startDir); - for (let i = 0; i < 5; i++) { - const envPath = join(dir, ".env"); - if (existsSync(envPath)) { - const txt = readFileSync(envPath, "utf8"); - for (const raw of txt.split("\n")) { - const line = raw.trim(); - if (!line || line.startsWith("#")) continue; - const eq = line.indexOf("="); - if (eq < 0) continue; - const key = line.slice(0, eq).trim(); - let val = line.slice(eq + 1).trim(); - if ( - (val.startsWith('"') && val.endsWith('"')) || - (val.startsWith("'") && val.endsWith("'")) - ) { - val = val.slice(1, -1); - } - if (key && !(key in process.env)) process.env[key] = val; - } - return; - } - const parent = resolve(dir, ".."); - if (parent === dir) return; - dir = parent; - } -} -loadEnvFromDir(hyperframesDir); - -// ---------- resolve HeyGen credential ---------- -// Mirrors the hyperframes CLI (packages/cli/src/auth: resolver.ts + store.ts + -// client.ts#buildAuthHeaders). First usable source wins: -// 1. $HEYGEN_API_KEY → X-Api-Key -// 2. $HYPERFRAMES_API_KEY → X-Api-Key (alias) -// 3. ~/.heygen/credentials (shared with heygen-cli / `hyperframes auth login`; -// $HEYGEN_CONFIG_DIR overrides the dir): -// oauth (unexpired) → Authorization: Bearer · else api_key → X-Api-Key -// · legacy single-line plaintext key → X-Api-Key -// Pure resolution (never throws); returns { headers } | { expired: true } | null. -function heygenCredential() { - const envKey = process.env.HEYGEN_API_KEY || process.env.HYPERFRAMES_API_KEY; - if (envKey) return { headers: { "X-Api-Key": envKey } }; - - const file = join(process.env.HEYGEN_CONFIG_DIR || join(homedir(), ".heygen"), "credentials"); - if (!existsSync(file)) return null; - const raw = readFileSync(file, "utf8").trim(); - if (!raw) return null; - if (!raw.startsWith("{")) return { headers: { "X-Api-Key": raw } }; - - const cred = JSON.parse(raw); - const oauth = cred.oauth; - if (oauth?.access_token) { - const expired = oauth.expires_at && new Date(oauth.expires_at).getTime() - 60_000 < Date.now(); - if (!expired) return { headers: { Authorization: `Bearer ${oauth.access_token}` } }; - if (!cred.api_key) return { expired: true }; - } - if (cred.api_key) return { headers: { "X-Api-Key": cred.api_key } }; - return null; -} - -// Headers for the HeyGen REST calls, or a clear error pointing at the fix. -function heygenAuthHeaders() { - const cred = heygenCredential(); - if (cred?.headers) return cred.headers; - if (cred?.expired) - die( - "HeyGen OAuth token expired — run `hyperframes auth refresh` (or `hyperframes auth login`)", - ); - die( - "no HeyGen credentials — set $HEYGEN_API_KEY, or run `hyperframes auth login` (writes ~/.heygen/credentials)", - ); -} - -// ---------- Step 1: bootstrap HyperFrames project root ---------- -if (!existsSync(hyperframesDir)) { - console.log(`HyperFrames project root missing → npx hyperframes init ${hyperframesDir}`); - const r = spawnSync( - "npx", - [ - "hyperframes", - "init", - hyperframesDir, - "--example", - "blank", - "--non-interactive", - "--skip-skills", - ], - { stdio: "inherit" }, - ); - if (r.status !== 0) die("npx hyperframes init failed"); -} -const voiceDir = join(hyperframesDir, "assets", "voice"); -mkdirSync(voiceDir, { recursive: true }); - -// ---------- Step 2: read inputs ---------- -if (!existsSync(narratorPath)) die(`narrator_scripts.json not found at ${narratorPath}`); -const narrator = JSON.parse(readFileSync(narratorPath, "utf8")); - -// story-design agents may embed inline tags in the script field: ..., -// ..., ..., .... These are creative-time -// annotations only — the Phase 4a.5 captions agent does NOT consume them -// (captions are derived directly from whisper word JSON). audio.mjs strips -// them before TTS so the provider doesn't speak the tag names. The strip is -// conservative: only known tag names; unknown markup is passed through (the -// agent would have to face the TTS pronouncing it). -const CAPTION_TAG_RE = /<\/?(em|brand|emph|cta)\b[^>]*>/gi; -function stripCaptionTags(s) { - return String(s).replace(CAPTION_TAG_RE, ""); -} - -const scenes = (narrator.scenes || []).map((s) => { - const dm = String(s.estimatedDuration ?? "0").match(/[\d.]+/); - return { - sceneNumber: s.sceneNumber, - sceneId: `scene_${s.sceneNumber}`, - script: stripCaptionTags(typeof s.script === "string" ? s.script : ""), - estimatedDuration: dm ? parseFloat(dm[0]) : 0, + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def; +}; +const pad2 = (n) => String(n).padStart(2, "0"); + +// SCRIPT.md → [{ frame, text }]. `## … (Frame N)` opens a line; `**key:**` rows +// are metadata; the indented block is the spoken text (the only TTS input). +function parseScript(md) { + const out = []; + let cur = null; + const flush = () => { + if (cur && cur.text.trim()) out.push({ frame: cur.frame, text: cur.text.trim() }); + cur = null; }; -}); -if (scenes.length === 0) die("no scenes in narrator_scripts.json"); -for (const s of scenes) { - if (!s.script.trim()) die(`${s.sceneId}: empty "script" field in narrator_scripts.json`); -} - -// BGM-inference corpus: concatenate every scene's narrative metadata so we can -// look for category keywords (SaaS / crypto / creative / fintech / etc.) and -// pick a matching Lyria prompt. Replaces the old tokens.json-based inference. -const bgmInferenceBlob = (() => { - const parts = [ - narrator.project || "", - narrator.narrativeArchetype || "", - narrator.emotionalArc || "", - ]; - for (const s of narrator.scenes || []) { - parts.push(s.sceneName || ""); - parts.push(stripCaptionTags(s.script || "")); - if (s.narrativeIntent) { - parts.push(s.narrativeIntent.narrativeRole || ""); - parts.push(s.narrativeIntent.keyMessage || ""); + for (const line of md.split(/\r?\n/)) { + const h = line.match(/^#{2,3}\s+.*?\(frame\s+(\d+)\)/i); + if (h) { + flush(); + cur = { frame: Number(h[1]), text: "" }; + continue; } - } - return parts.join(" ").toLowerCase(); -})(); - -// ---------- Step 3: provider detection ---------- -// Self-contained selection (no dependency on CLI provider plumbing): -// heygen ← $HEYGEN_API_KEY / $HYPERFRAMES_API_KEY / ~/.heygen/credentials -// (cloud REST, returns word timestamps; see synthesizeHeygen / heygenCredential) -// elevenlabs ← $ELEVENLABS_API_KEY + `pip install elevenlabs` (inline python) -// kokoro ← always (local, no key; via published `hyperframes tts`) -function heygenAvailable() { - return heygenCredential() !== null; -} -function elevenlabsAvailable() { - if (!process.env.ELEVENLABS_API_KEY) return false; - const r = spawnSync("python3", ["-c", "import elevenlabs"], { - stdio: "ignore", - }); - return r.status === 0; -} -// Lyria accepts either GEMINI_API_KEY or GOOGLE_API_KEY (OR-fallback). -function lyriaKey() { - return process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || ""; -} - -let provider = userProvider; -if (!provider) { - provider = heygenAvailable() ? "heygen" : elevenlabsAvailable() ? "elevenlabs" : "kokoro"; -} -if (!["heygen", "elevenlabs", "kokoro"].includes(provider)) - die(`invalid --provider "${provider}" (must be heygen | elevenlabs | kokoro)`); -if (provider === "heygen" && !heygenAvailable()) - die( - "provider=heygen but no HeyGen credentials — set $HEYGEN_API_KEY or run `hyperframes auth login`", - ); -if (provider === "elevenlabs" && !process.env.ELEVENLABS_API_KEY) - die("provider=elevenlabs but $ELEVENLABS_API_KEY is not set"); + if (!cur) continue; + if (/^\s*\*\*/.test(line)) continue; + const m = line.match(/^(?: {4,}|\t)(.+)$/); + if (m) cur.text += (cur.text ? " " : "") + m[1].trim(); + } + flush(); + return out; +} + +// Path of the engine's neutral meta — a stable sidecar so `--only` merges +// (generate then fetch-sfx) accumulate, while audio_meta.json holds the PL shape. +const neutralPath = (plOutPath) => join(dirname(plOutPath), "audio_engine_meta.json"); + +// Run the shared engine. Returns nothing; dies on a non-zero exit. +function runEngine({ request, hyperframesDir, neutral, only, extra = [] }, die) { + const reqPath = join(hyperframesDir, "audio_request.json"); + writeFileSync(reqPath, JSON.stringify(request, null, 2)); + const engine = process.env.HF_MEDIA_ENGINE || DEFAULT_ENGINE; + if (!existsSync(engine)) die(`media audio engine not found at ${engine} (set $HF_MEDIA_ENGINE)`); + const args = [ + engine, + "--request", + reqPath, + "--hyperframes", + hyperframesDir, + "--out", + neutral, + "--only", + only, + ...extra, + ]; + const r = spawnSync("node", args, { stdio: "inherit" }); + if (r.status !== 0) die(`media audio engine exited ${r.status}`); +} + +// Engine neutral meta (id-keyed) → product-launch meta (frame-keyed) consumed by +// captions.mjs / assemble-index.mjs. id is the zero-padded frame number. +function toProductLaunchMeta(neutral) { + const voices = (neutral.voices ?? []).map((v) => ({ + frame: Number(v.id), + path: v.path, + duration_s: v.duration_s, + words: (v.words ?? []).map((w) => ({ id: w.id, text: w.text, start: w.start, end: w.end })), + })); + const bgm = neutral.bgm + ? { + path: neutral.bgm.path, + volume: neutral.bgm.volume, + query: neutral.bgm.query ?? null, + duration_s: neutral.bgm.duration_s ?? null, + } + : null; + const sfx = (neutral.sfx ?? []).map((s) => ({ + frame: Number(s.id), + file: s.file, + offset_s: s.offset_s ?? 0, + duration_s: s.duration_s ?? 1, + volume: s.volume ?? 0.35, + })); + return { bgm, voices, sfx }; +} + +// ── generate (TTS + BGM) ──────────────────────────────────────────────────── +function runGenerate(argv) { + const die = (m) => { + console.error(`✗ audio generate: ${m}`); + process.exit(1); + }; + const hyperframesDir = resolve(flag(argv, "hyperframes", ".")); + const storyboardPath = resolve(flag(argv, "storyboard", join(hyperframesDir, "STORYBOARD.md"))); + const scriptPath = resolve(flag(argv, "script", join(hyperframesDir, "SCRIPT.md"))); + const outPath = resolve(flag(argv, "out", join(hyperframesDir, "audio_meta.json"))); + const userVoice = flag(argv, "voice", null); + const speed = Number(flag(argv, "speed", "1.0")) || 1.0; + + if (!existsSync(storyboardPath)) die(`STORYBOARD.md not found at ${storyboardPath}`); + const manifest = parseStoryboard(readFileSync(storyboardPath, "utf8")); + const g = manifest.globals; + + const lines = existsSync(scriptPath) + ? parseScript(readFileSync(scriptPath, "utf8")).map((l) => ({ + id: pad2(l.frame), + text: l.text, + })) + : []; + if (!lines.length) console.error("· no SCRIPT.md — silent film (BGM only)"); + + // BGM mood: storyboard `music:` → message → arc → default. `mode: retrieve` is + // strict here (no wait-bgm step downstream). + const query = (g.extra && g.extra.music) || g.message || g.arc || "calm cinematic underscore"; + const request = { + provider: "auto", + speed, + lines, + bgm: { mode: "retrieve", query, blob: g.message || "", arc: g.arc || "" }, + }; + if (userVoice) request.voice = userVoice; -let voiceId = - userVoice || - (provider === "elevenlabs" - ? "21m00Tcm4TlvDq8ikWAM" // Rachel (ElevenLabs default) - : provider === "kokoro" - ? lang === "en" - ? "am_michael" - : die( - "Kokoro non-English path requires explicit --voice (see /hyperframes-media references/tts.md)", - ) - : null); // heygen default resolved below — needs a starfish voice_id + const neutral = neutralPath(outPath); + runEngine({ request, hyperframesDir, neutral, only: "tts,bgm" }, die); -// HeyGen's /v3/voices/speech only accepts STARFISH voice_ids; a v2-catalog id -// (the old hardcoded default 1bd001e7…) is rejected with HTTP 400. With no -// --voice, auto-pick the first English public starfish voice. -if (provider === "heygen" && !voiceId) { - const vres = await fetch( - "https://api.heygen.com/v3/voices?engine=starfish&type=public&limit=50", - { - headers: heygenAuthHeaders(), - }, + const meta = toProductLaunchMeta(JSON.parse(readFileSync(neutral, "utf8"))); + writeFileSync(outPath, JSON.stringify(meta, null, 2)); + console.log( + `✓ audio generate: ${meta.voices.length} voice + ${meta.bgm ? "1 bgm" : "no bgm"} → ${outPath}`, ); - if (!vres.ok) die(`heygen voice list failed (HTTP ${vres.status})`); - const list = (await vres.json()).data ?? []; - const pick = list.find((v) => v.language === "English") ?? list[0]; - if (!pick) die("no public starfish voices available — pass --voice"); - voiceId = pick.voice_id; -} - -// ---------- Step 4: write narration → /scene_.txt ---------- -for (const s of scenes) { - writeFileSync(scratchPath(`${s.sceneId}.txt`), s.script); -} - -// ---------- Step 4b: pre-flight BGM-deps install (parallel with TTS) ---------- -// MusicGen via HuggingFace transformers (no audiocraft / xformers / PyAV — those -// don't build cleanly on Apple Silicon). If Lyria won't be used and deps are -// missing, kick off pip install now so it runs in the background while TTS is -// generating. We await the result in Step 5b before deciding whether to spawn BGM. -const BGM_PY_DEPS = ["transformers", "torch", "soundfile", "numpy"]; -const BGM_PY_PROBE = - "import transformers, soundfile, torch, numpy; from transformers import MusicgenForConditionalGeneration"; -let bgmDepsInstallPromise = null; -if (!noBgm && !(lyriaKey() && lyriaRecipe && existsSync(lyriaRecipe))) { - const probe = spawnSync("python3", ["-c", BGM_PY_PROBE], { stdio: "ignore" }); - if (probe.status !== 0) { - console.log( - `BGM: deps missing → pip install ${BGM_PY_DEPS.join(" ")} (background, parallel with TTS)…`, - ); - bgmDepsInstallPromise = new Promise((resolve) => { - const proc = spawn("pip", ["install", "-q", ...BGM_PY_DEPS], { stdio: "ignore" }); - proc.on("exit", (code) => { - if (code === 0) console.log("BGM: deps install complete ✓"); - else console.log(`BGM: deps install failed (exit ${code}) — BGM will be skipped`); - resolve(code === 0); - }); - proc.on("error", () => resolve(false)); - }); - } -} - -// ---------- Step 5: BGM variables + helpers ---------- -const bgmRelPath = "assets/bgm.wav"; -const bgmAbsPath = join(hyperframesDir, bgmRelPath); -let bgmEnabled = false; -let bgmReason = ""; -let bgmPid = null; -let bgmMeta = null; - -function bgmPyDepsAvailable() { - const r = spawnSync("python3", ["-c", BGM_PY_PROBE], { stdio: "ignore" }); - return r.status === 0; } -function inferBgmPrompt() { - if (userBgmPrompt) return userBgmPrompt; - const blob = bgmInferenceBlob; - - // --- Industry base --- - let base, bpm; - if (/\b(crypto|nft|web3|defi|token|blockchain|exchange|wallet|dao)\b/.test(blob)) { - base = "atmospheric electronic, deep bass, futuristic synths, restrained percussion"; - bpm = 100; - } else if (/\b(finance|fintech|bank|payment|invest|wealth|insurance|treasury)\b/.test(blob)) { - base = "calm cinematic, soft strings, subtle piano, restrained percussion"; - bpm = 92; - } else if (/\b(creative|agency|design|studio|art|brand|marketing|content)\b/.test(blob)) { - base = "playful electronic, warm pads, light percussion"; - bpm = 115; - } else { - // default: SaaS / tech / platform - base = "uplifting corporate tech, bright modern piano with synth pads"; - bpm = 108; - } - - // --- Archetype adjusts arc shape --- - const archetype = (narrator.narrativeArchetype || "").toLowerCase(); - const arc = (narrator.emotionalArc || "").toLowerCase(); - - // PAS: starts tense, resolves → build from minor to major feel - if (/\bpas\b|pain.agitate|pain.+solve/.test(archetype)) { - return `${base}, starts with subtle tension then builds to resolution, BPM ${bpm}, transitions from MINOR to MAJOR`; - } - // BAB / Future Pacing: visionary, ascending energy - if (/\bbab\b|before.after|future.pac|vision/.test(archetype)) { - return `${base}, cinematic and aspirational, steady build with rising energy, BPM ${bpm}, MAJOR`; - } - // Feature Cascade: fast momentum, no dip - if (/cascade|feature.benefit/.test(archetype)) { - bpm = Math.min(bpm + 10, 128); - return `${base}, energetic and driving, consistent momentum without slowdown, BPM ${bpm}, MAJOR`; - } - // Demo Loop: focused, clean, not distracting - if (/demo.loop|question.+answer/.test(archetype)) { - bpm = Math.max(bpm - 8, 88); - return `${base}, clean and focused, minimal arrangement to not distract from UI demo, BPM ${bpm}`; - } +// ── fetch-sfx ──────────────────────────────────────────────────────────────── +function runFetchSfx(argv) { + const die = (m) => { + console.error(`✗ audio fetch-sfx: ${m}`); + process.exit(1); + }; + const hyperframesDir = resolve(flag(argv, "hyperframes", ".")); + const storyboardPath = resolve(flag(argv, "storyboard", join(hyperframesDir, "STORYBOARD.md"))); + const outPath = resolve(flag(argv, "audio-meta", join(hyperframesDir, "audio_meta.json"))); + + if (!existsSync(storyboardPath)) die(`STORYBOARD.md not found at ${storyboardPath}`); + const manifest = parseStoryboard(readFileSync(storyboardPath, "utf8")); + + // Per-frame `sfx:` cues (comma-separated) → engine lines carrying only sfx. + const lines = []; + for (const f of manifest.frames) { + const names = (f.extra?.sfx ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (names.length && f.number != null) lines.push({ id: pad2(f.number), sfx: names }); + } + + const neutral = neutralPath(outPath); + const request = { lines, bgm: { mode: "none" } }; + // --only sfx is a MERGE, not an overwrite: the engine reads the existing neutral + // sidecar (audio_engine_meta.json) and recomputes only the sfx section, so the + // voices/bgm written by the earlier generate (--only tts,bgm) pass are preserved. + runEngine({ request, hyperframesDir, neutral, only: "sfx" }, die); + + const meta = toProductLaunchMeta(JSON.parse(readFileSync(neutral, "utf8"))); + writeFileSync(outPath, JSON.stringify(meta, null, 2)); + console.log(`✓ audio fetch-sfx: ${meta.sfx.length} SFX cue(s) → ${outPath}`); +} + +// ── sync-durations (local; rewrites STORYBOARD.md) ──────────────────────────── +function runSyncDurations(argv) { + const die = (m) => { + console.error(`✗ audio sync-durations: ${m}`); + process.exit(1); + }; + const hyperframesDir = resolve(flag(argv, "hyperframes", ".")); + const audioMetaPath = resolve(flag(argv, "audio-meta", join(hyperframesDir, "audio_meta.json"))); + const storyboardPath = resolve(flag(argv, "storyboard", join(hyperframesDir, "STORYBOARD.md"))); + if (!existsSync(audioMetaPath)) die(`audio_meta.json not found at ${audioMetaPath}`); - // --- Emotional arc as tiebreaker --- - if (/frustrat|anxiety|overwhelm|tension/.test(arc) && /relief|excite|triumph/.test(arc)) { - return `${base}, builds from understated tension to uplifting resolution, BPM ${bpm}, MINOR to MAJOR`; - } - if (/excit|awe|power|triumph/.test(arc)) { - return `${base}, energetic and confident, uplifting throughout, BPM ${bpm}, MAJOR`; + const meta = JSON.parse(readFileSync(audioMetaPath, "utf8")); + const durByFrame = new Map(); + for (const v of meta.voices ?? []) { + if (v.frame != null && v.duration_s) durByFrame.set(v.frame, v.duration_s); } - if (/trust|ease|clarity|reassur/.test(arc)) { - return `${base}, warm and reassuring, gentle momentum, BPM ${Math.max(bpm - 5, 85)}`; - } - - return `${base}, BPM ${bpm}, MAJOR`; -} -// ---------- Step 6: per-scene chained TTS → transcribe (parallel across scenes) ---------- -function spawnP(cmd, args, opts) { - return new Promise((resolve) => { - const p = spawn(cmd, args, { stdio: "ignore", ...opts }); - p.on("exit", (code) => resolve({ status: code ?? -1 })); - p.on("error", () => resolve({ status: -1 })); - }); -} - -const ELEVENLABS_PY = ` -import os, sys -from elevenlabs.client import ElevenLabs -from elevenlabs import save -client = ElevenLabs(api_key=os.environ["ELEVENLABS_API_KEY"]) -text = open(sys.argv[1]).read() -audio = client.text_to_speech.convert( - text=text, - voice_id=sys.argv[2], - model_id="eleven_multilingual_v2", - output_format="mp3_44100_128", -) -save(audio, sys.argv[3]) -`; - -// HeyGen TTS — inline, no CLI dependency. One REST call to -// api.heygen.com/v3/voices/speech returns audio_url + word_timestamps; we -// download, transcode mp3→wav (44.1k mono), and write the scene's words JSON -// directly from word_timestamps so the whisper pass is skipped (the "single -// call" caption path). Self-contained so the skill needs no provider plumbing -// in the published hyperframes CLI. -const HEYGEN_ENDPOINT = "https://api.heygen.com/v3/voices/speech"; - -async function synthesizeHeygen(s) { - const wordsAbs = join(hyperframesDir, `assets/voice/${s.sceneId}_words.json`); - const wavAbs = join(hyperframesDir, `assets/voice/${s.sceneId}.wav`); + // Read directly and handle ENOENT here, rather than an existsSync precheck — + // the check→write pair (write-back below) is a TOCTOU race CodeQL flags. + let storyboardRaw = ""; try { - const text = readFileSync(scratchPath(`${s.sceneId}.txt`), "utf8"); - const reqBody = { text, voice_id: voiceId, speed: 1.0 }; - if (lang !== "en") reqBody.language = lang; - const res = await fetch(HEYGEN_ENDPOINT, { - method: "POST", - headers: { ...heygenAuthHeaders(), "Content-Type": "application/json" }, - body: JSON.stringify(reqBody), - }); - if (!res.ok) return { status: -1 }; - const payload = await res.json(); - const inner = payload.data ?? payload; - if (!inner.audio_url) return { status: -1 }; - - const audioRes = await fetch(inner.audio_url); - if (!audioRes.ok) return { status: -1 }; - const bytes = Buffer.from(await audioRes.arrayBuffer()); - const td = mkdtempSync(join(tmpdir(), `hf-heygen-${s.sceneId}-`)); - const tmpAudio = join(td, "audio.mp3"); // ffmpeg detects true format from content - writeFileSync(tmpAudio, bytes); - const ff = spawnSync( - "ffmpeg", - ["-y", "-loglevel", "error", "-i", tmpAudio, "-ar", "44100", "-ac", "1", wavAbs], - { stdio: "ignore" }, - ); - rmSync(td, { recursive: true, force: true }); - if (ff.status !== 0 || !existsSync(wavAbs)) return { status: -1 }; - - // word_timestamps → scene__words.json ([{text,start,end}], scene-local) - const wts = inner.word_timestamps; - if (Array.isArray(wts)) { - const words = wts - .filter((w) => w && typeof w.word === "string" && isFinite(w.start) && isFinite(w.end)) - // Drop HeyGen's / boundary sentinels (no spoken text). - .filter((w) => !/^<.*>$/.test(w.word.trim())) - .map((w) => ({ text: w.word, start: w.start, end: w.end })); - if (words.length) writeFileSync(wordsAbs, JSON.stringify(words, null, 2)); - } - return { status: 0 }; + storyboardRaw = readFileSync(storyboardPath, "utf8"); } catch { - return { status: -1 }; - } -} - -async function ttsScene(s) { - const txt = scratchPath(`${s.sceneId}.txt`); - const wavRel = `assets/voice/${s.sceneId}.wav`; - - // HeyGen: inline REST (see synthesizeHeygen) — also writes the words JSON. - if (provider === "heygen") return synthesizeHeygen(s); - - // ElevenLabs: direct python SDK (no CLI dependency). - if (provider === "elevenlabs") { - const wavAbs = join(hyperframesDir, wavRel); - return spawnP("python3", ["-c", ELEVENLABS_PY, txt, voiceId, wavAbs], {}); - } - - // Kokoro: local model via the published hyperframes CLI. - const args = ["hyperframes", "tts", txt, "--voice", voiceId, "--output", wavRel]; - if (lang !== "en") args.push("--lang", lang); - return spawnP("npx", args, { cwd: hyperframesDir }); -} - -async function transcribeScene(s) { - // `npx hyperframes transcribe` writes a fixed `transcript.json` into its - // --dir. Parallel scenes would collide if they all wrote into - // assets/voice/transcript.json, so give each scene its own - // throwaway --dir and move the result into the canonical name afterwards. - const wavRel = `assets/voice/${s.sceneId}.wav`; - const model = lang === "en" ? "small.en" : "small"; - const td = mkdtempSync(join(tmpdir(), `hf-trans-${s.sceneId}-`)); - const args = ["hyperframes", "transcribe", wavRel, "--model", model, "--dir", td]; - if (lang !== "en") args.push("--language", lang); - const r = await spawnP("npx", args, { cwd: hyperframesDir }); - if (r.status === 0) { - const src = join(td, "transcript.json"); - const dst = join(hyperframesDir, `assets/voice/${s.sceneId}_words.json`); - if (existsSync(src)) { - try { - renameSync(src, dst); - } catch { - // cross-device fallback: read+write - try { - writeFileSync(dst, readFileSync(src)); - } catch {} - } + die(`STORYBOARD.md not found at ${storyboardPath}`); + } + const lines = storyboardRaw.split(/\r?\n/); + const FRAME_RE = /^#{2,3}\s+(?:frame|beat|scene)\b.*?(\d+)/i; + let curFrame = null; + let updated = 0; + for (let i = 0; i < lines.length; i++) { + const h = lines[i].match(FRAME_RE); + if (h) { + curFrame = Number(h[1]); + continue; } - } - try { - rmSync(td, { recursive: true, force: true }); - } catch {} - return r; -} - -async function runScene(s) { - const wordsRel = `assets/voice/${s.sceneId}_words.json`; - const wordsAbs = join(hyperframesDir, wordsRel); - // Clear any stale words file from a prior run: HeyGen re-writes it from - // word_timestamps; Kokoro/ElevenLabs leave it absent → whisper writes it. - rmSync(wordsAbs, { force: true }); - - const tts = await ttsScene(s); - if (tts.status !== 0) return { sceneId: s.sceneId, ttsOk: false }; - - // Skip the whisper pass when TTS already produced word timestamps (HeyGen). - if (!existsSync(wordsAbs)) { - await transcribeScene(s); - } - - let wordsNonempty = false; - if (existsSync(wordsAbs)) { - try { - const arr = JSON.parse(readFileSync(wordsAbs, "utf8")); - wordsNonempty = Array.isArray(arr) && arr.length > 0; - } catch { - wordsNonempty = false; + if (curFrame != null && durByFrame.has(curFrame)) { + const m = lines[i].match(/^(\s*[-*]\s+duration\s*:\s*).*/i); + if (m) { + lines[i] = `${m[1]}${durByFrame.get(curFrame)}s`; + durByFrame.delete(curFrame); + updated++; + } } } - return { - sceneId: s.sceneId, - ttsOk: true, - voicePath: `assets/voice/${s.sceneId}.wav`, - wordsPath: wordsNonempty ? wordsRel : "", - }; -} - -console.log(`provider: ${provider} voice: ${voiceId} lang: ${lang}`); -console.log(`spawning ${scenes.length} TTS+transcribe pipelines in parallel…`); -const t0 = Date.now(); -const results = await Promise.all(scenes.map(runScene)); -const elapsed = ((Date.now() - t0) / 1000).toFixed(1); -console.log(`voice work done in ${elapsed}s`); - -// ---------- Step 5a: ffprobe voice durations ---------- -function ffprobeDuration(path) { - const r = spawnSync( - "ffprobe", - ["-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", path], - { encoding: "utf8" }, - ); - if (r.status !== 0) return NaN; - return parseFloat(r.stdout.trim()); -} - -const scenesMap = {}; -const failedScenes = []; -let totalDuration = 0; -for (const r of results) { - if (!r.ttsOk) { - failedScenes.push(r.sceneId); - continue; - } - const dur = ffprobeDuration(join(hyperframesDir, r.voicePath)); - if (!isFinite(dur) || dur <= 0) { - failedScenes.push(r.sceneId); - continue; - } - scenesMap[r.sceneId] = { - voicePath: r.voicePath, - voiceDuration: parseFloat(dur.toFixed(3)), - wordsPath: r.wordsPath, - }; - totalDuration += dur; -} - -if (Object.keys(scenesMap).length === 0) { - const emptyAudioMeta = { - tts_provider: provider, - voice_id: voiceId, - bgm_provider: null, - bgm_enabled: false, - bgm_path: null, - bgm_pending: false, - bgm_log: null, - bgm_pid: null, - bgm_mode: null, - bgm_target_duration_s: null, - bgm_seed_duration_s: null, - bgm_loop_count: null, - total_duration_s: 0, - scenes: scenesMap, - }; - writeFileSync(outPath, JSON.stringify(emptyAudioMeta, null, 2)); - console.error( - `✗ audio.mjs: zero scenes got voice — wrote audio_meta.json with empty scenes map for orchestrator to decide`, - ); - process.exit(1); -} - -const bgmTargetDurationS = Math.max(1, totalDuration); - -// ---------- Step 5b: spawn BGM (after TTS — deps install may now be done) ---------- -if (bgmDepsInstallPromise) { - console.log("BGM: waiting for deps install to finish…"); - await bgmDepsInstallPromise; -} - -if (noBgm) { - bgmReason = "disabled by --no-bgm"; -} else if (lyriaKey() && lyriaRecipe && existsSync(lyriaRecipe)) { - // Path A: Lyria (cloud) - const totalS = bgmTargetDurationS; - const prompt = inferBgmPrompt(); - const log = scratchPath(`bgm-${Date.now()}.log`); - console.log(`BGM: launching Lyria (detached) — prompt: "${prompt.slice(0, 70)}…"`); - console.log(` log: ${log}`); - const fd = openSync(log, "w"); - const bgm = spawn( - "python3", - [ - lyriaRecipe, - "--output", - bgmAbsPath, - "--duration", - String(Math.max(1, totalS)), - "--prompt", - prompt, - ], - { detached: true, stdio: ["ignore", fd, fd] }, - ); - bgm.unref(); - closeSync(fd); - bgmEnabled = true; - bgmPid = bgm.pid; - bgmMeta = { - provider: "lyria", - mode: "detached-single", - pid: bgmPid, - log, - target_duration_s: Math.max(1, totalS), - }; -} else if (bgmPyDepsAvailable()) { - // Path B: MusicGen via HuggingFace transformers (local, free, no API key). - // pip install transformers torch soundfile numpy → facebook/musicgen-small (~300MB). - // MusicGen emits ~50 codec frames per second, so max_new_tokens ≈ duration_s × 50. - // - // Generate ONE seed clip in a single generate() call (kept ≤28s / 1400 tokens - // to stay under the decoder's ~30s positional limit, which otherwise fails - // with `IndexError: index out of range`). Then: - // * target ≤ seed → trim the seed down (with a tail fade-out), or - // * target > seed → loop the seed with short crossfades until we reach the - // target, so there are no hard per-segment seams. - const totalS = bgmTargetDurationS; - const prompt = inferBgmPrompt(); - const log = scratchPath(`bgm-${Date.now()}.log`); - const targetS = Math.max(1, totalS); - const seedS = Math.min(bgmSeedSeconds, 30); - const loops = targetS > seedS ? Math.ceil(targetS / seedS) : 1; + writeFileSync(storyboardPath, lines.join("\n")); + const missing = [...durByFrame.keys()]; console.log( - `BGM: launching MusicGen via transformers (detached, local, ${seedS}s seed → ${targetS > seedS ? `crossfade-loop ×${loops}` : "trim"} → ${targetS.toFixed(1)}s) — prompt: "${prompt.slice(0, 70)}…"`, + `✓ audio sync-durations: ${updated} frame duration(s) updated` + + (missing.length ? ` · no \`- duration:\` line for frame(s) ${missing.join(", ")}` : ""), ); - console.log(` log: ${log}`); - const fd = openSync(log, "w"); - const script = ` -import math -import os -import sys -import traceback -from pathlib import Path - -import numpy as np -import soundfile as sf -from transformers import MusicgenForConditionalGeneration, AutoProcessor - -prompt = ${JSON.stringify(prompt)} -out_path = ${JSON.stringify(bgmAbsPath)} -target_s = float(${targetS.toFixed(3)}) -seed_s = float(${seedS.toFixed(3)}) -token_rate = 50 -crossfade_s = 0.3 - -def apply_fade(arr, sr, fade_in_s=0.08, fade_out_s=0.5): - n_in = min(int(round(fade_in_s * sr)), arr.shape[0] // 2) - n_out = min(int(round(fade_out_s * sr)), arr.shape[0] // 2) - if n_in > 1: - arr[:n_in] *= np.linspace(0.0, 1.0, n_in, dtype="float32") - if n_out > 1: - arr[-n_out:] *= np.linspace(1.0, 0.0, n_out, dtype="float32") - return arr - -def loop_crossfade(seed, target_len, xf): - # Equal-power crossfade the seed onto itself until we cover target_len samples. - if seed.shape[0] >= target_len: - return seed[:target_len] - xf = min(xf, seed.shape[0] // 2) - if xf < 1: - reps = int(math.ceil(target_len / seed.shape[0])) - return np.tile(seed, reps)[:target_len] - t = np.linspace(0.0, 1.0, xf, dtype="float32") - fade_out = np.cos(t * (math.pi / 2)) - fade_in = np.sin(t * (math.pi / 2)) - out = seed.copy() - while out.shape[0] < target_len: - tail = out[-xf:] * fade_out - head = seed[:xf] * fade_in - out = np.concatenate([out[:-xf], tail + head, seed[xf:]]) - return out[:target_len] - -try: - Path(os.path.dirname(out_path)).mkdir(parents=True, exist_ok=True) - print(f"[musicgen] seed render target={target_s:.3f}s seed={seed_s:.3f}s", flush=True) - processor = AutoProcessor.from_pretrained("facebook/musicgen-small") - model = MusicgenForConditionalGeneration.from_pretrained("facebook/musicgen-small") - model.eval() - sr = int(model.config.audio_encoder.sampling_rate) - - # Only generate as much seed as we actually need (target may be < seed). - gen_s = min(seed_s, target_s) - tokens = max(1, int(math.ceil(gen_s * token_rate))) - print(f"[musicgen] generating seed: dur={gen_s:.3f}s tokens={tokens}", flush=True) - inputs = processor(text=[prompt], padding=True, return_tensors="pt") - audio = model.generate(**inputs, max_new_tokens=tokens) - seed = audio[0, 0].detach().cpu().numpy().astype("float32") - - # Normalize the seed to ~0.89 peak BEFORE looping. MusicGen output sits near - # full-scale, so the equal-power crossfade's brief energy bump at each loop - # join would otherwise push samples past 1.0 and clip. Headroom prevents it. - seed_peak = float(np.max(np.abs(seed))) - if seed_peak > 1e-6: - seed = seed * (0.89 / seed_peak) - - want_total = max(1, int(round(target_s * sr))) - if seed.shape[0] >= want_total: - final = seed[:want_total].copy() - print(f"[musicgen] trimmed seed to {want_total} samples", flush=True) - else: - xf = int(round(crossfade_s * sr)) - final = loop_crossfade(seed, want_total, xf) - print(f"[musicgen] crossfade-looped seed to {final.shape[0]} samples", flush=True) - - if final.shape[0] < want_total: - final = np.pad(final, (0, want_total - final.shape[0])) - else: - final = final[:want_total] - final = apply_fade(final, sr) - # Safety limiter: a residual peak >1.0 (rounding, fade edges) would clip on - # write. Scale the whole buffer down by the overshoot if it ever happens. - peak = float(np.max(np.abs(final))) - if peak > 1.0: - final = final / peak - sf.write(out_path, final, sr) - print(f"[musicgen] wrote {out_path} samples={final.shape[0]} sr={sr}", flush=True) -except Exception: - traceback.print_exc() - sys.exit(1) -`; - const bgm = spawn("python3", ["-c", script], { - detached: true, - stdio: ["ignore", fd, fd], - }); - bgm.unref(); - closeSync(fd); - bgmEnabled = true; - bgmPid = bgm.pid; - bgmMeta = { - provider: "musicgen", - mode: targetS > seedS ? "detached-seed-loop" : "detached-seed-trim", - pid: bgmPid, - log, - target_duration_s: Number(targetS.toFixed(3)), - seed_duration_s: seedS, - loop_count: loops, - }; -} else { - const depsHint = `pip install ${BGM_PY_DEPS.join(" ")}`; - bgmReason = !lyriaKey() - ? `$GEMINI_API_KEY/$GOOGLE_API_KEY not set; BGM deps not installed (${depsHint})` - : `--lyria-recipe not provided; BGM deps not installed (${depsHint})`; } -// ---------- Step 7: assemble audio_meta.json ---------- -const audioMeta = { - tts_provider: provider, - voice_id: voiceId, - bgm_provider: bgmMeta?.provider || null, - bgm_enabled: bgmEnabled, - bgm_path: bgmEnabled ? bgmRelPath : null, - bgm_pending: bgmEnabled && !existsSync(bgmAbsPath), - bgm_log: bgmMeta?.log || null, - bgm_pid: bgmMeta?.pid || null, - bgm_mode: bgmMeta?.mode || null, - bgm_target_duration_s: bgmMeta?.target_duration_s || null, - bgm_seed_duration_s: bgmMeta?.seed_duration_s || null, - bgm_loop_count: bgmMeta?.loop_count || null, - total_duration_s: parseFloat(totalDuration.toFixed(3)), - scenes: scenesMap, -}; - -writeFileSync(outPath, JSON.stringify(audioMeta, null, 2)); - -// ---------- Step 8: summary ---------- -console.log(`\n✓ wrote ${outPath}`); -console.log(` provider: ${provider} voice: ${voiceId}`); -console.log(` scenes voiced: ${Object.keys(scenesMap).length}/${scenes.length}`); -const transcribed = Object.values(scenesMap).filter((s) => s.wordsPath).length; -console.log(` scenes transcribed: ${transcribed}/${Object.keys(scenesMap).length}`); -console.log(` total voice duration: ${audioMeta.total_duration_s}s`); -if (bgmEnabled) { - const bgmBackend = - lyriaKey() && lyriaRecipe && existsSync(lyriaRecipe) - ? "Lyria" - : `MusicGen via transformers (local, ${bgmMeta?.seed_duration_s || "?"}s seed ${bgmMeta?.mode === "detached-seed-loop" ? `→ crossfade-loop ×${bgmMeta?.loop_count || "?"}` : "→ trim"})`; - console.log(` bgm: launched via ${bgmBackend} pid=${bgmPid} (detached, → ${bgmRelPath})`); - if (bgmMeta?.log) console.log(` log: ${bgmMeta.log}`); - if (audioMeta.bgm_pending) { - console.log(` bgm_pending=true; Phase 4c wait-bgm.mjs waits/checks before assemble`); - } else { - console.log(` bgm.wav already on disk`); - } -} else { - console.log(` bgm: disabled (${bgmReason})`); -} -if (failedScenes.length) { - console.log( - `\nfailed scenes (omitted from audio_meta — Phase 4a falls back to estimatedDuration):`, - ); - for (const id of failedScenes) console.log(` - ${id}`); -} +// ── dispatch ────────────────────────────────────────────────────────────────── +const sub = process.argv[2]; +if (sub === "sync-durations") runSyncDurations(process.argv.slice(3)); +else if (sub === "fetch-sfx") runFetchSfx(process.argv.slice(3)); +else runGenerate(process.argv.slice(2)); // default: generate diff --git a/skills/faceless-explainer/scripts/build-frame.mjs b/skills/faceless-explainer/scripts/build-frame.mjs new file mode 100644 index 0000000000..dabb9ff7d8 --- /dev/null +++ b/skills/faceless-explainer/scripts/build-frame.mjs @@ -0,0 +1,280 @@ +#!/usr/bin/env node +// build-frame.mjs — Step 2 design system in ONE command. The LLM only chooses a +// preset; this does the deterministic rest: copy the preset's FRAME.md → frame.md, +// remix its colors/typography onto the project's brand tokens, copy the preset's +// caption-skin.html, and self-validate. "Strict on brand" is deterministic, so it's +// a script, not LLM hand-editing (which mis-copies hex / breaks keys). +// +// node build-frame.mjs --preset capsule --hyperframes . +// [--tokens capture/extracted/tokens.json] [--preset-dir ] +// +// Remix rule — ONLY `colors:` values and `typography:` fontFamily change; keys, +// structure, geometry, and components are untouched: +// colors — map brand tokens onto the preset's keys BY ROLE: the ink-role key takes +// the brand ink (darkest/ink-named), the canvas-role key takes the brand +// canvas (lightest), and every other color is repainted with the nearest +// brand accent's hue+saturation while KEEPING its own lightness, so tint +// families (sun / sun-soft / haze) stay a family. Empty brand colors → the +// preset palette is kept (it is already a complete, good design). +// fonts — the preset's display family → the brand display font, its body family → +// the brand body font, wherever they appear. Empty brand fonts → kept. + +import { copyFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + brandRolesFromStats, + chroma, + lum, + parseColors, + parseFonts, + pickAccent, + semanticColors, + UA_DEFAULT_COLORS, +} from "./lib/tokens.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const argv = process.argv.slice(2); +const flag = (name, def) => { + const i = argv.indexOf(`--${name}`); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def; +}; +const die = (m) => { + console.error(`✗ build-frame: ${m}`); + process.exit(1); +}; + +const presetName = flag("preset", null); +const hyperframesDir = resolve(flag("hyperframes", ".")); +const presetDir = resolve( + flag("preset-dir", join(__dirname, "../../hyperframes-creative/frame-presets")), +); +const tokensPath = resolve(flag("tokens", join(hyperframesDir, "capture/extracted/tokens.json"))); + +if (!presetName) die("--preset is required"); +const presetFrame = join(presetDir, presetName, "FRAME.md"); +if (!existsSync(presetFrame)) { + const avail = existsSync(presetDir) + ? readdirSync(presetDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + : []; + die( + `no FRAME.md for preset "${presetName}" under ${presetDir}\n available: ${avail.join(", ")}`, + ); +} + +// ── HSL helpers (recolor = brand hue+sat, original lightness) ────────────────── +function hexToHsl(hex) { + const m = /^#?([0-9a-fA-F]{6})$/.exec(String(hex).trim()); + if (!m) return null; + const n = parseInt(m[1], 16); + const r = ((n >> 16) & 255) / 255, + g = ((n >> 8) & 255) / 255, + b = (n & 255) / 255; + const max = Math.max(r, g, b), + min = Math.min(r, g, b), + d = max - min; + let h = 0; + const l = (max + min) / 2; + const s = d === 0 ? 0 : l > 0.5 ? d / (2 - max - min) : d / (max + min); + if (d !== 0) { + h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4; + h *= 60; + } + return { h, s, l }; +} +function hslToHex(h, s, l) { + h = (((h % 360) + 360) % 360) / 360; + const hue = (p, q, t) => { + t = (t + 1) % 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + let r, g, b; + if (s === 0) { + r = g = b = l; + } else { + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hue(p, q, h + 1 / 3); + g = hue(p, q, h); + b = hue(p, q, h - 1 / 3); + } + const to = (x) => + Math.round(x * 255) + .toString(16) + .padStart(2, "0") + .toUpperCase(); + return `#${to(r)}${to(g)}${to(b)}`; +} +const hueDist = (a, b) => { + const d = Math.abs(a - b) % 360; + return d > 180 ? 360 - d : d; +}; + +// ── brand tokens ────────────────────────────────────────────────────────────── +let brandColors = []; +let brandFonts = []; +let brandColorStats = []; // rich per-color usage stats (areaBg / interactiveBg / textCount …) +if (existsSync(tokensPath)) { + try { + const t = JSON.parse(readFileSync(tokensPath, "utf8")); + brandColors = (t.colors ?? []) + .map((c) => (typeof c === "string" ? c : (c?.hex ?? c?.value ?? ""))) + .map((c) => String(c).trim()) + .filter((c) => /^#?[0-9a-fA-F]{6}$/.test(c)) + .map((c) => (c.startsWith("#") ? c : `#${c}`)); + brandFonts = (t.fonts ?? []) + .map((f) => (typeof f === "string" ? f : (f?.family ?? f?.name ?? ""))) + .map((f) => String(f).split(",")[0].replace(/['"]/g, "").trim()) + .filter(Boolean); + brandColorStats = Array.isArray(t.colorStats) ? t.colorStats : []; + } catch (e) { + die(`tokens.json parse: ${e.message}`); + } +} + +let md = readFileSync(presetFrame, "utf8"); +const presetColors = parseColors(md); +const summary = []; + +// ── color remix ─────────────────────────────────────────────────────────────── +if (brandColors.length && presetColors.length) { + const pr = semanticColors(presetColors); + // Brand roles: prefer the function-based reading of capture colorStats (canvas = + // largest background, accent = top interactive bg, ink = dominant contrasting text). + // Fall back to the legacy luminance/chroma heuristic only when stats are absent — + // but pick the accent via pickAccent either way so a UA-default link color never wins. + const br = + brandRolesFromStats(brandColorStats) ?? + (() => { + // strip UA-default link colors so a stray color can't become ink/canvas/accent + const clean = brandColors.filter((h) => !UA_DEFAULT_COLORS.has(h.toUpperCase())); + const s = semanticColors(clean.map((h, i) => [`c${i}`, h])); + return { + ink: s.ink, + canvas: s.canvas, + accent: pickAccent(brandColorStats, clean, [s.ink, s.canvas]) ?? s.accent, + accent2: s.accent2, + }; + })(); + if (!br.accent) die("accent 选取失败:品牌色里没有可用的强调色"); + if (chroma(br.accent) <= 40) { + console.warn( + ` ⚠ accent ${br.accent} 彩度很低 (${chroma(br.accent)}) — 确认这是品牌色而非中性/默认色`, + ); + } + // Map by LUMINANCE POLARITY, not by role name: the preset's darker neutral takes the + // brand's darker neutral, the lighter takes the lighter. So a dark-ground preset stays + // dark and a light-ground preset stays light — both land on the brand's real values, + // even when the brand's canvas is dark (dark-mode brand) and ink is light. + const darker = (a, b) => ((lum(a) ?? 0) <= (lum(b) ?? 0) ? a : b); + const prDark = darker(pr.ink, pr.canvas); + const prLight = prDark === pr.ink ? pr.canvas : pr.ink; + const brDark = darker(br.ink, br.canvas); + const brLight = brDark === br.ink ? br.canvas : br.ink; + const prAccentHsl = hexToHsl(pr.accent); + const prAccent2Hsl = hexToHsl(pr.accent2); + const newByKey = new Map(); + for (const [key, val] of presetColors) { + const ph = hexToHsl(val); + let next; + if (val === prDark) next = brDark; + else if (val === prLight) next = brLight; + else if (val === pr.accent) + next = br.accent; // primary accent → the EXACT brand color + else if (pr.accent2 !== pr.accent && val === pr.accent2) + next = br.accent2; // exact 2nd accent + else if (!ph) + next = val; // non-hex (rgba) → leave as-is + else { + // repaint the remaining tints: pick the brand accent whose preset counterpart is + // nearest in hue, then keep THIS color's own lightness so tint families stay families. + const useSecond = + pr.accent !== pr.accent2 && + prAccentHsl && + prAccent2Hsl && + hueDist(ph.h, prAccent2Hsl.h) < hueDist(ph.h, prAccentHsl.h); + const bh = hexToHsl(useSecond ? br.accent2 : br.accent); + next = bh ? hslToHex(bh.h, bh.s, ph.l) : val; + } + if (next !== val) newByKey.set(key, next); + } + // rewrite only the value of each colors: line; everything else byte-identical. + let inBlock = false; + md = md + .split(/\r?\n/) + .map((line) => { + if (/^colors:\s*$/.test(line)) { + inBlock = true; + return line; + } + if (inBlock && /^\S/.test(line)) inBlock = false; + if (!inBlock) return line; + const m = line.match(/^(\s+)([\w-]+):\s*["']?[^"'\n]*["']?\s*$/); + if (m && newByKey.has(m[2])) return `${m[1]}${m[2]}: "${newByKey.get(m[2])}"`; + return line; + }) + .join("\n"); + summary.push( + `colors: dark ${prDark}→${brDark}, light ${prLight}→${brLight}, accent ${pr.accent}→${br.accent}` + + ` (${newByKey.size}/${presetColors.length} keys repainted${brandColorStats.length ? ", via colorStats" : ""})`, + ); +} else { + summary.push( + brandColors.length + ? "colors: preset has no parseable colors — kept" + : "colors: no brand colors — preset palette kept", + ); +} + +// ── font remix ──────────────────────────────────────────────────────────────── +if (brandFonts.length) { + const pf = parseFonts(md); + const strip = (q) => (q ? q.replace(/^"|"$/g, "") : null); + const pDisplay = strip(pf.display); + const pBody = strip(pf.body); + const bDisplay = brandFonts[0]; + const bBody = brandFonts[1] ?? brandFonts[0]; + if (pDisplay && bDisplay) md = md.split(`"${pDisplay}"`).join(`"${bDisplay}"`); + if (pBody && pBody !== pDisplay && bBody) md = md.split(`"${pBody}"`).join(`"${bBody}"`); + summary.push(`fonts: display ${pDisplay}→${bDisplay}, body ${pBody}→${bBody}`); +} else { + summary.push("fonts: no brand fonts — preset fonts kept"); +} + +// ── write frame.md ──────────────────────────────────────────────────────────── +const framePath = join(hyperframesDir, "frame.md"); +writeFileSync(framePath, md); + +// ── copy caption-skin.html ──────────────────────────────────────────────────── +const presetSkin = join(presetDir, presetName, "caption-skin.html"); +let skinCopied = false; +if (existsSync(presetSkin)) { + copyFileSync(presetSkin, join(hyperframesDir, "caption-skin.html")); + skinCopied = true; +} + +// ── self-validate ───────────────────────────────────────────────────────────── +const outColors = parseColors(md); +if (outColors.length !== presetColors.length) { + die(`color keys changed (${presetColors.length}→${outColors.length}) — keys must be preserved`); +} +const outRoles = semanticColors(outColors); +const li = lum(outRoles.ink), + lc = lum(outRoles.canvas); +if (li != null && lc != null && li >= lc) { + die( + `ink (${outRoles.ink}, lum ${li.toFixed(0)}) is not darker than canvas (${outRoles.canvas}, lum ${lc.toFixed(0)}) — bad brand mapping`, + ); +} + +console.log(`✓ build-frame: ${presetName} → ${framePath}`); +for (const s of summary) console.log(` ${s}`); +console.log( + ` caption-skin.html: ${skinCopied ? "copied" : "preset ships none — captions will use the default pill"}`, +); +console.log(` self-check: keys preserved, ink darker than canvas ✓`); diff --git a/skills/faceless-explainer/scripts/captions.mjs b/skills/faceless-explainer/scripts/captions.mjs index 756054eb5f..72035382fa 100644 --- a/skills/faceless-explainer/scripts/captions.mjs +++ b/skills/faceless-explainer/scripts/captions.mjs @@ -1,1721 +1,456 @@ #!/usr/bin/env node -// captions.mjs — merged caption pipeline CLI. Dispatches by subcommand: -// group → (was build-captions.mjs) deterministic caption grouping -// usage: node captions.mjs group --group-spec ./group_spec.json -// --hyperframes . --tokens design-system/chunks/tokens.css -// --out ./caption_groups.json -// html → (was build-captions-html.mjs) deterministic caption-HTML builder -// usage: node captions.mjs html --hyperframes . --groups ./caption_groups.json -// --tokens design-system/chunks/tokens.css -// [--inference design-system/inference.json] -// [--out compositions/captions.html] -// [--skin caption-pill-karaoke] [--skin-file ] [--no-emit] -// keepout → (was check-caption-keepout.mjs) static caption keep-out gate -// usage: node captions.mjs keepout --group-spec ./group_spec.json -// --hyperframes . [--json] +// captions.mjs — build the captions sub-composition from STORYBOARD + audio_meta. // -// Each original file's body is wrapped verbatim in its own async function so -// its local const/function names stay function-local and never collide. The -// only edit inside each body: CLI args read from the passed-in `argv` param -// (the dispatcher passes process.argv.slice(3), i.e. args after the subcommand). - -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; -import { resolve, join, dirname } from "node:path"; -import { execFileSync } from "node:child_process"; -import { readDims, captionBand } from "./lib/dimensions.mjs"; - -// ===================================================================== -// group — was build-captions.mjs -// ===================================================================== -// Phase 4a.5 (engine) — deterministic caption grouping. No subagent. +// One mode: `build`. Reads STORYBOARD.md (frame order + durations → cumulative +// frame starts) + audio_meta.json (voices[].words, frame-relative) → absolute- +// timed caption groups → writes: +// compositions/captions.html — a self-contained sub-composition the index +// assembler mounts on its captions track (data-composition-id="captions"). +// caption_groups.json — the computed groups (debug / inspection / --out). +// caption-overrides.json — an empty `[]` shim (silences the captions runtime's +// validate-time fetch; only written when captions.html is). +// No narration / no words → legal skip: nothing written, assemble-index then omits +// the captions track (it keys off compositions/captions.html existence). // -// Owns the word-data half of the captions contract (clean / group / global-time -// / class + the non-overlap invariant). Its output, caption_groups.json, is the -// single source of grouping/timing truth consumed by the deterministic HTML -// builder build-captions-html.mjs (no LLM, no hand-authored spans). -// Color/contrast decisions are NOT made here — color-mix()/var() can only be -// resolved by a browser at render time, so the A-lite scene-background -// adaptation lives in the caption template's render-time + + +`; } -// ===================================================================== -// dispatcher -// ===================================================================== const sub = process.argv[2]; -const rest = process.argv.slice(3); -switch (sub) { - case "group": - await runGroup(rest); - break; - case "html": - await runHtml(rest); - break; - case "keepout": - await runKeepout(rest); - break; - default: - console.error("usage: node captions.mjs [args...]"); - process.exit(2); +if (sub === "build" || sub === undefined) runBuild(process.argv.slice(sub === "build" ? 3 : 2)); +else { + console.error( + "usage: node captions.mjs build [--storyboard …] [--audio-meta …] [--hyperframes .]", + ); + process.exit(2); } diff --git a/skills/faceless-explainer/scripts/check-compositions.mjs b/skills/faceless-explainer/scripts/check-compositions.mjs deleted file mode 100644 index 8a661b247e..0000000000 --- a/skills/faceless-explainer/scripts/check-compositions.mjs +++ /dev/null @@ -1,530 +0,0 @@ -#!/usr/bin/env node -// check-compositions.mjs — Step 7 finalize preflight harness -// -// Runs after all Step 6 workers return and before Step 7 finalize starts assembling index.html. -// Catches historical worker bugs (finalize used to spend 13 minutes on average -// in edit-and-retry debugging): -// -// 1. Wrapper-ancestor selector: CSS / JS selector written as `.-root .foo` -// / `.-root #foo`. Preview / snapshot works because the bundler keeps -// the wrapper, but `hyperframes render` uses the producer pipeline, which strips -// that wrapper, so every selector misses and the scene renders black or as raw DOM. -// Correct form: plain `.s-foo` / `#s-foo`; the runtime scoper adds host scope. -// 2. Self data-composition-id selector: CSS written as -// `[data-composition-id=""] { ... }` triggers the newer CLI -// `composition_self_attribute_selector` warning. Root styles should use `#root`. -// 3. Scene-root id selector: `#-root` is not a runtime contract. -// Root may only use `#root`; scene-internal elements use `#s-foo`. -// 4. Missing root contract: no `id="root"`, no `class="-root"`, no -// `data-composition-id`, no `data-duration`, or no `window.__timelines[...]`. -// 5. Asset references a file absent from /public/: the worker invented -// or misspelled the basename. -// -// Usage: -// node check-compositions.mjs --hyperframes . --group-spec ./group_spec.json -// -// Exit codes: -// 0 = all compositions pass. stdout prints the summary. -// 1 = one or more fatal violations. stderr lists per-scene, per-rule failures; the -// orchestrator should re-dispatch affected workers instead of patching in finalize. - -import { existsSync, readFileSync, statSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; - -const argv = process.argv.slice(2); -const flag = (name, def) => { - const i = argv.indexOf(`--${name}`); - return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def; -}; -const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - -const hyperframesDir = resolve(flag("hyperframes", ".")); -const groupSpecPath = resolve(flag("group-spec", "./group_spec.json")); -const compositionsDir = join(hyperframesDir, "compositions"); - -if (!existsSync(groupSpecPath)) { - console.error(`✗ group_spec.json not found at ${groupSpecPath}`); - process.exit(1); -} -if (!existsSync(compositionsDir)) { - console.error(`✗ compositions dir not found at ${compositionsDir}`); - process.exit(1); -} - -const groupSpec = JSON.parse(readFileSync(groupSpecPath, "utf8")); -const sceneIds = (groupSpec.groups || []).flatMap((g) => g.scene_ids || []); - -if (sceneIds.length === 0) { - console.error(`✗ no scene_ids found in group_spec.json`); - process.exit(1); -} - -// scene_id → group entry (for component-meta lookup) -const sceneEntries = new Map(); -for (const g of groupSpec.groups || []) { - for (const sid of g.scene_ids || []) { - sceneEntries.set(sid, g.scenes?.[sid] || {}); - } -} - -const visualTargets = - Array.isArray(groupSpec.visual_clips) && groupSpec.visual_clips.length > 0 - ? groupSpec.visual_clips.map((v) => ({ - id: String(v.id || ""), - file: String(v.file || `compositions/${v.id}.html`), - kind: v.kind || "scene", - sceneIds: Array.isArray(v.scene_ids) ? v.scene_ids : [], - })) - : sceneIds.map((sid) => ({ - id: sid, - file: `compositions/${sid}.html`, - kind: "scene", - sceneIds: [sid], - })); - -if (visualTargets.length === 0) { - console.error(`✗ no visual clips found in group_spec.json`); - process.exit(1); -} - -// Component metadata lookup — chunks/index.json carries rank / forbidden_with / -// trigger_signals / visual_role per component (written by emit-chunks.mjs). -// Keyed by component id, which equals the html file's basename. Empty when the -// preset hasn't migrated to the new schema; rank checks then silently skip. -const componentMeta = new Map(); -{ - // Resolve chunks/index.json off the first scene's design_chunks.tokens_file — - // tokens_file is required by prep.mjs so it's always present when chunks were - // emitted. Walk up from chunks/tokens.css → chunks/ → chunks/index.json. - const anyScene = [...sceneEntries.values()].find((e) => e.design_chunks?.tokens_file); - const tokensPath = anyScene?.design_chunks?.tokens_file; - if (tokensPath) { - const chunksDir = dirname(tokensPath); - const indexPath = join(chunksDir, "index.json"); - if (existsSync(indexPath)) { - try { - const index = JSON.parse(readFileSync(indexPath, "utf8")); - for (const c of index.components || []) { - componentMeta.set(c.id, { - rank: c.rank ?? null, - trigger_signals: c.trigger_signals || [], - forbidden_with: c.forbidden_with || [], - visual_role: c.visual_role || null, - }); - } - } catch { - // Malformed index.json — skip metadata-driven checks; let other gates flag the data issue - } - } - } -} - -// Given an absolute path like "/.../chunks/components/hero.html", return the -// component id "hero". Returns null if path doesn't fit the expected shape so -// callers can skip safely. -function componentIdFromPath(absPath) { - if (typeof absPath !== "string") return null; - const m = absPath.match(/\/chunks\/components\/([a-z0-9-]+)\.html$/); - return m ? m[1] : null; -} - -const errors = []; // fatal: { sceneId, rule, detail } -const anomalies = []; // non-fatal: { sceneId, rule, detail } - -for (const target of visualTargets) { - const sceneId = target.id; - const compRel = target.file || `compositions/${sceneId}.html`; - const filePath = join(hyperframesDir, compRel); - - // Rule 0: file exists and is non-empty - if (!existsSync(filePath) || statSync(filePath).size === 0) { - errors.push({ - sceneId, - rule: "file", - detail: `${compRel} missing or empty`, - }); - continue; // Skip remaining rules for this scene. - } - - const html = readFileSync(filePath, "utf8"); - - // Rule 1: root div contract - // There must be exactly one root div with both id="root" and class="-root". - const rootDivRe = new RegExp( - `]*\\bid=["']root["'][^>]*\\bclass=["'][^"']*\\b${sceneId}-root\\b[^"']*["']`, - "i", - ); - const rootDivAltRe = new RegExp( - `]*\\bclass=["'][^"']*\\b${sceneId}-root\\b[^"']*["'][^>]*\\bid=["']root["']`, - "i", - ); - if (!rootDivRe.test(html) && !rootDivAltRe.test(html)) { - errors.push({ - sceneId, - rule: "root-contract", - detail: `no
found — both attributes must be on the same div`, - }); - } - - // Rule 1b: data-composition-id and data-duration on root - const hostIdRe = new RegExp(`data-composition-id=["']${sceneId}["']`); - if (!hostIdRe.test(html)) { - errors.push({ - sceneId, - rule: "data-composition-id", - detail: `no data-composition-id="${sceneId}" found`, - }); - } - if (!/data-duration=["'][\d.]+["']/.test(html)) { - errors.push({ - sceneId, - rule: "data-duration", - detail: `no data-duration="" found on root`, - }); - } - - // Rule 1c: window.__timelines[""] registration - const tlKeyRe = new RegExp(`window\\.__timelines\\s*\\[\\s*["']${sceneId}["']\\s*\\]\\s*=`); - if (!tlKeyRe.test(html)) { - errors.push({ - sceneId, - rule: "timeline-registration", - detail: `no window.__timelines["${sceneId}"] = ... line found (scene id must match verbatim)`, - }); - } - - // Recommended namespace prefix: scene_1 -> s1-; group_w2 -> g2- for shared nodes. - // Used for fix hints and namespace health checks. - const groupM = sceneId.match(/^group_w(\d+)$/); - const m = sceneId.match(/(\d+)/); - const sN = groupM ? `g${groupM[1]}-` : m ? `s${m[1]}-` : `s-`; - const sceneLocalHints = - groupM && target.sceneIds.length - ? `; logical-scene-only nodes may use ${target.sceneIds - .map((sid) => `.${sid.replace(/^scene_/, "s")}-foo`) - .join(" / ")}` - : ""; - const wrapperAncestor = `.${sceneId}-root`; // literal bug shape - const fixHint = `plain .${sN}foo / #${sN}foo (no ancestor selector)${sceneLocalHints}`; - - // Rule 2: CSS — - - - -${tmplMatch[1]} -`, - ); - - let page; - try { - page = await browser.newPage(); - await page.setViewport({ width: CANVAS_W, height: CANVAS_H, deviceScaleFactor: 1 }); - await page.goto(`file://${probePath}`, { waitUntil: "networkidle0", timeout: 30_000 }); - await page.evaluate(() => - document.fonts && document.fonts.ready ? document.fonts.ready.then(() => true) : true, - ); - const tlReady = await page.evaluate( - (sid) => - new Promise((res) => { - const start = Date.now(); - const tick = () => { - if (window.gsap && window.__timelines && window.__timelines[sid]) return res(true); - if (Date.now() - start > 5000) return res(false); - setTimeout(tick, 50); - }; - tick(); - }), - sid, - ); - if (!tlReady) { - scenesNoTimeline++; - if (!asJson) - console.error(` ⚠ ${sid}: timeline never registered — probing t=0 frame only`); - } - - const byPair = new Map(); // pair_uid -> { hit times, worst sample } - const cfg = { - decoRx: - "(?:^|[-_\\s])(?:bg|background|dot-?grid|mesh|gradient|swell|ambient|texture|noise|scanline|surface|overlay|halo|glow|frame|pin|corner-?pin|deco|star-?burst|burst|ring|stripe|rect|shadow|pulse|ripple|measure|probe|hidden|scrim|backdrop|veil|fog|grain|underline|divider|rule|accent|line|connector|arrow|caret|cursor)(?:[-_\\s]|$)", - visOpacity: VIS_OPACITY, - minSidePx: MIN_SIDE_PX, - minOverlapPx: MIN_OVERLAP_PX, - containFrac: CONTAIN_FRAC, - fullBleedFrac: FULL_BLEED_FRAC, - }; - for (const t of tlReady ? probeTimes : [0]) { - // seek(t, false): suppressEvents=false so onUpdate-driven content - // (discrete-text-sequence, ASR glow) is applied at the seeked frame. - // Then emulate the runtime's clip windowing: the probe page has no - // framework runtime, so [data-start]/[data-duration] media clips - // (image A swapped for image B in the same slot) would all render at - // once and false-positive as overlap. Hide clips outside their - // window — touching only elements WE hid (data-ovl-hid marker), so - // GSAP-driven visibility (autoAlpha) is never overridden. - await page.evaluate( - (sid, t) => { - const tl = window.__timelines && window.__timelines[sid]; - if (tl) tl.seek(t, false); - for (const el of document.querySelectorAll("[data-start]")) { - const start = parseFloat(el.getAttribute("data-start")); - if (Number.isNaN(start)) continue; - const durRaw = el.getAttribute("data-duration"); - const dur = durRaw == null ? NaN : parseFloat(durRaw); - const inWindow = t >= start && (Number.isNaN(dur) || t < start + dur); - if (!inWindow) { - if (!el.dataset.ovlHid) { - el.dataset.ovlHid = "1"; - el.style.visibility = "hidden"; - } - } else if (el.dataset.ovlHid) { - delete el.dataset.ovlHid; - el.style.visibility = ""; - } - } - }, - sid, - t, - ); - await page.evaluate( - () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))), - ); - await new Promise((r) => setTimeout(r, 60)); - const pairs = await page.evaluate(PROBE, cfg, CANVAS_W, CANVAS_H); - for (const p of pairs) { - const prev = byPair.get(p.pair_uid); - if (!prev) { - byPair.set(p.pair_uid, { sample: p, times: [t] }); - } else { - prev.times.push(t); - const area = (x) => x.overlap.width * x.overlap.height; - if (area(p) > area(prev.sample)) prev.sample = p; - } - } - } - - for (const { sample, times } of byPair.values()) { - const row = { - scene_id: sid, - file: compRel, - type: "foreground-overlap", - a: sample.a, - b: sample.b, - overlap: sample.overlap, - frac_of_smaller: sample.frac_of_smaller, - probe_times_s: times, - hits: times.length, - }; - if (times.length >= PERSIST_HITS || !tlReady) violations.push(row); - else transients.push(row); - } - scenesScanned++; - } finally { - if (page) await page.close().catch(() => {}); - } - } - } -} finally { - await browser.close().catch(() => {}); - for (const p of cleanupPaths) rmSync(p, { force: true }); -} - -// ---------- report ---------- -const result = { - status: "ok", - canvas: { width: CANVAS_W, height: CANVAS_H }, - probe_ratios: PROBE_RATIOS, - min_overlap_px: MIN_OVERLAP_PX, - scenes_scanned: scenesScanned, - scenes_skipped: scenesSkipped, - scenes_no_timeline: scenesNoTimeline, - violations, - transients, -}; - -if (asJson) { - console.log(JSON.stringify(result, null, 2)); - process.exit(violations.length === 0 ? 0 : 1); -} - -const fmtRow = (v) => - ` [${v.scene_id}] ${v.a.selector} (${v.a.kinds.join("+")}) × ${v.b.selector} (${v.b.kinds.join("+")})\n` + - ` a: (${v.a.rect.left},${v.a.rect.top} ${v.a.rect.width}×${v.a.rect.height}) b: (${v.b.rect.left},${v.b.rect.top} ${v.b.rect.width}×${v.b.rect.height})\n` + - ` overlap: ${v.overlap.width}×${v.overlap.height} at (${v.overlap.left},${v.overlap.top}), ${Math.round(v.frac_of_smaller * 100)}% of smaller, at t=${v.probe_times_s.join("/")}s (${v.hits}/${PROBE_RATIOS.length} probes)`; - -if (violations.length === 0) { - console.log( - `✓ check-overlap: ${scenesScanned} scene(s) probed at ${PROBE_RATIOS.join("/")} of duration — no foreground overlap` + - (scenesNoTimeline - ? ` (⚠ ${scenesNoTimeline} scene(s) probed at t=0 only — timeline missing)` - : ""), - ); - if (transients.length) { - console.log( - ` ${transients.length} transient crossing(s) (single-probe, mid-tween — not blocking):`, - ); - for (const v of transients) console.log(fmtRow(v)); - } - process.exit(0); -} - -console.error( - `✗ check-overlap: ${violations.length} foreground overlap(s) across ${new Set(violations.map((v) => v.scene_id)).size} scene(s) — no two foreground boxes may intersect (z-flattened)`, -); -for (const v of violations) console.error(fmtRow(v)); -if (transients.length) { - console.error( - ` ${transients.length} transient crossing(s) (single-probe, mid-tween — not blocking):`, - ); - for (const v of transients) console.error(fmtRow(v)); -} -console.error( - `\n → fix by root cause: move one box / put both in a flow (flex/grid) container / stagger their visible windows.` + - `\n There is no opt-out — DOM-nested children (text inside its own card) are already ignored; every other flagged pair must clear.`, -); -process.exit(1); diff --git a/skills/faceless-explainer/scripts/hoist-videos.mjs b/skills/faceless-explainer/scripts/hoist-videos.mjs deleted file mode 100644 index 75e45a5050..0000000000 --- a/skills/faceless-explainer/scripts/hoist-videos.mjs +++ /dev/null @@ -1,491 +0,0 @@ -#!/usr/bin/env node -// hoist-videos.mjs — deterministic host-root