{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "theme",
  "type": "registry:lib",
  "title": "Theme",
  "description": "Resolve brand inputs into coherent, readable scene tokens.",
  "registryDependencies": [
    "@vanillasky/video-config"
  ],
  "files": [
    {
      "path": "src/lib/theme/index.ts",
      "type": "registry:lib",
      "target": "vanillasky/theme/index.ts",
      "content": "/** Brand, color, type-treatment, density, and motion-style resolution. */\nexport * from \"../scene-templates/tokens\";\nexport * from \"./colors\";\n"
    },
    {
      "path": "src/lib/theme/colors.ts",
      "type": "registry:lib",
      "target": "vanillasky/theme/colors.ts",
      "content": "/** Export-safe color and contrast helpers shared by themes and backgrounds. */\n\n/** Convert a 3-, 6-, or 8-digit hex color plus opacity to rgba(). */\nexport function withOpacity(hex: string, opacity: number): string {\n  const match = hex.match(/^#([0-9a-f]{3,8})$/i);\n  if (!match) return hex;\n\n  const h = match[1];\n  let r: number;\n  let g: number;\n  let b: number;\n  if (h.length === 3) {\n    r = parseInt(h[0] + h[0], 16);\n    g = parseInt(h[1] + h[1], 16);\n    b = parseInt(h[2] + h[2], 16);\n  } else if (h.length >= 6) {\n    r = parseInt(h.slice(0, 2), 16);\n    g = parseInt(h.slice(2, 4), 16);\n    b = parseInt(h.slice(4, 6), 16);\n  } else {\n    return hex;\n  }\n  return `rgba(${r},${g},${b},${opacity})`;\n}\n\n/** Return whether a hex color is dark enough to prefer light foreground text. */\nexport function isColorDark(hex: string): boolean {\n  const match = hex.match(/^#([0-9a-f]{3,8})$/i);\n  if (!match) return true;\n\n  const h = match[1];\n  let r: number;\n  let g: number;\n  let b: number;\n  if (h.length === 3) {\n    r = parseInt(h[0] + h[0], 16);\n    g = parseInt(h[1] + h[1], 16);\n    b = parseInt(h[2] + h[2], 16);\n  } else if (h.length >= 6) {\n    r = parseInt(h.slice(0, 2), 16);\n    g = parseInt(h.slice(2, 4), 16);\n    b = parseInt(h.slice(4, 6), 16);\n  } else {\n    return true;\n  }\n  return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5;\n}\n\n/** Order two six-digit hex colors from darker to lighter. */\nexport function orderDarkToLight(a: string, b: string): [string, string] {\n  const luminance = (hex: string): number => {\n    const match = hex.match(/^#([0-9a-f]{6})$/i);\n    if (!match) return 0;\n    const h = match[1];\n    return (\n      0.299 * parseInt(h.slice(0, 2), 16) +\n      0.587 * parseInt(h.slice(2, 4), 16) +\n      0.114 * parseInt(h.slice(4, 6), 16)\n    );\n  };\n  return luminance(a) <= luminance(b) ? [a, b] : [b, a];\n}\n\n/** Pick a readable light or dark text color for a background. */\nexport function autoTextColor(background: string): string {\n  return isColorDark(background) ? \"#ffffff\" : \"#111111\";\n}\n"
    },
    {
      "path": "src/lib/scene-templates/tokens.ts",
      "type": "registry:lib",
      "target": "vanillasky/scene-templates/tokens.ts",
      "content": "/**\n * Canonical brand-token resolver — THE single derivation path from a video's\n * GlobalStyle (font + brandKit) to the resolved design tokens scene templates\n * and primitives consume.\n *\n * Before this module the default brand was defined in 19+ places with three\n * different answers (17 templates hardcoded \"#00e5a0\", milestone used\n * \"#3b82f6\", tweet hashes the author name), the font-stack idiom was\n * copy-pasted in 22 files, and shiftHue(accent, 50) was re-derived in 14.\n * Every fallback now lives here, exactly once.\n *\n * `deriveBrandContext` (src/lib/primitives/brand-context.ts) delegates to\n * this resolver, so templates and primitives can never drift.\n *\n * Documented divergences that stay OUTSIDE the canonical fallbacks:\n *  - social-tweet derives a per-author hue when no brand accent is set —\n *    an intentional feature, routed through `accentFallback`.\n *  - Platform-look templates (incoming-call, social-conversation,\n *    social-notification, brand-message) keep their OS/system font stacks;\n *    they imitate iOS/WhatsApp/X chrome, not the brand.\n */\n\nimport type { GlobalStyle } from \"../video-config\";\n\n// ─── Canonical defaults (the only place these values are defined) ──\n\nexport const TOKEN_DEFAULTS = {\n  /** Primary accent — CTA, highlights. */\n  accent: \"#00e5a0\",\n  /** Deepest background surface. */\n  surface: \"#0a0a14\",\n  /** Elevated card / panel surface. */\n  surface_elevated: \"#14152a\",\n  /** Primary text color. */\n  content: \"#ffffff\",\n  /** Muted text — labels, footers, supporting copy. */\n  muted: \"#a7a6b0\",\n  /** Primary sans font family (first name of the stack). */\n  font: \"Inter\",\n  /** Script accent font family for handwritten callouts. */\n  script_font: \"Caveat\",\n} as const;\n\n/**\n * The canonical template font stack: first family of `style.font`, backed by\n * OS-native sans fallbacks that render identically in preview and the\n * SVG-as-image export path.\n */\nexport function fontStack(styleFont: string | undefined): string {\n  return `${(styleFont || \"\").split(\",\")[0].trim() || TOKEN_DEFAULTS.font}, -apple-system, BlinkMacSystemFont, \"Helvetica Neue\", Helvetica, Arial, sans-serif`;\n}\n\n// ─── Resolved tokens ───────────────────────────────────────────────\n\n// ─── Style presets ─────────────────────────────────────────────────\n\n/**\n * Background families a preset can pick. Each resolves to a pure CSS\n * background string in `gradientBackground` — no CSS `filter`, which the\n * SVG export path cannot rasterize.\n */\nexport type BackgroundFamily = \"mesh\" | \"wash\" | \"spotlight\";\n\n/** Title placement a preset defaults to (SceneFrameVariant, minus no-title). */\nexport type PresetTitlePlacement = \"title-top\" | \"title-center\";\n\nexport interface TypeTreatment {\n  /** Added to the computed fontWeight (clamped 100–900). */\n  weightDelta: number;\n  /** Added to the size role's letterSpacing, in em. */\n  trackingDeltaEm: number;\n  /** Multiplies the computed fontSize. */\n  sizeScale: number;\n  /** Applied as CSS text-transform when set. */\n  transform?: \"uppercase\";\n  /**\n   * Multiplies every text-archetype entrance/exit phase duration. Set by\n   * `resolveTokens` from `style.motion`; absent on the raw preset literals,\n   * where it reads as 1.\n   *\n   * It rides on the type treatment because that object is the one channel\n   * that already flows from `style` into every `<TemplateText>` — 17 call\n   * sites pass `resolveTokens(style).preset.type` and nothing else\n   * style-derived. Pacing of typographic motion is part of how the type is\n   * treated, so this isn't a smuggled payload.\n   */\n  phaseScale?: number;\n}\n\nexport interface StylePreset {\n  id: string;\n  /** Agent-facing use-when — surfaced in the registry index. */\n  useWhen: string;\n  background: BackgroundFamily;\n  titlePlacement: PresetTitlePlacement;\n  type: TypeTreatment;\n}\n\n/**\n * The named looks. `bold` is the default and is a deliberate no-op: a config\n * with no `preset` resolves to it and renders byte-identically to the\n * pre-preset output, so adding presets can't restyle anyone's existing video.\n *\n * Deliberately small. Every preset multiplies the QA surface by every\n * template at both orientations — grow this only when a brief can't be\n * expressed by the ones here.\n */\nexport const STYLE_PRESETS: Record<string, StylePreset> = {\n  bold: {\n    id: \"bold\",\n    useWhen:\n      \"The default. Drifting two-color brand mesh, heavy tight headlines at the top. Launches, hype, product moments — the loudest of the three.\",\n    background: \"mesh\",\n    titlePlacement: \"title-top\",\n    type: { weightDelta: 0, trackingDeltaEm: 0, sizeScale: 1 },\n  },\n  editorial: {\n    id: \"editorial\",\n    useWhen:\n      \"Calm vertical wash, lighter and wider-tracked headlines, centered. Reviews, thoughtful updates, premium or B2B brands — when the copy should feel considered rather than shouted.\",\n    background: \"wash\",\n    titlePlacement: \"title-center\",\n    type: { weightDelta: -200, trackingDeltaEm: 0.01, sizeScale: 1.08 },\n  },\n  stark: {\n    id: \"stark\",\n    useWhen:\n      \"Single hard spotlight on near-black, uppercase and tightly tracked. Dev tools, technical claims, high-contrast statements — maximum weight on very few words.\",\n    background: \"spotlight\",\n    titlePlacement: \"title-top\",\n    type: { weightDelta: 100, trackingDeltaEm: -0.01, sizeScale: 1, transform: \"uppercase\" },\n  },\n};\n\nexport const DEFAULT_PRESET_ID = \"bold\";\nexport const PRESET_IDS = Object.keys(STYLE_PRESETS);\n\n/** Unknown/unset ids fall back to the default rather than throwing — a bad\n *  preset should never be the reason a render fails. */\nexport function resolvePreset(id: string | undefined): StylePreset {\n  return (id && STYLE_PRESETS[id]) || STYLE_PRESETS[DEFAULT_PRESET_ID];\n}\n\n// ─── Density & motion ──────────────────────────────────────────────\n//\n// Two dimensions orthogonal to the named preset. `preset` answers \"which\n// look\"; these answer \"how loud\". Splitting them is what lets \"make the\n// whole video more understated\" be one instruction instead of hand-tuning\n// every scene: they resolve into multipliers on levers that already reach\n// all 28 templates, so no template file knows they exist.\n//\n// Both default to `normal`, whose multipliers are all 1 — a config that sets\n// neither renders byte-identically to the pre-density output. Same invariant\n// the presets hold.\n\nexport type StyleDensity = \"airy\" | \"normal\" | \"packed\";\nexport type StyleMotion = \"calm\" | \"normal\" | \"punchy\";\n\nexport interface DensityScale {\n  id: StyleDensity;\n  /** Agent-facing use-when — surfaced in the registry index. */\n  useWhen: string;\n  /** Multiplies the headline font size (via the preset's `type.sizeScale`). */\n  typeScale: number;\n  /** Multiplies the frame's safe-zone insets — bigger insets, more air. */\n  safeZoneScale: number;\n}\n\nexport interface MotionScale {\n  id: StyleMotion;\n  /** Agent-facing use-when — surfaced in the registry index. */\n  useWhen: string;\n  /** Multiplies every text-archetype entrance/exit phase duration. */\n  phaseScale: number;\n}\n\nexport const DENSITY_SCALES: Record<StyleDensity, DensityScale> = {\n  airy: {\n    id: \"airy\",\n    useWhen:\n      \"Smaller headlines held further off the frame edges. Premium, considered, editorial — when the copy should have room to breathe.\",\n    typeScale: 0.92,\n    safeZoneScale: 1.3,\n  },\n  normal: {\n    id: \"normal\",\n    useWhen: \"The default. No change to type size or frame padding.\",\n    typeScale: 1,\n    safeZoneScale: 1,\n  },\n  packed: {\n    id: \"packed\",\n    useWhen:\n      \"Bigger headlines pushed closer to the edges. Dense, urgent, information-heavy — when the frame should feel full.\",\n    typeScale: 1.08,\n    safeZoneScale: 0.8,\n  },\n};\n\nexport const MOTION_SCALES: Record<StyleMotion, MotionScale> = {\n  calm: {\n    id: \"calm\",\n    useWhen:\n      \"Slower entrances and exits — text eases in rather than arriving. Founder stories, sober data, anything reflective.\",\n    phaseScale: 1.4,\n  },\n  normal: {\n    id: \"normal\",\n    useWhen: \"The default. Archetype timings as authored.\",\n    phaseScale: 1,\n  },\n  punchy: {\n    id: \"punchy\",\n    useWhen:\n      \"Snappier entrances and exits — text lands fast and clears fast. Hype, launches, hot takes.\",\n    phaseScale: 0.7,\n  },\n};\n\nexport const DEFAULT_DENSITY_ID: StyleDensity = \"normal\";\nexport const DEFAULT_MOTION_ID: StyleMotion = \"normal\";\nexport const DENSITY_IDS = Object.keys(DENSITY_SCALES) as StyleDensity[];\nexport const MOTION_IDS = Object.keys(MOTION_SCALES) as StyleMotion[];\n\n/** Unknown/unset ids fall back to `normal`, same as `resolvePreset`. */\nexport function resolveDensity(id: string | undefined): DensityScale {\n  return (id && DENSITY_SCALES[id as StyleDensity]) || DENSITY_SCALES[DEFAULT_DENSITY_ID];\n}\n\n/** Unknown/unset ids fall back to `normal`, same as `resolvePreset`. */\nexport function resolveMotion(id: string | undefined): MotionScale {\n  return (id && MOTION_SCALES[id as StyleMotion]) || MOTION_SCALES[DEFAULT_MOTION_ID];\n}\n\nexport interface ResolvedTokens {\n  /** Primary accent color. */\n  accent: string;\n  /** Gradient partner — explicit kit secondary, or shiftHue(accent, 50)\n   *  when missing or \"auto\". */\n  secondary: string;\n  /** Solid scene-background override. Undefined = gradient mode. */\n  bg?: string;\n  /** Deepest background surface. */\n  surface: string;\n  /** Elevated card / panel surface. Explicit kit value, or surface\n   *  lightened 4% when a kit is present, or the canonical default. */\n  surface_elevated: string;\n  /** Primary text color (brandKit.text). */\n  content: string;\n  /** Muted text color. */\n  muted: string;\n  /** Full font fallback stack (see fontStack). */\n  font: string;\n  /** Script accent font family. */\n  script_font: string;\n  /** Optional logo data URL. */\n  logo?: string;\n  /**\n   * Resolved style preset — frame-level look. Always set.\n   *\n   * `preset.type` is the composed treatment, not the raw preset literal:\n   * `sizeScale` already carries the density multiplier and `phaseScale`\n   * carries the motion one. Templates pass this straight to `<TemplateText>`,\n   * which is how both dials reach every template without a template edit.\n   */\n  preset: StylePreset;\n  /** Resolved density dial. Always set; `normal` when unset. */\n  density: DensityScale;\n  /** Resolved motion dial. Always set; `normal` when unset. */\n  motion: MotionScale;\n  /**\n   * Extended-tier kit values passed through only when explicitly set — no\n   * canonical fallback applied. For template spots whose local fallback\n   * intentionally differs from the canonical default: the kit wins when\n   * present, the template's own literal otherwise.\n   */\n  explicit: {\n    surface?: string;\n    surface_elevated?: string;\n    muted?: string;\n    script_font?: string;\n    text?: string;\n  };\n}\n\nexport interface ResolveTokensOptions {\n  /** Wins over brandKit.accent entirely (e.g. a per-scene background\n   *  override). Secondary derives from this accent too. */\n  accentOverride?: string;\n  /** Replaces the canonical accent fallback when brandKit.accent is unset\n   *  (e.g. social-tweet's per-author hashed hue). */\n  accentFallback?: string;\n}\n\nexport function resolveTokens(\n  style: GlobalStyle,\n  opts: ResolveTokensOptions = {},\n): ResolvedTokens {\n  const kit = style.brandKit;\n  const accent =\n    opts.accentOverride ??\n    (kit?.accent || opts.accentFallback || TOKEN_DEFAULTS.accent);\n  const secondary =\n    kit?.secondary && kit.secondary !== \"auto\"\n      ? kit.secondary\n      : shiftHue(accent, 50);\n  const surface = kit?.surface || TOKEN_DEFAULTS.surface;\n  const surface_elevated = kit\n    ? kit.surface_elevated || lighten(surface, 0.04)\n    : TOKEN_DEFAULTS.surface_elevated;\n\n  // Compose the two dials into the preset's type treatment here, once. Every\n  // template already passes `preset.type` to <TemplateText>, so folding them\n  // in at the resolver is what makes them bite everywhere with no template\n  // edits. At `normal`/`normal` both multipliers are 1 and the object is\n  // value-identical to the preset literal.\n  const preset = resolvePreset(style.preset);\n  const density = resolveDensity(style.density);\n  const motion = resolveMotion(style.motion);\n  const composedPreset: StylePreset = {\n    ...preset,\n    type: {\n      ...preset.type,\n      sizeScale: preset.type.sizeScale * density.typeScale,\n      phaseScale: motion.phaseScale,\n    },\n  };\n\n  return {\n    accent,\n    secondary,\n    bg: kit?.bg,\n    surface,\n    surface_elevated,\n    content: kit?.text || TOKEN_DEFAULTS.content,\n    muted: kit?.muted || TOKEN_DEFAULTS.muted,\n    font: fontStack(style.font),\n    script_font: kit?.script_font || TOKEN_DEFAULTS.script_font,\n    logo: kit?.logoDataUrl,\n    preset: composedPreset,\n    density,\n    motion,\n    explicit: {\n      surface: kit?.surface,\n      surface_elevated: kit?.surface_elevated,\n      muted: kit?.muted,\n      script_font: kit?.script_font,\n      text: kit?.text,\n    },\n  };\n}\n\n// ─── Color math the resolver depends on ────────────────────────────\n// (Lives beside the resolver because theme owns both token resolution and\n// color derivation.)\n\n/**\n * Shift a hex color's hue by a number of degrees.\n * Used to auto-derive a secondary color from the accent.\n */\nexport function shiftHue(hex: string, degrees: number): string {\n  const r = parseInt(hex.slice(1, 3), 16) / 255;\n  const g = parseInt(hex.slice(3, 5), 16) / 255;\n  const b = parseInt(hex.slice(5, 7), 16) / 255;\n\n  const max = Math.max(r, g, b);\n  const min = Math.min(r, g, b);\n  const d = max - min;\n  const l = (max + min) / 2;\n  let h = 0;\n  let s = 0;\n\n  if (d > 0) {\n    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);\n    if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;\n    else if (max === g) h = ((b - r) / d + 2) / 6;\n    else h = ((r - g) / d + 4) / 6;\n  }\n\n  h = (h + degrees / 360 + 1) % 1;\n\n  const hue2rgb = (p: number, q: number, t: number) => {\n    if (t < 0) t += 1;\n    if (t > 1) t -= 1;\n    if (t < 1 / 6) return p + (q - p) * 6 * t;\n    if (t < 1 / 2) return q;\n    if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;\n    return p;\n  };\n\n  let r2: number, g2: number, b2: number;\n  if (s === 0) {\n    r2 = g2 = b2 = l;\n  } else {\n    const q = l < 0.5 ? l * (1 + s) : l + s - l * s;\n    const p = 2 * l - q;\n    r2 = hue2rgb(p, q, h + 1 / 3);\n    g2 = hue2rgb(p, q, h);\n    b2 = hue2rgb(p, q, h - 1 / 3);\n  }\n\n  const toHex = (v: number) =>\n    Math.round(v * 255)\n      .toString(16)\n      .padStart(2, \"0\");\n  return `#${toHex(r2)}${toHex(g2)}${toHex(b2)}`;\n}\n\n/** Lighten a hex color by a 0-1 factor. */\n/** Scale a hex color toward black by `factor` (0 = unchanged, 1 = black). */\nexport function darken(hex: string, factor: number): string {\n  if (!hex.startsWith(\"#\") || (hex.length !== 4 && hex.length !== 7)) {\n    return hex;\n  }\n  const full = hex.length === 4\n    ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`\n    : hex;\n  const ch = (i: number) =>\n    Math.max(0, Math.round(parseInt(full.slice(i, i + 2), 16) * (1 - factor)))\n      .toString(16)\n      .padStart(2, \"0\");\n  return `#${ch(1)}${ch(3)}${ch(5)}`;\n}\n\nexport function lighten(hex: string, factor: number): string {\n  if (!hex.startsWith(\"#\") || (hex.length !== 4 && hex.length !== 7)) {\n    return hex;\n  }\n  const full = hex.length === 4\n    ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`\n    : hex;\n  const r = parseInt(full.slice(1, 3), 16);\n  const g = parseInt(full.slice(3, 5), 16);\n  const b = parseInt(full.slice(5, 7), 16);\n  const lr = Math.min(255, Math.round(r + (255 - r) * factor));\n  const lg = Math.min(255, Math.round(g + (255 - g) * factor));\n  const lb = Math.min(255, Math.round(b + (255 - b) * factor));\n  return `#${lr.toString(16).padStart(2, \"0\")}${lg.toString(16).padStart(2, \"0\")}${lb.toString(16).padStart(2, \"0\")}`;\n}\n"
    }
  ],
  "meta": {
    "vanillasky": {
      "layer": "lib",
      "tier": "free",
      "domain": "appearance",
      "level": "foundation",
      "audiences": [
        "custom-scenes",
        "react-developers"
      ],
      "dependencies": [
        "video-config"
      ],
      "useWhen": "Use resolveTokens once per scene whenever the visual result should inherit the video's brand, preset, density, or motion intensity coherently.",
      "avoidWhen": "Do not hardcode fallback colors or font stacks beside it, and do not use it to animate values or paint a background."
    }
  }
}
