{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scene-spec",
  "title": "Scene spec",
  "description": "Compose timed blocks with explicit portrait layouts, orientation overrides, and configurable safe areas.",
  "dependencies": [
    "remotion"
  ],
  "registryDependencies": [
    "@jbm/tokens",
    "@jbm/label",
    "@jbm/big",
    "@jbm/chip",
    "@jbm/stat-card",
    "@jbm/callout",
    "@jbm/bullet-list",
    "@jbm/scene",
    "@jbm/pop",
    "@jbm/code-card",
    "@jbm/brand",
    "@jbm/rebuild-screens",
    "@jbm/catalog",
    "@jbm/propagate",
    "@jbm/shelf"
  ],
  "files": [
    {
      "path": "registry/jbm/motion/compile.tsx",
      "content": "import * as React from \"react\"\nimport { color, stage, type Orientation } from \"../lib/tokens\"\nimport { Label } from \"../ui/label\"\nimport { Big } from \"../ui/big\"\nimport { Chip } from \"../ui/chip\"\nimport { StatCard } from \"../ui/stat-card\"\nimport { Callout } from \"../ui/callout\"\nimport { BulletList } from \"../ui/bullet-list\"\nimport { Brand } from \"../ui/brand\"\nimport { Scene } from \"./scene\"\nimport { Pop, Leave } from \"./pop\"\nimport { CodeCard } from \"./code-card\"\nimport { RebuildScreens } from \"./rebuild-screens\"\nimport { Catalog } from \"./catalog\"\nimport { Propagate } from \"./propagate\"\nimport { Shelf, Twice } from \"./shelf\"\nimport type { At, Block, SceneSpec, SafeArea } from \"./spec\"\n\n/**\n * Compile a SceneSpec into a Remotion scene for one orientation.\n *\n * Layout is a vertical flow inside the stage's safe area (tokens.stage): blocks stack top to bottom\n * with `gap` between them. Explicit composition options and orientation variants let authors\n * design for each frame; content is not automatically fitted. By default, a block changes shape: a stat-row is three cards\n * side by side in landscape and three row-mode cards stacked in vertical; text sizes step down.\n *\n * `resolve(phrase)` returns seconds from scene start for a narration phrase (the host's `rel`);\n * `t(s)` translates an on-screen string (the host's i18n). Both are injected so the compiler knows\n * nothing about a project's timing table or dictionary.\n */\nexport type Host = {\n  resolve: (phrase: string) => number\n  t?: (s: string) => string\n}\n\nconst pick = <T,>(v: T | { landscape: T; vertical: T }, o: Orientation): T =>\n  typeof v === \"object\" && v !== null && \"landscape\" in (v as object)\n    ? (v as { landscape: T; vertical: T })[o]\n    : (v as T)\n\n/** \"name\", \"name+0.2\", \"name-0.5\", or a number of seconds. */\nexport function resolveAt(at: At, spec: SceneSpec, host: Host): number {\n  if (typeof at === \"number\") {\n    if (!Number.isFinite(at))\n      throw new Error(`scene ${spec.id}: non-finite time`)\n    return at\n  }\n  const m = /^([\\w-]+?)\\s*([+-]\\s*(?:\\d+(?:\\.\\d+)?|\\.\\d+))?$/.exec(at.trim())\n  if (!m) throw new Error(`scene ${spec.id}: bad anchor \"${at}\"`)\n  const exact = spec.anchors?.[at.trim()]\n  const phrase = exact ?? spec.anchors?.[m[1]]\n  if (phrase === undefined)\n    throw new Error(`scene ${spec.id}: unknown anchor \"${m[1]}\"`)\n  const off =\n    exact === undefined && m[2] ? parseFloat(m[2].replace(/\\s+/g, \"\")) : 0\n  const result = host.resolve(phrase) + off\n  if (!Number.isFinite(result))\n    throw new Error(`scene ${spec.id}: non-finite anchor \"${at}\"`)\n  return result\n}\n\n/** Resolve explicit canvas insets while retaining legacy geometry for existing scenes. */\nexport function sceneGeometry(\n  orientation: Orientation,\n  safeArea: SafeArea = \"legacy\"\n) {\n  const s = stage[orientation]\n  const insets =\n    typeof safeArea === \"object\"\n      ? safeArea\n      : safeArea === \"legacy\"\n        ? { left: s.pad, right: s.pad, top: s.top, bottom: s.h - s.bottom }\n        : safeArea === \"social\" && orientation === \"vertical\"\n          ? { left: 72, right: 160, top: 160, bottom: 320 }\n          : { left: s.pad, right: s.pad, top: s.top, bottom: s.top }\n  if (\n    [insets.left, insets.right, insets.top, insets.bottom].some(\n      (v) => !Number.isFinite(v) || v < 0\n    ) ||\n    insets.left + insets.right >= s.w ||\n    insets.top + insets.bottom >= s.h\n  )\n    throw new Error(\"Invalid scene safe-area insets\")\n  return {\n    left: insets.left,\n    top: insets.top,\n    width: s.w - insets.left - insets.right,\n    height: s.h - insets.top - insets.bottom,\n  }\n}\n\nexport function SceneFromSpec({\n  spec,\n  orientation,\n  host,\n  showSafeArea = false,\n}: {\n  spec: SceneSpec\n  showSafeArea?: boolean\n  orientation: Orientation\n  host: Host\n}) {\n  const options = { ...spec.composition, ...spec.variants?.[orientation] }\n  const area = sceneGeometry(orientation, options.safeArea)\n  const layout = options.layout ?? \"flow\"\n  const blocks = options.blocks ?? spec.blocks\n  const subjectScale = options.subjectScale ?? 1\n  if (!Number.isFinite(subjectScale) || subjectScale <= 0)\n    throw new Error(\"subjectScale must be positive and finite\")\n  const ratio = options.headlineRatio ?? 0.25\n  if (!Number.isFinite(ratio) || ratio <= 0 || ratio >= 1)\n    throw new Error(\"headlineRatio must be between 0 and 1\")\n  if (layout === \"headline-illustration\" && (blocks.length !== 2 || spec.title))\n    throw new Error(\n      \"headline-illustration requires exactly two blocks and no title\"\n    )\n  const V = orientation === \"vertical\"\n  const W = area.width\n  const H = area.height\n  const tr = host.t ?? ((x: string) => x)\n  const at = (a: At) => resolveAt(a, spec, host)\n  const gap = pick(options.gap ?? spec.gap ?? 40, orientation)\n  const tone = (c?: \"accent\" | \"ink\") =>\n    c === \"ink\" ? color.ink : color.accent\n\n  const render = (\n    b: Block,\n    i: number,\n    height = H,\n    width = W\n  ): React.ReactNode => {\n    const body = renderBody(b, i, height, width)\n    return b.until === undefined || b.type === \"overlay\" ? (\n      body\n    ) : (\n      <Leave key={i} at={at(b.until)}>\n        {body}\n      </Leave>\n    )\n  }\n\n  const renderBody = (\n    b: Block,\n    i: number,\n    height: number,\n    W: number\n  ): React.ReactNode => {\n    switch (b.type) {\n      case \"big\":\n        return (\n          <Pop key={i} at={at(b.at)} from={b.from ?? \"up\"} style={{ width: W }}>\n            <Big\n              size={b.size ?? (V ? 96 : 120)}\n              color={b.color ? tone(b.color) : color.ink}\n              style={{\n                whiteSpace: \"pre-line\",\n                textAlign: b.align === \"center\" ? \"center\" : undefined,\n              }}\n            >\n              {tr(b.text)}\n            </Big>\n          </Pop>\n        )\n      case \"stat-row\": {\n        const n = b.items.length\n        if (n === 0) return null\n        const cw = V ? W : Math.floor((W - 40 * (n - 1)) / n)\n        return (\n          <div\n            key={i}\n            style={{\n              display: \"flex\",\n              flexDirection: V ? \"column\" : \"row\",\n              gap: V ? 30 : 40,\n            }}\n          >\n            {b.items.map((it, k) => (\n              <Pop key={k} at={at(it.at)} from=\"up\">\n                <StatCard\n                  row={V}\n                  w={cw}\n                  h={V ? 180 : 300}\n                  label={tr(it.label)}\n                  value={tr(it.value)}\n                  sub={it.sub ? tr(it.sub) : undefined}\n                  valueColor={tone(it.valueColor)}\n                />\n              </Pop>\n            ))}\n          </div>\n        )\n      }\n      case \"note\":\n        return (\n          <Pop key={i} at={at(b.at)} from=\"left\" style={{ width: W }}>\n            <Callout variant=\"note\" size={V ? 22 : 24}>\n              {tr(b.text)}\n            </Callout>\n          </Pop>\n        )\n      case \"callout\":\n        return (\n          <Pop key={i} at={at(b.at)} from=\"up\" style={{ width: W }}>\n            <Callout\n              variant={b.variant ?? \"accent\"}\n              size={V ? 30 : 34}\n              maxWidth={W}\n            >\n              {tr(b.text)}\n            </Callout>\n          </Pop>\n        )\n      case \"bullets\": {\n        const t0 = at(b.at)\n        const step = b.step ?? 0.6\n        return (\n          <BulletList\n            key={i}\n            items={b.items.map(tr)}\n            marker={b.marker}\n            size={36}\n            gap={V ? 22 : 16}\n            style={{ width: W }}\n            renderItem={(node, k) => (\n              <Pop at={t0 + k * step} from=\"left\" dist={14}>\n                {node}\n              </Pop>\n            )}\n          />\n        )\n      }\n      case \"chips\": {\n        const t0 = at(b.at)\n        return (\n          <div\n            key={i}\n            style={{ display: \"flex\", gap: 14, flexWrap: \"wrap\", width: W }}\n          >\n            {b.items.map((c, k) => (\n              <Pop key={k} at={t0 + k * (b.step ?? 0.35)} from=\"up\" dist={12}>\n                <Chip size={V ? 28 : 26} accent={b.accent} mono={b.mono}>\n                  {tr(c)}\n                </Chip>\n              </Pop>\n            ))}\n          </div>\n        )\n      }\n      case \"code\": {\n        const cc = {\n          green: color.codeGreen,\n          soft: color.soft,\n          dim: color.dim,\n        } as const\n        return (\n          <Pop key={i} at={at(b.at)} from=\"up\">\n            <CodeCard\n              charsPerSecond={b.charsPerSecond}\n              title={b.title ? tr(b.title) : undefined}\n              w={V ? W : Math.min(W, 1200)}\n              h={\n                layout === \"illustration\" || layout === \"headline-illustration\"\n                  ? height\n                  : V\n                    ? 520\n                    : 480\n              }\n              size={V ? 26 : 24}\n              lines={b.lines.map((l) => ({\n                t: tr(l.text),\n                at: at(l.at),\n                color: l.color ? cc[l.color] : undefined,\n              }))}\n            />\n          </Pop>\n        )\n      }\n      case \"spacer\":\n        return <div key={i} style={{ height: pick(b.h, orientation) }} />\n      case \"screens\":\n        return (\n          <RebuildScreens\n            key={i}\n            phoneScale={b.phoneScale}\n            w={W}\n            h={pick(\n              b.h ??\n                (layout === \"flow\"\n                  ? { landscape: 720, vertical: 1000 }\n                  : height),\n              orientation\n            )}\n            pieces={b.pieces.map((p) => ({ kind: p.kind, at: at(p.at) }))}\n            again={(b.again ?? []).map(at)}\n            sticker={\n              b.sticker\n                ? { text: tr(b.sticker.text), at: at(b.sticker.at) }\n                : undefined\n            }\n          />\n        )\n      case \"catalog\":\n        return (\n          <Catalog\n            key={i}\n            w={V ? W : Math.min(W, 1200)}\n            at={at(b.at)}\n            title={b.title ? tr(b.title) : undefined}\n            items={b.items.map((it) => ({\n              kind: it.kind,\n              label: tr(it.label),\n              at: at(it.at),\n            }))}\n            tokens={(b.tokens ?? []).map((tk) => ({\n              kind: tk.kind,\n              label: tr(tk.label),\n              at: at(tk.at),\n            }))}\n            tokensAt={b.tokensAt === undefined ? undefined : at(b.tokensAt)}\n            stamp={\n              b.stamp\n                ? { text: tr(b.stamp.text), at: at(b.stamp.at) }\n                : undefined\n            }\n          />\n        )\n      case \"propagate\": {\n        const opt = (a?: At) => (a === undefined ? undefined : at(a))\n        return (\n          <Propagate\n            key={i}\n            w={W}\n            h={pick(\n              b.h ??\n                (layout === \"flow\"\n                  ? { landscape: 760, vertical: 1040 }\n                  : height),\n              orientation\n            )}\n            at={at(b.at)}\n            label={\n              b.label\n                ? { text: tr(b.label.text), at: at(b.label.at) }\n                : undefined\n            }\n            targets={b.targets}\n            bug={opt(b.bug)}\n            fix={opt(b.fix)}\n            fixed={opt(b.fixed)}\n            recolor={opt(b.recolor)}\n            recolored={opt(b.recolored)}\n          />\n        )\n      }\n      case \"shelf\":\n        return (\n          <Shelf\n            key={i}\n            w={V ? W : Math.min(W, 1100)}\n            items={b.items.map((it) => ({\n              text: tr(it.text),\n              at: at(it.at),\n              tone: it.tone,\n            }))}\n          />\n        )\n      case \"twice\":\n        return (\n          <Twice\n            key={i}\n            w={V ? W : Math.min(W, 1000)}\n            at={at(b.at)}\n            second={at(b.second)}\n            strike={b.strike === undefined ? undefined : at(b.strike)}\n          />\n        )\n      case \"brand\":\n        return (\n          <Pop key={i} at={at(b.at)} from=\"up\" dist={20} style={{ width: W }}>\n            <Brand\n              size={b.size ?? (V ? 56 : 52)}\n              tagline={b.tagline ? tr(b.tagline) : undefined}\n            />\n          </Pop>\n        )\n      case \"overlay\":\n        return (\n          <div\n            key={i}\n            style={{\n              position: \"absolute\",\n              left: 0,\n              top: 0,\n              width: W,\n              height,\n              display: \"flex\",\n              flexDirection: \"column\",\n              justifyContent:\n                (b.valign ?? \"center\") === \"center\" ? \"center\" : undefined,\n              gap,\n            }}\n          >\n            {b.until === undefined ? (\n              b.blocks.map((block, index) => render(block, index, height, W))\n            ) : (\n              <Leave\n                at={at(b.until)}\n                style={{ display: \"flex\", flexDirection: \"column\", gap }}\n              >\n                {b.blocks.map((block, index) => render(block, index, height))}\n              </Leave>\n            )}\n          </div>\n        )\n    }\n  }\n\n  const subject = (block: Block, index: number, height: number) => (\n    <div style={{ width: W, height, position: \"relative\", flexShrink: 0 }}>\n      <div\n        style={{\n          width: W / subjectScale,\n          height: height / subjectScale,\n          transform: \"scale(\" + subjectScale + \")\",\n          transformOrigin: \"top left\",\n          display: \"flex\",\n          flexDirection: \"column\",\n          justifyContent: \"center\",\n          position: \"relative\",\n        }}\n      >\n        {render(block, index, height / subjectScale, W / subjectScale)}\n      </div>\n    </div>\n  )\n  if (layout === \"illustration\" && (blocks.length !== 1 || spec.title))\n    throw new Error(\"illustration requires exactly one block and no title\")\n  if (!Number.isFinite(gap) || gap < 0 || gap >= H)\n    throw new Error(\"gap must fit inside the safe area\")\n  return (\n    <Scene>\n      <div\n        style={{\n          position: \"absolute\",\n          left: area.left,\n          top: area.top,\n          width: W,\n          maxHeight: H,\n          height:\n            layout !== \"flow\" || (options.valign ?? spec.valign) === \"center\"\n              ? H\n              : undefined,\n          justifyContent:\n            layout === \"hero\" ||\n            layout === \"illustration\" ||\n            (options.valign ?? spec.valign) === \"center\"\n              ? \"center\"\n              : undefined,\n          display: \"flex\",\n          flexDirection: \"column\",\n          gap,\n        }}\n      >\n        {spec.title ? (\n          <Pop at={0.1} style={{ marginBottom: V ? 0 : 30 }}>\n            <Label>{tr(spec.title)}</Label>\n          </Pop>\n        ) : null}\n        {layout === \"headline-illustration\"\n          ? blocks.map((block, index) => {\n              const height = (H - gap) * (index === 0 ? ratio : 1 - ratio)\n              return (\n                <div\n                  key={index}\n                  style={{\n                    position: \"relative\",\n                    height,\n                    flexShrink: 0,\n                    display: \"flex\",\n                    flexDirection: \"column\",\n                    justifyContent: \"center\",\n                  }}\n                >\n                  {index === 0\n                    ? render(block, index, height)\n                    : subject(block, index, height)}\n                </div>\n              )\n            })\n          : layout === \"illustration\"\n            ? subject(blocks[0], 0, H)\n            : blocks.map((block, index) => render(block, index))}\n        {showSafeArea && (\n          <div\n            aria-hidden\n            style={{\n              position: \"absolute\",\n              inset: 0,\n              height: H,\n              outline: \"3px dashed \" + color.accent,\n              pointerEvents: \"none\",\n            }}\n          />\n        )}\n      </div>\n    </Scene>\n  )\n}\n",
      "type": "registry:component",
      "target": "src/jbm/motion/compile.tsx"
    },
    {
      "path": "registry/jbm/motion/spec.ts",
      "content": "/**\n * Scene spec: the YAML shape a scene is written in. One spec compiles to both orientations.\n *\n * Timing: every `at` is an anchor NAME (a key of `anchors`), optionally with an offset: \"posible+0.2\".\n * Anchors map to a phrase in the narration; the host resolves them with its own timing table\n * (see `compile.tsx`, `resolve`). Numbers are also accepted as literal seconds from scene start.\n *\n * Text: every string that is shown on screen passes through the host's `t()` so one spec serves\n * every language; keys are the Spanish source strings, as in the Jev project.\n */\nexport type At = string | number\n\nexport type StatItem = {\n  at: At\n  label: string\n  value: string\n  sub?: string\n  valueColor?: \"accent\" | \"ink\"\n}\n\n/** Any block may leave the screen on a cue: it fades out and drifts up from `until`. */\nexport type Exit = { until?: At }\n\nexport type Block = BlockBody & Exit\n\nexport type BlockBody =\n  | {\n      type: \"big\"\n      at: At\n      text: string\n      color?: \"accent\" | \"ink\"\n      size?: number\n      from?: \"up\" | \"scale\" | \"left\"\n      align?: \"left\" | \"center\"\n    }\n  | { type: \"stat-row\"; items: StatItem[] }\n  | { type: \"note\"; at: At; text: string }\n  | { type: \"callout\"; at: At; text: string; variant?: \"accent\" | \"ink\" }\n  | {\n      type: \"bullets\"\n      at: At\n      items: string[]\n      step?: number\n      marker?: \"arrow\" | \"dot\"\n    }\n  | {\n      type: \"chips\"\n      at: At\n      items: string[]\n      step?: number\n      accent?: boolean\n      mono?: boolean\n    }\n  | {\n      type: \"code\"\n      at: At\n      title?: string\n      charsPerSecond?: number\n      lines: { text: string; at: At; color?: \"green\" | \"soft\" | \"dim\" }[]\n    }\n  | { type: \"spacer\"; h: number | { landscape: number; vertical: number } }\n  /** Illustrated blocks (paper cut-out pieces of interface). See ../ui/ui-bits.tsx. */\n  | {\n      /** A phone screen built piece by piece, then rebuilt on new screens on every `again` cue. */\n      type: \"screens\"\n      pieces: { kind: \"button\" | \"input\" | \"card\"; at: At }[]\n      again?: At[]\n      phoneScale?: number\n      sticker?: { text: string; at: At }\n      h?: number | { landscape: number; vertical: number }\n    }\n  | {\n      /** A catalogue sheet of pieces (top row) and design-token glyphs (second row). */\n      type: \"catalog\"\n      at: At\n      title?: string\n      items: { kind: \"button\" | \"input\" | \"card\"; label: string; at: At }[]\n      tokens?: { kind: \"color\" | \"type\" | \"space\"; label: string; at: At }[]\n      /** When the sheet unfolds to show the token row (default: first token cue − 0.6 s). */\n      tokensAt?: At\n      stamp?: { text: string; at: At }\n    }\n  | {\n      /** One source card fanning out to a grid of screens; bug/fix/recolor cues propagate along the lines. */\n      type: \"propagate\"\n      at: At\n      label?: { text: string; at: At }\n      targets?: number\n      bug?: At\n      fix?: At\n      fixed?: At\n      recolor?: At\n      recolored?: At\n      h?: number | { landscape: number; vertical: number }\n    }\n  | {\n      /** A pile of wide library cards, each on its own cue; `tone: accent` for the one that is yours. */\n      type: \"shelf\"\n      items: { text: string; at: At; tone?: \"paper\" | \"accent\" | \"ink\" }[]\n    }\n  | {\n      /** A button, the same button again, and a vermilion cross over the second. */\n      type: \"twice\"\n      at: At\n      second: At\n      strike?: At\n    }\n  | { type: \"brand\"; at: At; tagline?: string; size?: number }\n  | {\n      /** A layer over the flow: its blocks stack in their own centred column inside the safe area, so a\n       *  late beat can take the middle of the screen after earlier blocks `until`-exit. */\n      type: \"overlay\"\n      blocks: Block[]\n      valign?: \"top\" | \"center\"\n    }\n\nexport type SafeArea =\n  | \"legacy\"\n  | \"full\"\n  | \"social\"\n  | { top: number; right: number; bottom: number; left: number }\nexport type SceneLayout =\n  \"flow\" | \"hero\" | \"headline-illustration\" | \"illustration\"\nexport type CompositionOptions = {\n  /** Insets are canvas pixels. Social is a house preset, not a platform guarantee. */\n  safeArea?: SafeArea\n  layout?: SceneLayout\n  /** Fraction reserved for the first block in headline-illustration (default .25). */\n  headlineRatio?: number\n  /** Illustration subject scale; its logical box shrinks to preserve the safe area. */\n  subjectScale?: number\n  gap?: number | { landscape: number; vertical: number }\n  valign?: \"top\" | \"center\"\n  /** Overrides replace the entire block list; anchors remain shared. */\n  blocks?: Block[]\n}\n\nexport type SceneSpec = {\n  id: string\n  composition?: Omit<CompositionOptions, \"blocks\">\n  variants?: Partial<Record<\"landscape\" | \"vertical\", CompositionOptions>>\n  /** Scene heading (Label, top-left). Omit for a headline-only scene. */\n  title?: string\n  /** First words the narrator says in this scene; pipeline/build_timing.py cuts scene boundaries here. Not needed on the first scene. */\n  starts?: string\n  /** anchor name → phrase spoken in this scene. */\n  anchors?: Record<string, string>\n  blocks: Block[]\n  /** Optional per-orientation gap between blocks (default 40). */\n  gap?: number | { landscape: number; vertical: number }\n  /** Vertical placement of the block stack inside the safe area (default top). */\n  valign?: \"top\" | \"center\"\n}\n\nexport type ScenesFile = { scenes: SceneSpec[] }\n",
      "type": "registry:component",
      "target": "src/jbm/motion/spec.ts"
    }
  ],
  "type": "registry:component"
}