Zurück zu Skills
heygen-com/hyperframesVor der Ausführung prüfen

SKILL DETAIL

embedded-captions

heygen-com/hyperframes/embedded-captions

This skill adds captions or subtitles to an existing single-subject talking-head video without editing the footage. It supports plain verbatim captions, cinematic captions embedded behind the subject, VFX captions (e.g., “炸/特效/酷炫字幕”), or a named identity from the 35-style catalog. Routing is by visual identity, not by backend engine. The quiet `anchor` rail is the default; embed every word only when the user explicitly wants a fully cinematic treatment. The workflow runs locally end to end, including transcription and subject matting; split multi-shot footage before applying it.

Installationen · 1.229Quelle ansehen

Installation

npx skills add https://github.com/heygen-com/hyperframes --skill embedded-captions

Skill-Dateien

SKILL.md

Zuletzt synchronisiert · 29.08.2026

.gitignore
# Matting weights: the PP-MattingV2 ONNX SHIPS with the skill (committed — the
# upstream exports have no stable public download URL). Ignore any other stray
# .onnx (e.g. experimental variants) so they don't get committed by accident.
assets/*.onnx
!assets/ppmattingv2_stdc1_human_544x960.onnx

# Stray render/test artifacts — a project's outputs must never live inside the skill.
*.mp4
*.webm
*.log
_*.png
frames_bg/
frames_fg/
history/
*_shadow/
transcript.json
plan.json
matte.fps
safe-zones.json

.DS_Store
assets/fonts/char-widths.json
{
  "_meta": "Measured per-char advance widths (em @100px) + fontBoundingBox ascent/descent for the bundled families in modes/standard/fonts/fonts.css. Generated once via headless-Chrome canvas measureText over the SAME woff2 faces the renderer embeds (kerning ignored, ~<=3px/word). Used by make-theme.cjs to BAKE pixel-exact word geometry at compile time so font-load timing in the render browser can never skew aim. Regenerate if fonts.css changes.",
  "Anton": {
    "ascent": 1.18,
    "descent": 0.33,
    "widths": {
      "0": 0.4941,
      "1": 0.3306,
      "2": 0.4941,
      "3": 0.4941,
      "4": 0.4941,
      "5": 0.4941,
      "6": 0.4941,
      "7": 0.4941,
      "8": 0.4941,
      "9": 0.4941,
      "A": 0.4854,
      "B": 0.4785,
      "C": 0.4741,
      "D": 0.4932,
      "E": 0.4116,
      "F": 0.3989,
      "G": 0.4849,
      "H": 0.499,
      "I": 0.2266,
      "J": 0.4663,
      "K": 0.4722,
      "L": 0.3975,
      "M": 0.7461,
      "N": 0.498,
      "O": 0.4863,
      "P": 0.4722,
      "Q": 0.4937,
      "R": 0.4766,
      "S": 0.4614,
      "T": 0.3955,
      "U": 0.4736,
      "V": 0.4692,
      "W": 0.7119,
      "X": 0.4839,
      "Y": 0.4463,
      "Z": 0.4102,
      "a": 0.4834,
      "b": 0.5015,
      "c": 0.4912,
      "d": 0.498,
      "e": 0.4883,
      "f": 0.2803,
      "g": 0.5039,
      "h": 0.5054,
      "i": 0.2432,
      "j": 0.2627,
      "k": 0.4907,
      "l": 0.248,
      "m": 0.7583,
      "n": 0.4985,
      "o": 0.4966,
      "p": 0.5015,
      "q": 0.498,
      "r": 0.3467,
      "s": 0.4746,
      "t": 0.3052,
      "u": 0.499,
      "v": 0.4609,
      "w": 0.6963,
      "x": 0.459,
      "y": 0.4609,
      "z": 0.3857,
      ".": 0.2285,
      ",": 0.2363,
      "!": 0.229,
      "?": 0.4922,
      "'": 0.2139,
      "\u2019": 0.2319,
      "\"": 0.4287,
      ":": 0.2417,
      ";": 0.2451,
      "-": 0.311,
      "\u2013": 0.311,
      "\u2014": 0.563,
      "(": 0.291,
      ")": 0.291,
      "[": 0.3179,
      "]": 0.3179,
      "/": 0.4053,
      "&amp;": 0.52,
      "%": 1.0566,
      "+": 0.3555,
      "#": 0.5464,
      "@": 0.8643,
      "*": 0.4521,
      " ": 0.2344
    }
  },
  "Audiowide": {
    "ascent": 0.99,
    "descent": 0.29,
    "widths": {
      "0": 0.9253,
      "1": 0.3545,
      "2": 0.7524,
      "3": 0.7192,
      "4": 0.7734,
      "5": 0.7505,
      "6": 0.7441,
      "7": 0.6411,
      "8": 0.7461,
      "9": 0.7441,
      "A": 0.8345,
      "B": 0.8091,
      "C": 0.7471,
      "D": 0.8232,
      "E": 0.7554,
      "F": 0.7383,
      "G": 0.8232,
      "H": 0.8491,
      "I": 0.2764,
      "J": 0.7344,
      "K": 0.8135,
      "L": 0.7363,
      "M": 0.9624,
      "N": 0.8472,
      "O": 0.8604,
      "P": 0.8062,
      "Q": 0.8604,
      "R": 0.8271,
      "S": 0.7783,
      "T": 0.7261,
      "U": 0.8335,
      "V": 0.7725,
      "W": 1.0024,
      "X": 0.7871,
      "Y": 0.7813,
      "Z": 0.7134,
      "a": 0.6533,
      "b": 0.6904,
      "c": 0.6162,
      "d": 0.7002,
      "e": 0.6533,
      "f": 0.5503,
      "g": 0.6904,
      "h": 0.7031,
      "i": 0.2505,
      "j": 0.2505,
      "k": 0.6914,
      "l": 0.3135,
      "m": 0.8735,
      "n": 0.6963,
      "o": 0.6821,
      "p": 0.6904,
      "q": 0.6904,
      "r": 0.5454,
      "s": 0.5991,
      "t": 0.5635,
      "u": 0.6963,
      "v": 0.6753,
      "w": 0.9131,
      "x": 0.6953,
      "y": 0.7031,
      "z": 0.6011,
      ".": 0.2051,
      ",": 0.2251,
      "!": 0.2603,
      "?": 0.6655,
      "'": 0.2124,
      "\u2019": 0.2305,
      "\"": 0.3823,
      ":": 0.2432,
      ";": 0.2515,
      "-": 0.5713,
      "\u2013": 0.5132,
      "\u2014": 0.9131,
      "(": 0.4272,
      ")": 0.4272,
      "[": 0.3652,
      "]": 0.3652,
      "/": 0.6592,
      "&amp;": 0.6714,
      "%": 0.8662,
      "+": 0.5391,
      "#": 0.7295,
      "@": 0.6611,
      "*": 0.5425,
      " ": 0.3252
    }
  },
  "Baloo 2": {
    "ascent": 1.08,
    "descent": 0.52,
    "widths": {
      "0": 0.583,
      "1": 0.357,
      "2": 0.508,
      "3": 0.506,
      "4": 0.548,
      "5": 0.538,
      "6": 0.55,
      "7": 0.458,
      "8": 0.569,
      "9": 0.552,
      "A": 0.592,
      "B": 0.599,
      "C": 0.588,
      "D": 0.652,
      "E": 0.531,
      "F": 0.517,
      "G": 0.639,
      "H": 0.654,
      "I": 0.262,
      "J": 0.385,
      "K": 0.566,
      "L": 0.501,
      "M": 0.78,
      "N": 0.66,
      "O": 0.689,
      "P": 0.567,
      "Q": 0.69,
      "R": 0.589,
      "S": 0.523,
      "T": 0.523,
      "U": 0.649,
      "V": 0.583,
      "W": 0.803,
      "X": 0.591,
      "Y": 0.55,
      "Z": 0.557,
      "a": 0.499,
      "b": 0.549,
      "c": 0.478,
      "d": 0.547,
      "e": 0.519,
      "f": 0.399,
      "g": 0.537,
      "h": 0.551,
      "i": 0.241,
      "j": 0.243,
      "k": 0.487,
      "l": 0.246,
      "m": 0.816,
      "n": 0.55,
      "o": 0.553,
      "p": 0.546,
      "q": 0.546,
      "r": 0.382,
      "s": 0.447,
      "t": 0.372,
      "u": 0.55,
      "v": 0.495,
      "w": 0.729,
      "x": 0.482,
      "y": 0.489,
      "z": 0.469,
      ".": 0.141,
      ",": 0.145,
      "!": 0.25,
      "?": 0.459,
      "'": 0.139,
      "\u2019": 0.127,
      "\"": 0.27,
      ":": 0.189,
      ";": 0.202,
      "-": 0.389,
      "\u2013": 0.534,
      "\u2014": 0.767,
      "(": 0.254,
      ")": 0.254,
      "[": 0.268,
      "]": 0.269,
      "/": 0.305,
      "&amp;": 0.637,
      "%": 0.772,
      "+": 0.548,
      "#": 0.667,
      "@": 1,
      "*": 0.299,
      " ": 0.2
    }
  },
  "Bangers": {
    "ascent": 0.88,
    "descent": 0.18,
    "widths": {
      "0": 0.474,
      "1": 0.32,
      "2": 0.467,
      "3": 0.458,
      "4": 0.459,
      "5": 0.45,
      "6": 0.478,
      "7": 0.339,
      "8": 0.518,
      "9": 0.489,
      "A": 0.433,
      "B": 0.428,
      "C": 0.414,
      "D": 0.441,
      "E": 0.341,
      "F": 0.341,
      "G": 0.499,
      "H": 0.442,
      "I": 0.2,
      "J": 0.279,
      "K": 0.462,
      "L": 0.302,
      "M": 0.619,
      "N": 0.453,
      "O": 0.493,
      "P": 0.379,
      "Q": 0.525,
      "R": 0.444,
      "S": 0.43,
      "T": 0.362,
      "U": 0.419,
      "V": 0.379,
      "W": 0.601,
      "X": 0.411,
      "Y": 0.384,
      "Z": 0.431,
      "a": 0.433,
      "b": 0.428,
      "c": 0.414,
      "d": 0.441,
      "e": 0.341,
      "f": 0.341,
      "g": 0.499,
      "h": 0.441,
      "i": 0.202,
      "j": 0.279,
      "k": 0.462,
      "l": 0.302,
      "m": 0.619,
      "n": 0.452,
      "o": 0.493,
      "p": 0.379,
      "q": 0.525,
      "r": 0.444,
      "s": 0.43,
      "t": 0.362,
      "u": 0.419,
      "v": 0.379,
      "w": 0.601,
      "x": 0.411,
      "y": 0.384,
      "z": 0.431,
      ".": 0.209,
      ",": 0.197,
      "!": 0.263,
      "?": 0.438,
      "'": 0.202,
      "\u2019": 0.202,
      "\"": 0.421,
      ":": 0.195,
      ";": 0.262,
      "-": 0.36,
      "\u2013": 0.36,
      "\u2014": 0.52,
      "(": 0.334,
      ")": 0.331,
      "[": 0.319,
      "]": 0.313,
      "/": 0.551,
      "&amp;": 0.535,
      "%": 0.799,
      "+": 0.33,
      "#": 0.626,
      "@": 0.987,
      "*": 0.423,
      " ": 0.195
    }
  },
  "Bodoni Moda": {
    "ascent": 1.13,
    "descent": 0.4,
    "widths": {
      "0": 0.6195,
      "1": 0.4345,
      "2": 0.5495,
      "3": 0.5205,
      "4": 0.5945,
      "5": 0.5,
      "6": 0.5595,
      "7": 0.5045,
      "8": 0.5495,
      "9": 0.5595,
      "A": 0.772,
      "B": 0.656,
      "C": 0.7155,
      "D": 0.726,
      "E": 0.6185,
      "F": 0.5985,
      "G": 0.739,
      "H": 0.762,
      "I": 0.387,
      "J": 0.45,
      "K": 0.705,
      "L": 0.6035,
      "M": 0.8725,
      "N": 0.7735,
      "O": 0.75,
      "P": 0.6385,
      "Q": 0.75,
      "R": 0.7215,
      "S": 0.576,
      "T": 0.6735,
      "U": 0.726,
      "V": 0.774,
      "W": 1.0045,
      "X": 0.7665,
      "Y": 0.7335,
      "Z": 0.577,
      "a": 0.523,
      "b": 0.6085,
      "c": 0.5095,
      "d": 0.6085,
      "e": 0.5235,
      "f": 0.398,
      "g": 0.5955,
      "h": 0.5955,
      "i": 0.303,
      "j": 0.305,
      "k": 0.604,
      "l": 0.333,
      "m": 0.8605,
      "n": 0.5955,
      "o": 0.561,
      "p": 0.6085,
      "q": 0.6085,
      "r": 0.442,
      "s": 0.439,
      "t": 0.3355,
      "u": 0.598,
      "v": 0.542,
      "w": 0.7615,
      "x": 0.577,
      "y": 0.5465,
      "z": 0.467,
      ".": 0.1995,
      ",": 0.2145,
      "!": 0.3175,
      "?": 0.5495,
      "'": 0.1995,
      "\u2019": 0.2145,
      "\"": 0.3695,
      ":": 0.1975,
      ";": 0.2145,
      "-": 0.3595,
      "\u2013": 0.5405,
      "\u2014": 0.7845,
      "(": 0.3615,
      ")": 0.3615,
      "[": 0.3245,
      "]": 0.3245,
      "/": 0.528,
      "&amp;": 0.8035,
      "%": 1,
      "+": 0.5295,
      "#": 0.638,
      "@": 1.0095,
      "*": 0.632,
      " ": 0.25
    }
  },
  "Caveat": {
    "ascent": 0.96,
    "descent": 0.3,
    "widths": {
      "0": 0.45,
      "1": 0.45,
      "2": 0.45,
      "3": 0.45,
      "4": 0.45,
      "5": 0.45,
      "6": 0.45,
      "7": 0.45,
      "8": 0.45,
      "9": 0.45,
      "A": 0.502,
      "B": 0.52,
      "C": 0.472,
      "D": 0.578,
      "E": 0.529,
      "F": 0.454,
      "G": 0.487,
      "H": 0.563,
      "I": 0.4,
      "J": 0.306,
      "K": 0.498,
      "L": 0.426,
      "M": 0.72,
      "N": 0.599,
      "O": 0.496,
      "P": 0.464,
      "Q": 0.502,
      "R": 0.537,
      "S": 0.486,
      "T": 0.458,
      "U": 0.482,
      "V": 0.49,
      "W": 0.72,
      "X": 0.524,
      "Y": 0.504,
      "Z": 0.522,
      "a": 0.437,
      "b": 0.435,
      "c": 0.355,
      "d": 0.393,
      "e": 0.325,
      "f": 0.291,
      "g": 0.355,
      "h": 0.464,
      "i": 0.187,
      "j": 0.204,
      "k": 0.365,
      "l": 0.169,
      "m": 0.56,
      "n": 0.45,
      "o": 0.353,
      "p": 0.378,
      "q": 0.377,
      "r": 0.36,
      "s": 0.341,
      "t": 0.331,
      "u": 0.37,
      "v": 0.331,
      "w": 0.517,
      "x": 0.343,
      "y": 0.341,
      "z": 0.315,
      ".": 0.198,
      ",": 0.198,
      "!": 0.21,
      "?": 0.374,
      "'": 0.117,
      "\u2019": 0.117,
      "\"": 0.237,
      ":": 0.198,
      ";": 0.198,
      "-": 0.326,
      "\u2013": 0.446,
      "\u2014": 0.566,
      "(": 0.33,
      ")": 0.33,
      "[": 0.33,
      "]": 0.33,
      "/": 0.33,
      "&amp;": 0.564,
      "%": 0.6,
      "+": 0.446,
      "#": 0.559,
      "@": 0.643,
      "*": 0.363,
      " ": 0.242
    }
  },
  "Chakra Petch": {
    "ascent": 0.99,
    "descent": 0.31,
    "widths": {
      "0": 0.636,
      "1": 0.366,
      "2": 0.558,
      "3": 0.587,
      "4": 0.563,
      "5": 0.59,
      "6": 0.606,
      "7": 0.506,
      "8": 0.622,
      "9": 0.611,
      "A": 0.629,
      "B": 0.654,
      "C": 0.639,
      "D": 0.672,
      "E": 0.59,
      "F": 0.56,
      "G": 0.66,
      "H": 0.682,
      "I": 0.256,
      "J": 0.564,
      "K": 0.628,
      "L": 0.545,
      "M": 0.785,
      "N": 0.681,
      "O": 0.684,
      "P": 0.63,
      "Q": 0.684,
      "R": 0.659,
      "S": 0.621,
      "T": 0.568,
      "U": 0.684,
      "V": 0.634,
      "W": 0.868,
      "X": 0.62,
      "Y": 0.606,
      "Z": 0.576,
      "a": 0.559,
      "b": 0.585,
      "c": 0.542,
      "d": 0.587,
      "e": 0.568,
      "f": 0.384,
      "g": 0.587,
      "h": 0.578,
      "i": 0.253,
      "j": 0.268,
      "k": 0.538,
      "l": 0.243,
      "m": 0.845,
      "n": 0.576,
      "o": 0.577,
      "p": 0.587,
      "q": 0.58,
      "r": 0.393,
      "s": 0.52,
      "t": 0.398,
      "u": 0.575,
      "v": 0.491,
      "w": 0.714,
      "x": 0.501,
      "y": 0.577,
      "z": 0.482,
      ".": 0.217,
      ",": 0.217,
      "!": 0.254,
      "?": 0.542,
      "'": 0.216,
      "\u2019": 0.215,
      "\"": 0.38,
      ":": 0.215,
      ";": 0.215,
      "-": 0.412,
      "\u2013": 0.628,
      "\u2014": 0.735,
      "(": 0.322,
      ")": 0.322,
      "[": 0.321,
      "]": 0.321,
      "/": 0.484,
      "&amp;": 0.646,
      "%": 0.842,
      "+": 0.548,
      "#": 0.697,
      "@": 0.819,
      "*": 0.343,
      " ": 0.253
    }
  },
  "Cinzel": {
    "ascent": 0.98,
    "descent": 0.37,
    "widths": {
      "0": 0.596,
      "1": 0.344,
      "2": 0.553,
      "3": 0.503,
      "4": 0.583,
      "5": 0.498,
      "6": 0.56,
      "7": 0.502,
      "8": 0.552,
      "9": 0.56,
      "A": 0.712,
      "B": 0.586,
      "C": 0.76,
      "D": 0.807,
      "E": 0.577,
      "F": 0.541,
      "G": 0.798,
      "H": 0.813,
      "I": 0.338,
      "J": 0.324,
      "K": 0.657,
      "L": 0.562,
      "M": 0.931,
      "N": 0.841,
      "O": 0.838,
      "P": 0.577,
      "Q": 0.837,
      "R": 0.677,
      "S": 0.475,
      "T": 0.618,
      "U": 0.785,
      "V": 0.74,
      "W": 0.982,
      "X": 0.662,
      "Y": 0.661,
      "Z": 0.651,
      "a": 0.663,
      "b": 0.57,
      "c": 0.705,
      "d": 0.76,
      "e": 0.546,
      "f": 0.523,
      "g": 0.742,
      "h": 0.746,
      "i": 0.336,
      "j": 0.309,
      "k": 0.624,
      "l": 0.536,
      "m": 0.878,
      "n": 0.779,
      "o": 0.774,
      "p": 0.553,
      "q": 0.777,
      "r": 0.627,
      "s": 0.462,
      "t": 0.578,
      "u": 0.7,
      "v": 0.663,
      "w": 0.899,
      "x": 0.626,
      "y": 0.64,
      "z": 0.608,
      ".": 0.179,
      ",": 0.185,
      "!": 0.23,
      "?": 0.413,
      "'": 0.17,
      "\u2019": 0.193,
      "\"": 0.315,
      ":": 0.179,
      ";": 0.185,
      "-": 0.38,
      "\u2013": 0.5,
      "\u2014": 0.8,
      "(": 0.338,
      ")": 0.338,
      "[": 0.332,
      "]": 0.332,
      "/": 0.406,
      "&amp;": 0.646,
      "%": 0.684,
      "+": 0.481,
      "#": 0.536,
      "@": 0.977,
      "*": 0.386,
      " ": 0.25
    }
  },
  "Cormorant Garamond": {
    "ascent": 0.92,
    "descent": 0.29,
    "widths": {
      "0": 0.477,
      "1": 0.332,
      "2": 0.402,
      "3": 0.389,
      "4": 0.451,
      "5": 0.409,
      "6": 0.465,
      "7": 0.429,
      "8": 0.489,
      "9": 0.465,
      "A": 0.702,
      "B": 0.558,
      "C": 0.686,
      "D": 0.691,
      "E": 0.537,
      "F": 0.509,
      "G": 0.713,
      "H": 0.759,
      "I": 0.332,
      "J": 0.322,
      "K": 0.65,
      "L": 0.533,
      "M": 0.835,
      "N": 0.727,
      "O": 0.766,
      "P": 0.543,
      "Q": 0.766,
      "R": 0.673,
      "S": 0.491,
      "T": 0.634,
      "U": 0.698,
      "V": 0.666,
      "W": 0.931,
      "X": 0.652,
      "Y": 0.609,
      "Z": 0.595,
      "a": 0.419,
      "b": 0.51,
      "c": 0.411,
      "d": 0.512,
      "e": 0.408,
      "f": 0.305,
      "g": 0.445,
      "h": 0.499,
      "i": 0.273,
      "j": 0.257,
      "k": 0.491,
      "l": 0.258,
      "m": 0.763,
      "n": 0.517,
      "o": 0.479,
      "p": 0.511,
      "q": 0.488,
      "r": 0.364,
      "s": 0.338,
      "t": 0.338,
      "u": 0.493,
      "v": 0.434,
      "w": 0.671,
      "x": 0.439,
      "y": 0.435,
      "z": 0.405,
      ".": 0.197,
      ",": 0.222,
      "!": 0.247,
      "?": 0.329,
      "'": 0.14,
      "\u2019": 0.192,
      "\"": 0.283,
      ":": 0.197,
      ";": 0.224,
      "-": 0.323,
      "\u2013": 0.515,
      "\u2014": 0.83,
      "(": 0.309,
      ")": 0.309,
      "[": 0.274,
      "]": 0.274,
      "/": 0.341,
      "&amp;": 0.697,
      "%": 0.574,
      "+": 0.398,
      "#": 0.526,
      "@": 0.719,
      "*": 0.448,
      " ": 0.234
    }
  },
  "Creepster": {
    "ascent": 0.95,
    "descent": 0.22,
    "widths": {
      "0": 0.457,
      "1": 0.3105,
      "2": 0.4355,
      "3": 0.4277,
      "4": 0.4219,
      "5": 0.4277,
      "6": 0.4141,
      "7": 0.3975,
      "8": 0.4287,
      "9": 0.4355,
      "A": 0.4453,
      "B": 0.4678,
      "C": 0.4307,
      "D": 0.4541,
      "E": 0.4199,
      "F": 0.4238,
      "G": 0.46,
      "H": 0.457,
      "I": 0.3076,
      "J": 0.374,
      "K": 0.4922,
      "L": 0.3574,
      "M": 0.6563,
      "N": 0.5166,
      "O": 0.4561,
      "P": 0.4434,
      "Q": 0.4775,
      "R": 0.4629,
      "S": 0.4111,
      "T": 0.4082,
      "U": 0.4854,
      "V": 0.4297,
      "W": 0.582,
      "X": 0.4521,
      "Y": 0.4023,
      "Z": 0.3818,
      "a": 0.4316,
      "b": 0.4492,
      "c": 0.4336,
      "d": 0.4297,
      "e": 0.4033,
      "f": 0.3975,
      "g": 0.4336,
      "h": 0.4854,
      "i": 0.3281,
      "j": 0.3672,
      "k": 0.4541,
      "l": 0.3174,
      "m": 0.6553,
      "n": 0.5088,
      "o": 0.4697,
      "p": 0.4629,
      "q": 0.4805,
      "r": 0.459,
      "s": 0.3984,
      "t": 0.4502,
      "u": 0.4893,
      "v": 0.4531,
      "w": 0.6348,
      "x": 0.4619,
      "y": 0.4316,
      "z": 0.3916,
      ".": 0.165,
      ",": 0.1484,
      "!": 0.2021,
      "?": 0.3848,
      "'": 0.1416,
      "\u2019": 0.1387,
      "\"": 0.2764,
      ":": 0.165,
      ";": 0.1514,
      "-": 0.3896,
      "\u2013": 0.3896,
      "\u2014": 0.5732,
      "(": 0.2246,
      ")": 0.2266,
      "[": 0.2256,
      "]": 0.2217,
      "/": 0.3369,
      "&amp;": 0.4648,
      "%": 0.5498,
      "+": 0.4014,
      "#": 0.5859,
      "@": 0.6865,
      "*": 0.4121,
      " ": 0.2354
    }
  },
  "Fredoka": {
    "ascent": 0.97,
    "descent": 0.24,
    "widths": {
      "0": 0.587,
      "1": 0.392,
      "2": 0.598,
      "3": 0.587,
      "4": 0.544,
      "5": 0.516,
      "6": 0.536,
      "7": 0.554,
      "8": 0.559,
      "9": 0.536,
      "A": 0.704,
      "B": 0.638,
      "C": 0.667,
      "D": 0.692,
      "E": 0.623,
      "F": 0.631,
      "G": 0.708,
      "H": 0.687,
      "I": 0.217,
      "J": 0.545,
      "K": 0.591,
      "L": 0.558,
      "M": 0.825,
      "N": 0.708,
      "O": 0.732,
      "P": 0.601,
      "Q": 0.811,
      "R": 0.624,
      "S": 0.554,
      "T": 0.664,
      "U": 0.713,
      "V": 0.724,
      "W": 0.965,
      "X": 0.694,
      "Y": 0.638,
      "Z": 0.575,
      "a": 0.574,
      "b": 0.582,
      "c": 0.508,
      "d": 0.586,
      "e": 0.54,
      "f": 0.378,
      "g": 0.565,
      "h": 0.535,
      "i": 0.224,
      "j": 0.24,
      "k": 0.489,
      "l": 0.281,
      "m": 0.826,
      "n": 0.557,
      "o": 0.56,
      "p": 0.574,
      "q": 0.568,
      "r": 0.406,
      "s": 0.459,
      "t": 0.376,
      "u": 0.543,
      "v": 0.537,
      "w": 0.763,
      "x": 0.494,
      "y": 0.526,
      "z": 0.531,
      ".": 0.211,
      ",": 0.225,
      "!": 0.231,
      "?": 0.491,
      "'": 0.181,
      "\u2019": 0.157,
      "\"": 0.335,
      ":": 0.234,
      ";": 0.213,
      "-": 0.426,
      "\u2013": 0.556,
      "\u2014": 0.686,
      "(": 0.377,
      ")": 0.377,
      "[": 0.342,
      "]": 0.342,
      "/": 0.517,
      "&amp;": 0.713,
      "%": 0.823,
      "+": 0.537,
      "#": 0.796,
      "@": 0.829,
      "*": 0.494,
      " ": 0.254
    }
  },
  "Inter": {
    "ascent": 0.97,
    "descent": 0.24,
    "widths": {
      "0": 0.6309,
      "1": 0.4067,
      "2": 0.6099,
      "3": 0.6177,
      "4": 0.646,
      "5": 0.5933,
      "6": 0.6201,
      "7": 0.5659,
      "8": 0.6187,
      "9": 0.6201,
      "A": 0.6899,
      "B": 0.6543,
      "C": 0.7305,
      "D": 0.7217,
      "E": 0.6011,
      "F": 0.5903,
      "G": 0.7461,
      "H": 0.7432,
      "I": 0.2686,
      "J": 0.5708,
      "K": 0.6719,
      "L": 0.5654,
      "M": 0.9033,
      "N": 0.7534,
      "O": 0.7646,
      "P": 0.6387,
      "Q": 0.7646,
      "R": 0.6436,
      "S": 0.6416,
      "T": 0.6455,
      "U": 0.7441,
      "V": 0.6899,
      "W": 0.9854,
      "X": 0.6821,
      "Y": 0.6787,
      "Z": 0.6289,
      "a": 0.5615,
      "b": 0.6123,
      "c": 0.5713,
      "d": 0.6123,
      "e": 0.583,
      "f": 0.3701,
      "g": 0.6133,
      "h": 0.5913,
      "i": 0.2422,
      "j": 0.2422,
      "k": 0.5488,
      "l": 0.2422,
      "m": 0.876,
      "n": 0.5908,
      "o": 0.5996,
      "p": 0.6123,
      "q": 0.6123,
      "r": 0.3765,
      "s": 0.5278,
      "t": 0.3271,
      "u": 0.5913,
      "v": 0.562,
      "w": 0.8184,
      "x": 0.5459,
      "y": 0.562,
      "z": 0.5522,
      ".": 0.2881,
      ",": 0.2881,
      "!": 0.2876,
      "?": 0.5112,
      "'": 0.2998,
      "\u2019": 0.2607,
      "\"": 0.4658,
      ":": 0.2881,
      ";": 0.3018,
      "-": 0.46,
      "\u2013": 0.5,
      "\u2014": 1,
      "(": 0.3647,
      ")": 0.3647,
      "[": 0.3647,
      "]": 0.3647,
      "/": 0.3604,
      "&amp;": 0.644,
      "%": 0.9819,
      "+": 0.6616,
      "#": 0.6333,
      "@": 0.9658,
      "*": 0.501,
      " ": 0.2813
    }
  },
  "Monoton": {
    "ascent": 1.16,
    "descent": 0.4,
    "widths": {
      "0": 0.8433,
      "1": 0.4263,
      "2": 0.583,
      "3": 0.6123,
      "4": 0.6221,
      "5": 0.6919,
      "6": 0.7817,
      "7": 0.6318,
      "8": 0.8086,
      "9": 0.7822,
      "A": 0.9365,
      "B": 0.8623,
      "C": 0.8521,
      "D": 0.8862,
      "E": 0.7065,
      "F": 0.6831,
      "G": 0.9082,
      "H": 0.9014,
      "I": 0.3389,
      "J": 0.4346,
      "K": 0.8159,
      "L": 0.605,
      "M": 0.9209,
      "N": 0.8779,
      "O": 0.9336,
      "P": 0.8506,
      "Q": 0.938,
      "R": 0.8438,
      "S": 0.6821,
      "T": 0.5913,
      "U": 0.9053,
      "V": 0.9409,
      "W": 1.1616,
      "X": 0.8765,
      "Y": 0.7891,
      "Z": 0.854,
      "a": 0.832,
      "b": 0.7207,
      "c": 0.7368,
      "d": 0.833,
      "e": 0.667,
      "f": 0.6255,
      "g": 0.8271,
      "h": 0.8306,
      "i": 0.3379,
      "j": 0.3936,
      "k": 0.8018,
      "l": 0.583,
      "m": 1.168,
      "n": 0.7871,
      "o": 0.8335,
      "p": 0.7817,
      "q": 0.7881,
      "r": 0.8315,
      "s": 0.6831,
      "t": 0.5884,
      "u": 0.7959,
      "v": 0.8555,
      "w": 1.1299,
      "x": 0.79,
      "y": 0.7334,
      "z": 0.7148,
      ".": 0.2554,
      ",": 0.2529,
      "!": 0.3364,
      "?": 0.6938,
      "'": 0.2578,
      "\u2019": 0.2515,
      "\"": 0.5386,
      ":": 0.2627,
      ";": 0.2617,
      "-": 0.4736,
      "\u2013": 0.4736,
      "\u2014": 0.6152,
      "(": 0.4106,
      ")": 0.4087,
      "[": 0.3208,
      "]": 0.3198,
      "/": 0.4478,
      "&amp;": 1.0298,
      "%": 1.5049,
      "+": 0.6011,
      "#": 1.0146,
      "@": 1.0762,
      "*": 0.6646,
      " ": 0.1084
    }
  },
  "Orbitron": {
    "ascent": 1.01,
    "descent": 0.24,
    "widths": {
      "0": 0.834,
      "1": 0.391,
      "2": 0.83,
      "3": 0.826,
      "4": 0.73,
      "5": 0.83,
      "6": 0.82,
      "7": 0.66,
      "8": 0.834,
      "9": 0.828,
      "A": 0.836,
      "B": 0.832,
      "C": 0.822,
      "D": 0.834,
      "E": 0.766,
      "F": 0.723,
      "G": 0.83,
      "H": 0.851,
      "I": 0.214,
      "J": 0.78,
      "K": 0.797,
      "L": 0.779,
      "M": 0.928,
      "N": 0.832,
      "O": 0.828,
      "P": 0.791,
      "Q": 0.884,
      "R": 0.825,
      "S": 0.822,
      "T": 0.759,
      "U": 0.828,
      "V": 1.003,
      "W": 1.179,
      "X": 0.812,
      "Y": 0.806,
      "Z": 0.821,
      "a": 0.694,
      "b": 0.667,
      "c": 0.695,
      "d": 0.667,
      "e": 0.692,
      "f": 0.407,
      "g": 0.683,
      "h": 0.668,
      "i": 0.208,
      "j": 0.239,
      "k": 0.646,
      "l": 0.302,
      "m": 0.978,
      "n": 0.696,
      "o": 0.692,
      "p": 0.664,
      "q": 0.664,
      "r": 0.512,
      "s": 0.686,
      "t": 0.41,
      "u": 0.695,
      "v": 0.79,
      "w": 1.071,
      "x": 0.692,
      "y": 0.685,
      "z": 0.698,
      ".": 0.214,
      ",": 0.193,
      "!": 0.22,
      "?": 0.678,
      "'": 0.224,
      "\u2019": 0.166,
      "\"": 0.372,
      ":": 0.214,
      ";": 0.193,
      "-": 0.517,
      "\u2013": 0.708,
      "\u2014": 0.822,
      "(": 0.277,
      ")": 0.278,
      "[": 0.275,
      "]": 0.276,
      "/": 0.521,
      "&amp;": 0.938,
      "%": 0.966,
      "+": 0.433,
      "#": 0.797,
      "@": 0.832,
      "*": 0.491,
      " ": 0.272
    }
  },
  "Permanent Marker": {
    "ascent": 1.11,
    "descent": 0.32,
    "widths": {
      "0": 0.6865,
      "1": 0.3223,
      "2": 0.6504,
      "3": 0.5615,
      "4": 0.582,
      "5": 0.5801,
      "6": 0.501,
      "7": 0.5762,
      "8": 0.5371,
      "9": 0.5293,
      "A": 0.7021,
      "B": 0.6865,
      "C": 0.6113,
      "D": 0.5889,
      "E": 0.627,
      "F": 0.5752,
      "G": 0.7891,
      "H": 0.6953,
      "I": 0.5156,
      "J": 0.6689,
      "K": 0.7764,
      "L": 0.6328,
      "M": 0.9736,
      "N": 0.6846,
      "O": 0.7441,
      "P": 0.6396,
      "Q": 0.7441,
      "R": 0.6465,
      "S": 0.5762,
      "T": 0.6074,
      "U": 0.6953,
      "V": 0.584,
      "W": 0.9941,
      "X": 0.623,
      "Y": 0.4932,
      "Z": 0.6641,
      "a": 0.5811,
      "b": 0.5732,
      "c": 0.5137,
      "d": 0.4971,
      "e": 0.5215,
      "f": 0.4551,
      "g": 0.6523,
      "h": 0.582,
      "i": 0.3203,
      "j": 0.5342,
      "k": 0.6455,
      "l": 0.5332,
      "m": 0.8145,
      "n": 0.5645,
      "o": 0.6289,
      "p": 0.5244,
      "q": 0.625,
      "r": 0.541,
      "s": 0.4639,
      "t": 0.4795,
      "u": 0.585,
      "v": 0.4854,
      "w": 0.8252,
      "x": 0.5068,
      "y": 0.4004,
      "z": 0.5479,
      ".": 0.2285,
      ",": 0.2041,
      "!": 0.2607,
      "?": 0.5566,
      "'": 0.2656,
      "\u2019": 0.1992,
      "\"": 0.4922,
      ":": 0.2412,
      ";": 0.2451,
      "-": 0.6094,
      "\u2013": 0.5889,
      "\u2014": 0.8789,
      "(": 0.3613,
      ")": 0.3613,
      "[": 0.3877,
      "]": 0.4033,
      "/": 0.4014,
      "&amp;": 0.5996,
      "%": 0.6436,
      "+": 0.5791,
      "#": 0.7031,
      "@": 0.7324,
      "*": 0.6738,
      " ": 0.3701
    }
  },
  "Press Start 2P": {
    "ascent": 1,
    "descent": 0,
    "widths": {
      "0": 1,
      "1": 1,
      "2": 1,
      "3": 1,
      "4": 1,
      "5": 1,
      "6": 1,
      "7": 1,
      "8": 1,
      "9": 1,
      "A": 1,
      "B": 1,
      "C": 1,
      "D": 1,
      "E": 1,
      "F": 1,
      "G": 1,
      "H": 1,
      "I": 1,
      "J": 1,
      "K": 1,
      "L": 1,
      "M": 1,
      "N": 1,
      "O": 1,
      "P": 1,
      "Q": 1,
      "R": 1,
      "S": 1,
      "T": 1,
      "U": 1,
      "V": 1,
      "W": 1,
      "X": 1,
      "Y": 1,
      "Z": 1,
      "a": 1,
      "b": 1,
      "c": 1,
      "d": 1,
      "e": 1,
      "f": 1,
      "g": 1,
      "h": 1,
      "i": 1,
      "j": 1,
      "k": 1,
      "l": 1,
      "m": 1,
      "n": 1,
      "o": 1,
      "p": 1,
      "q": 1,
      "r": 1,
      "s": 1,
      "t": 1,
      "u": 1,
      "v": 1,
      "w": 1,
      "x": 1,
      "y": 1,
      "z": 1,
      ".": 1,
      ",": 1,
      "!": 1,
      "?": 1,
      "'": 1,
      "\u2019": 1,
      "\"": 1,
      ":": 1,
      ";": 1,
      "-": 1,
      "\u2013": 1,
      "\u2014": 1,
      "(": 1,
      ")": 1,
      "[": 1,
      "]": 1,
      "/": 1,
      "&amp;": 1,
      "%": 1,
      "+": 1,
      "#": 1,
      "@": 1,
      "*": 1,
      " ": 1
    }
  },
  "Rajdhani": {
    "ascent": 0.93,
    "descent": 0.35,
    "widths": {
      "0": 0.52,
      "1": 0.322,
      "2": 0.482,
      "3": 0.5,
      "4": 0.526,
      "5": 0.489,
      "6": 0.505,
      "7": 0.395,
      "8": 0.526,
      "9": 0.505,
      "A": 0.54,
      "B": 0.549,
      "C": 0.541,
      "D": 0.556,
      "E": 0.48,
      "F": 0.457,
      "G": 0.541,
      "H": 0.572,
      "I": 0.241,
      "J": 0.505,
      "K": 0.538,
      "L": 0.451,
      "M": 0.705,
      "N": 0.581,
      "O": 0.553,
      "P": 0.526,
      "Q": 0.56,
      "R": 0.537,
      "S": 0.555,
      "T": 0.427,
      "U": 0.554,
      "V": 0.506,
      "W": 0.797,
      "X": 0.514,
      "Y": 0.5,
      "Z": 0.493,
      "a": 0.507,
      "b": 0.51,
      "c": 0.399,
      "d": 0.51,
      "e": 0.492,
      "f": 0.318,
      "g": 0.51,
      "h": 0.512,
      "i": 0.221,
      "j": 0.221,
      "k": 0.443,
      "l": 0.221,
      "m": 0.777,
      "n": 0.512,
      "o": 0.5,
      "p": 0.51,
      "q": 0.507,
      "r": 0.323,
      "s": 0.433,
      "t": 0.349,
      "u": 0.512,
      "v": 0.431,
      "w": 0.686,
      "x": 0.427,
      "y": 0.463,
      "z": 0.418,
      ".": 0.194,
      ",": 0.194,
      "!": 0.237,
      "?": 0.471,
      "'": 0.186,
      "\u2019": 0.181,
      "\"": 0.337,
      ":": 0.194,
      ";": 0.194,
      "-": 0.312,
      "\u2013": 0.405,
      "\u2014": 0.565,
      "(": 0.29,
      ")": 0.29,
      "[": 0.297,
      "]": 0.297,
      "/": 0.4,
      "&amp;": 0.603,
      "%": 0.724,
      "+": 0.535,
      "#": 0.675,
      "@": 0.751,
      "*": 0.404,
      " ": 0.254
    }
  },
  "Saira Stencil One": {
    "ascent": 1.13,
    "descent": 0.44,
    "widths": {
      "0": 0.66,
      "1": 0.402,
      "2": 0.614,
      "3": 0.61,
      "4": 0.654,
      "5": 0.628,
      "6": 0.662,
      "7": 0.576,
      "8": 0.676,
      "9": 0.662,
      "A": 0.687,
      "B": 0.67,
      "C": 0.568,
      "D": 0.68,
      "E": 0.616,
      "F": 0.576,
      "G": 0.66,
      "H": 0.716,
      "I": 0.33,
      "J": 0.425,
      "K": 0.683,
      "L": 0.541,
      "M": 0.885,
      "N": 0.716,
      "O": 0.698,
      "P": 0.645,
      "Q": 0.698,
      "R": 0.674,
      "S": 0.612,
      "T": 0.581,
      "U": 0.69,
      "V": 0.663,
      "W": 0.996,
      "X": 0.678,
      "Y": 0.633,
      "Z": 0.618,
      "a": 0.558,
      "b": 0.578,
      "c": 0.465,
      "d": 0.578,
      "e": 0.557,
      "f": 0.437,
      "g": 0.578,
      "h": 0.588,
      "i": 0.299,
      "j": 0.299,
      "k": 0.576,
      "l": 0.299,
      "m": 0.857,
      "n": 0.588,
      "o": 0.572,
      "p": 0.578,
      "q": 0.578,
      "r": 0.425,
      "s": 0.498,
      "t": 0.427,
      "u": 0.586,
      "v": 0.551,
      "w": 0.846,
      "x": 0.566,
      "y": 0.588,
      "z": 0.534,
      ".": 0.33,
      ",": 0.33,
      "!": 0.363,
      "?": 0.521,
      "'": 0.3,
      "\u2019": 0.32,
      "\"": 0.525,
      ":": 0.33,
      ";": 0.33,
      "-": 0.379,
      "\u2013": 0.48,
      "\u2014": 0.88,
      "(": 0.374,
      ")": 0.374,
      "[": 0.399,
      "]": 0.399,
      "/": 0.429,
      "&amp;": 0.739,
      "%": 0.898,
      "+": 0.54,
      "#": 0.7,
      "@": 0.944,
      "*": 0.454,
      " ": 0.26
    }
  },
  "Shippori Mincho": {
    "ascent": 1.16,
    "descent": 0.29,
    "widths": {
      "0": 0.52,
      "1": 0.487,
      "2": 0.511,
      "3": 0.523,
      "4": 0.503,
      "5": 0.502,
      "6": 0.532,
      "7": 0.545,
      "8": 0.539,
      "9": 0.532,
      "A": 0.762,
      "B": 0.716,
      "C": 0.716,
      "D": 0.782,
      "E": 0.72,
      "F": 0.686,
      "G": 0.744,
      "H": 0.889,
      "I": 0.408,
      "J": 0.437,
      "K": 0.812,
      "L": 0.712,
      "M": 1.013,
      "N": 0.825,
      "O": 0.771,
      "P": 0.655,
      "Q": 0.8,
      "R": 0.758,
      "S": 0.651,
      "T": 0.743,
      "U": 0.815,
      "V": 0.769,
      "W": 1.022,
      "X": 0.745,
      "Y": 0.754,
      "Z": 0.593,
      "a": 0.55,
      "b": 0.579,
      "c": 0.509,
      "d": 0.577,
      "e": 0.543,
      "f": 0.355,
      "g": 0.592,
      "h": 0.628,
      "i": 0.317,
      "j": 0.239,
      "k": 0.586,
      "l": 0.318,
      "m": 0.956,
      "n": 0.633,
      "o": 0.576,
      "p": 0.606,
      "q": 0.57,
      "r": 0.437,
      "s": 0.499,
      "t": 0.327,
      "u": 0.604,
      "v": 0.558,
      "w": 0.783,
      "x": 0.576,
      "y": 0.557,
      "z": 0.48,
      ".": 0.249,
      ",": 0.25,
      "!": 0.288,
      "?": 0.444,
      "'": 0.198,
      "\u2019": 0.203,
      "\"": 0.389,
      ":": 0.209,
      ";": 0.21,
      "-": 0.271,
      "\u2013": 0.536,
      "\u2014": 1,
      "(": 0.363,
      ")": 0.363,
      "[": 0.438,
      "]": 0.437,
      "/": 0.443,
      "&amp;": 0.82,
      "%": 0.938,
      "+": 0.605,
      "#": 0.676,
      "@": 0.906,
      "*": 0.441,
      " ": 0.258
    }
  },
  "Sora": {
    "ascent": 0.97,
    "descent": 0.29,
    "widths": {
      "0": 0.743,
      "1": 0.42,
      "2": 0.618,
      "3": 0.614,
      "4": 0.642,
      "5": 0.623,
      "6": 0.659,
      "7": 0.574,
      "8": 0.637,
      "9": 0.659,
      "A": 0.756,
      "B": 0.68,
      "C": 0.795,
      "D": 0.791,
      "E": 0.6,
      "F": 0.56,
      "G": 0.831,
      "H": 0.804,
      "I": 0.314,
      "J": 0.64,
      "K": 0.678,
      "L": 0.544,
      "M": 0.92,
      "N": 0.849,
      "O": 0.865,
      "P": 0.64,
      "Q": 0.865,
      "R": 0.7,
      "S": 0.689,
      "T": 0.594,
      "U": 0.789,
      "V": 0.708,
      "W": 1.043,
      "X": 0.696,
      "Y": 0.652,
      "Z": 0.654,
      "a": 0.575,
      "b": 0.689,
      "c": 0.606,
      "d": 0.689,
      "e": 0.612,
      "f": 0.379,
      "g": 0.675,
      "h": 0.64,
      "i": 0.308,
      "j": 0.317,
      "k": 0.573,
      "l": 0.286,
      "m": 0.968,
      "n": 0.64,
      "o": 0.676,
      "p": 0.689,
      "q": 0.689,
      "r": 0.41,
      "s": 0.536,
      "t": 0.426,
      "u": 0.628,
      "v": 0.559,
      "w": 0.848,
      "x": 0.56,
      "y": 0.547,
      "z": 0.488,
      ".": 0.262,
      ",": 0.262,
      "!": 0.296,
      "?": 0.552,
      "'": 0.262,
      "\u2019": 0.244,
      "\"": 0.43,
      ":": 0.262,
      ";": 0.262,
      "-": 0.504,
      "\u2013": 0.512,
      "\u2014": 0.9,
      "(": 0.376,
      ")": 0.376,
      "[": 0.376,
      "]": 0.376,
      "/": 0.344,
      "&amp;": 0.688,
      "%": 0.85,
      "+": 0.59,
      "#": 0.692,
      "@": 1.122,
      "*": 0.567,
      " ": 0.228
    }
  },
  "Space Grotesk": {
    "ascent": 0.98,
    "descent": 0.29,
    "widths": {
      "0": 0.641,
      "1": 0.418,
      "2": 0.603,
      "3": 0.604,
      "4": 0.625,
      "5": 0.599,
      "6": 0.612,
      "7": 0.564,
      "8": 0.623,
      "9": 0.612,
      "A": 0.63,
      "B": 0.661,
      "C": 0.642,
      "D": 0.663,
      "E": 0.563,
      "F": 0.54,
      "G": 0.66,
      "H": 0.656,
      "I": 0.244,
      "J": 0.594,
      "K": 0.61,
      "L": 0.546,
      "M": 0.865,
      "N": 0.664,
      "O": 0.669,
      "P": 0.601,
      "Q": 0.669,
      "R": 0.631,
      "S": 0.613,
      "T": 0.588,
      "U": 0.666,
      "V": 0.619,
      "W": 0.875,
      "X": 0.638,
      "Y": 0.61,
      "Z": 0.577,
      "a": 0.575,
      "b": 0.644,
      "c": 0.598,
      "d": 0.644,
      "e": 0.592,
      "f": 0.442,
      "g": 0.642,
      "h": 0.613,
      "i": 0.245,
      "j": 0.247,
      "k": 0.538,
      "l": 0.245,
      "m": 0.86,
      "n": 0.613,
      "o": 0.618,
      "p": 0.644,
      "q": 0.644,
      "r": 0.377,
      "s": 0.527,
      "t": 0.46,
      "u": 0.612,
      "v": 0.539,
      "w": 0.801,
      "x": 0.593,
      "y": 0.612,
      "z": 0.521,
      ".": 0.253,
      ",": 0.251,
      "!": 0.253,
      "?": 0.58,
      "'": 0.251,
      "\u2019": 0.251,
      "\"": 0.425,
      ":": 0.253,
      ";": 0.253,
      "-": 0.455,
      "\u2013": 0.607,
      "\u2014": 0.911,
      "(": 0.379,
      ")": 0.377,
      "[": 0.335,
      "]": 0.335,
      "/": 0.37,
      "&amp;": 0.604,
      "%": 0.772,
      "+": 0.62,
      "#": 0.628,
      "@": 1.026,
      "*": 0.52,
      " ": 0.257
    }
  },
  "Special Elite": {
    "ascent": 0.7,
    "descent": 0.3,
    "widths": {
      "0": 0.6108,
      "1": 0.5698,
      "2": 0.5737,
      "3": 0.5571,
      "4": 0.6123,
      "5": 0.5376,
      "6": 0.5884,
      "7": 0.5552,
      "8": 0.5986,
      "9": 0.5845,
      "A": 0.5498,
      "B": 0.604,
      "C": 0.5791,
      "D": 0.6226,
      "E": 0.6387,
      "F": 0.6055,
      "G": 0.6152,
      "H": 0.6528,
      "I": 0.4951,
      "J": 0.541,
      "K": 0.5825,
      "L": 0.6025,
      "M": 0.6978,
      "N": 0.6279,
      "O": 0.6167,
      "P": 0.5532,
      "Q": 0.6021,
      "R": 0.6367,
      "S": 0.5889,
      "T": 0.5942,
      "U": 0.6216,
      "V": 0.6113,
      "W": 0.6436,
      "X": 0.582,
      "Y": 0.561,
      "Z": 0.5923,
      "a": 0.5654,
      "b": 0.5654,
      "c": 0.5474,
      "d": 0.603,
      "e": 0.5439,
      "f": 0.4731,
      "g": 0.583,
      "h": 0.6167,
      "i": 0.5684,
      "j": 0.4155,
      "k": 0.6206,
      "l": 0.5405,
      "m": 0.6636,
      "n": 0.6323,
      "o": 0.5835,
      "p": 0.5874,
      "q": 0.5679,
      "r": 0.5791,
      "s": 0.5195,
      "t": 0.5103,
      "u": 0.6387,
      "v": 0.605,
      "w": 0.6787,
      "x": 0.6606,
      "y": 0.5928,
      "z": 0.5288,
      ".": 0.3496,
      ",": 0.3364,
      "!": 0.2764,
      "?": 0.4702,
      "'": 0.1963,
      "\u2019": 0.2642,
      "\"": 0.3521,
      ":": 0.3433,
      ";": 0.3286,
      "-": 0.6362,
      "\u2013": 0.6118,
      "\u2014": 1.1138,
      "(": 0.2793,
      ")": 0.2817,
      "[": 0.312,
      "]": 0.3125,
      "/": 0.5571,
      "&amp;": 0.6763,
      "%": 0.667,
      "+": 0.4961,
      "#": 0.5547,
      "@": 0.665,
      "*": 0.5229,
      " ": 0.293
    }
  },
  "Teko": {
    "ascent": 0.96,
    "descent": 0.48,
    "widths": {
      "0": 0.39,
      "1": 0.235,
      "2": 0.328,
      "3": 0.355,
      "4": 0.363,
      "5": 0.358,
      "6": 0.382,
      "7": 0.291,
      "8": 0.389,
      "9": 0.378,
      "A": 0.385,
      "B": 0.409,
      "C": 0.377,
      "D": 0.412,
      "E": 0.343,
      "F": 0.332,
      "G": 0.382,
      "H": 0.417,
      "I": 0.21,
      "J": 0.334,
      "K": 0.384,
      "L": 0.301,
      "M": 0.516,
      "N": 0.41,
      "O": 0.393,
      "P": 0.379,
      "Q": 0.393,
      "R": 0.396,
      "S": 0.362,
      "T": 0.31,
      "U": 0.397,
      "V": 0.364,
      "W": 0.609,
      "X": 0.373,
      "Y": 0.361,
      "Z": 0.313,
      "a": 0.36,
      "b": 0.36,
      "c": 0.352,
      "d": 0.36,
      "e": 0.36,
      "f": 0.222,
      "g": 0.36,
      "h": 0.365,
      "i": 0.178,
      "j": 0.178,
      "k": 0.357,
      "l": 0.178,
      "m": 0.549,
      "n": 0.365,
      "o": 0.37,
      "p": 0.36,
      "q": 0.36,
      "r": 0.238,
      "s": 0.328,
      "t": 0.219,
      "u": 0.365,
      "v": 0.317,
      "w": 0.543,
      "x": 0.342,
      "y": 0.342,
      "z": 0.292,
      ".": 0.165,
      ",": 0.165,
      "!": 0.209,
      "?": 0.342,
      "'": 0.161,
      "\u2019": 0.166,
      "\"": 0.292,
      ":": 0.165,
      ";": 0.165,
      "-": 0.315,
      "\u2013": 0.42,
      "\u2014": 0.536,
      "(": 0.264,
      ")": 0.264,
      "[": 0.311,
      "]": 0.311,
      "/": 0.39,
      "&amp;": 0.462,
      "%": 0.641,
      "+": 0.408,
      "#": 0.496,
      "@": 0.696,
      "*": 0.321,
      " ": 0.145
    }
  },
  "VT323": {
    "ascent": 0.8,
    "descent": 0.2,
    "widths": {
      "0": 0.4,
      "1": 0.4,
      "2": 0.4,
      "3": 0.4,
      "4": 0.4,
      "5": 0.4,
      "6": 0.4,
      "7": 0.4,
      "8": 0.4,
      "9": 0.4,
      "A": 0.4,
      "B": 0.4,
      "C": 0.4,
      "D": 0.4,
      "E": 0.4,
      "F": 0.4,
      "G": 0.4,
      "H": 0.4,
      "I": 0.4,
      "J": 0.4,
      "K": 0.4,
      "L": 0.4,
      "M": 0.4,
      "N": 0.4,
      "O": 0.4,
      "P": 0.4,
      "Q": 0.4,
      "R": 0.4,
      "S": 0.4,
      "T": 0.4,
      "U": 0.4,
      "V": 0.4,
      "W": 0.4,
      "X": 0.4,
      "Y": 0.4,
      "Z": 0.4,
      "a": 0.4,
      "b": 0.4,
      "c": 0.4,
      "d": 0.4,
      "e": 0.4,
      "f": 0.4,
      "g": 0.4,
      "h": 0.4,
      "i": 0.4,
      "j": 0.4,
      "k": 0.4,
      "l": 0.4,
      "m": 0.4,
      "n": 0.4,
      "o": 0.4,
      "p": 0.4,
      "q": 0.4,
      "r": 0.4,
      "s": 0.4,
      "t": 0.4,
      "u": 0.4,
      "v": 0.4,
      "w": 0.4,
      "x": 0.4,
      "y": 0.4,
      "z": 0.4,
      ".": 0.4,
      ",": 0.4,
      "!": 0.4,
      "?": 0.4,
      "'": 0.4,
      "\u2019": 0.4,
      "\"": 0.4,
      ":": 0.4,
      ";": 0.4,
      "-": 0.4,
      "\u2013": 0.4,
      "\u2014": 0.4,
      "(": 0.4,
      ")": 0.4,
      "[": 0.4,
      "]": 0.4,
      "/": 0.4,
      "&amp;": 0.4,
      "%": 0.4,
      "+": 0.4,
      "#": 0.4,
      "@": 0.4,
      "*": 0.4,
      " ": 0.4
    }
  }
}
assets/strokefonts/HersheyScript1.svg
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >

<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1">

<metadata>
Font name: Hershey Script 1-stroke

Originally prepared in 2011 and converted to SVG fonts
in 2019 by Windell H. Oskay, www.evilmadscientist.com

Contents adapted from emergent.unpythonic.net/software/hershey
 by way of "Hershey Fonts in SVG" by Marty McGuire
 http://www.thingiverse.com/thing:6168
 
-------------------------------------------------------------------
The Hershey Fonts are a set of vector fonts with a liberal license.

USE RESTRICTION:
    This distribution of the Hershey Fonts may be used by anyone for
    any purpose, commercial or otherwise, providing that:
        1. The following acknowledgements must be distributed with
            the font data:
            - The Hershey Fonts were originally created by Dr.
                A. V. Hershey while working at the U. S.
                National Bureau of Standards.
            - The format of the Font data in this distribution
                was originally created by
                    James Hurt
                    Cognition, Inc.
                    900 Technology Park Drive
                    Billerica, MA 01821
                    (mit-eddie!ci-dandelion!hurt)
        2. The font data in this distribution may be converted into
            any other format *EXCEPT* the format distributed by
            the U.S. NTIS where each point is described
            in eight bytes as "xxx yyy:", where xxx and yyy are
            the coordinate values as ASCII numbers.

</metadata>
<defs>
<font id="HersheyScript1" horiz-adv-x="378" >
<font-face
font-family="Hershey Script 1-stroke"
units-per-em="1000"
ascent="800"
descent="-200"
cap-height="500"
x-height="300"
/>
<missing-glyph horiz-adv-x="378" />
<glyph unicode=" " glyph-name="space" horiz-adv-x="378" />
<glyph unicode="!" glyph-name="exclam" horiz-adv-x="346" d="M 353 662 L 321 630 L 258 252 M 353 630 L 258 252 M 353 662 L 384 630 L 258 252 M 195 63 L 164 31.5 L 195 0 L 227 31.5 L 195 63" />
<glyph unicode="&#x22;" glyph-name="quotedbl" horiz-adv-x="567" d="M 252 662 L 189 441 M 284 662 L 189 441 M 536 662 L 472 441 M 567 662 L 472 441" />
<glyph unicode="#" glyph-name="numbersign" horiz-adv-x="662" d="M 378 788 L 158 -220 M 567 788 L 346 -220 M 158 378 L 598 378 M 126 189 L 567 189" />
<glyph unicode="$" glyph-name="dollar" horiz-adv-x="662" d="M 410 788 L 158 -126 M 567 788 L 315 -126 M 598 536 L 567 504 L 598 472 L 630 504 L 630 536 L 598 598 L 567 630 L 472 662 L 346 662 L 252 630 L 189 567 L 189 504 L 220 441 L 252 410 L 472 284 L 536 220 M 189 504 L 252 441 L 472 315 L 504 284 L 536 220 L 536 126 L 504 63 L 472 31.5 L 378 0 L 252 0 L 158 31.5 L 126 63 L 94.5 126 L 94.5 158 L 126 189 L 158 158 L 126 126" />
<glyph unicode="%" glyph-name="percent" horiz-adv-x="756" d="M 693 662 L 126 0 M 284 662 L 346 598 L 346 536 L 315 472 L 252 441 L 189 441 L 126 504 L 126 567 L 158 630 L 220 662 L 284 662 L 346 630 L 441 598 L 536 598 L 630 630 L 693 662 M 567 220 L 504 189 L 472 126 L 472 63 L 536 0 L 598 0 L 662 31.5 L 693 94.5 L 693 158 L 630 220 L 567 220" />
<glyph unicode="&amp;" glyph-name="ampersand" horiz-adv-x="819" d="M 756 410 L 724 378 L 756 346 L 788 378 L 788 410 L 756 441 L 724 441 L 662 410 L 598 346 L 441 94.5 L 378 31.5 L 315 0 L 220 0 L 126 31.5 L 94.5 94.5 L 94.5 158 L 126 220 L 158 252 L 220 284 L 378 346 L 441 378 L 504 441 L 536 504 L 536 567 L 504 630 L 441 662 L 378 630 L 346 567 L 346 472 L 378 284 L 410 189 L 472 94.5 L 536 31.5 L 598 0 L 662 0 L 693 63 L 693 94.5 M 220 0 L 158 31.5 L 126 94.5 L 126 158 L 158 220 L 189 252 L 378 346 M 346 472 L 378 315 L 410 220 L 472 126 L 536 63 L 598 31.5 L 662 31.5 L 693 63" />
<glyph unicode="&apos;" glyph-name="quotesingle" horiz-adv-x="346" d="M 284 598 L 252 630 L 284 662 L 315 630 L 315 598 L 284 536 L 220 472" />
<glyph unicode="(" glyph-name="parenleft" horiz-adv-x="472" d="M 504 788 L 378 693 L 284 598 L 220 504 L 158 378 L 126 220 L 126 94.5 L 158 -63 L 189 -158 L 220 -220 M 378 693 L 284 567 L 220 441 L 189 346 L 158 189 L 158 31.5 L 189 -126 L 220 -220" />
<glyph unicode=")" glyph-name="parenright" horiz-adv-x="472" d="M 315 788 L 346 724 L 378 630 L 410 472 L 410 346 L 378 189 L 315 63 L 252 -31.5 L 158 -126 L 31.5 -220 M 315 788 L 346 693 L 378 536 L 378 378 L 346 220 L 315 126 L 252 0 L 158 -126" />
<glyph unicode="*" glyph-name="asterisk" horiz-adv-x="536" d="M 346 662 L 346 284 M 189 567 L 504 378 M 504 567 L 189 378" />
<glyph unicode="+" glyph-name="plus" horiz-adv-x="819" d="M 441 567 L 441 0 M 158 284 L 724 284" />
<glyph unicode="," glyph-name="comma" horiz-adv-x="346" d="M 126 0 L 94.5 31.5 L 126 63 L 158 31.5 L 158 0 L 126 -63 L 63 -126" />
<glyph unicode="-" glyph-name="hyphen" horiz-adv-x="819" d="M 158 284 L 724 284" />
<glyph unicode="." glyph-name="period" horiz-adv-x="315" d="M 189 63 L 158 31.5 L 189 0 L 220 31.5 L 189 63" />
<glyph unicode="/" glyph-name="slash" horiz-adv-x="693" d="M 788 788 L -31.5 -220" />
<glyph unicode="0" glyph-name="zero" horiz-adv-x="662" d="M 410 662 L 315 630 L 252 567 L 189 472 L 158 378 L 126 252 L 126 158 L 158 63 L 189 31.5 L 252 0 L 315 0 L 410 31.5 L 472 94.5 L 536 189 L 567 284 L 598 410 L 598 504 L 567 598 L 536 630 L 472 662 L 410 662 M 410 662 L 346 630 L 284 567 L 220 472 L 189 378 L 158 252 L 158 158 L 189 63 L 252 0 M 315 0 L 378 31.5 L 441 94.5 L 504 189 L 536 284 L 567 410 L 567 504 L 536 598 L 472 662" />
<glyph unicode="1" glyph-name="one" horiz-adv-x="662" d="M 410 536 L 252 0 M 472 662 L 284 0 M 472 662 L 378 567 L 284 504 L 220 472 M 441 567 L 315 504 L 220 472" />
<glyph unicode="2" glyph-name="two" horiz-adv-x="662" d="M 252 536 L 284 504 L 252 472 L 220 504 L 220 536 L 252 598 L 284 630 L 378 662 L 472 662 L 567 630 L 598 567 L 598 504 L 567 441 L 504 378 L 410 315 L 284 252 L 189 189 L 126 126 L 63 0 M 472 662 L 536 630 L 567 567 L 567 504 L 536 441 L 472 378 L 284 252 M 94.5 63 L 126 94.5 L 189 94.5 L 346 31.5 L 441 31.5 L 504 63 L 536 126 M 189 94.5 L 346 0 L 441 0 L 504 31.5 L 536 126" />
<glyph unicode="3" glyph-name="three" horiz-adv-x="662" d="M 252 536 L 284 504 L 252 472 L 220 504 L 220 536 L 252 598 L 284 630 L 378 662 L 472 662 L 567 630 L 598 567 L 598 504 L 567 441 L 472 378 L 378 346 M 472 662 L 536 630 L 567 567 L 567 504 L 536 441 L 472 378 M 315 346 L 378 346 L 472 315 L 504 284 L 536 220 L 536 126 L 504 63 L 472 31.5 L 378 0 L 252 0 L 158 31.5 L 126 63 L 94.5 126 L 94.5 158 L 126 189 L 158 158 L 126 126 M 378 346 L 441 315 L 472 284 L 504 220 L 504 126 L 472 63 L 441 31.5 L 378 0" />
<glyph unicode="4" glyph-name="four" horiz-adv-x="662" d="M 536 630 L 346 0 M 567 662 L 378 0 M 567 662 L 94.5 189 L 598 189" />
<glyph unicode="5" glyph-name="five" horiz-adv-x="662" d="M 315 662 L 158 346 M 315 662 L 630 662 M 315 630 L 472 630 L 630 662 M 158 346 L 189 378 L 284 410 L 378 410 L 472 378 L 504 346 L 536 284 L 536 189 L 504 94.5 L 441 31.5 L 346 0 L 252 0 L 158 31.5 L 126 63 L 94.5 126 L 94.5 158 L 126 189 L 158 158 L 126 126 M 378 410 L 441 378 L 472 346 L 504 284 L 504 189 L 472 94.5 L 410 31.5 L 346 0" />
<glyph unicode="6" glyph-name="six" horiz-adv-x="662" d="M 567 567 L 536 536 L 567 504 L 598 536 L 598 567 L 567 630 L 504 662 L 410 662 L 315 630 L 252 567 L 189 472 L 158 378 L 126 252 L 126 126 L 158 63 L 189 31.5 L 252 0 L 346 0 L 441 31.5 L 504 94.5 L 536 158 L 536 252 L 504 315 L 472 346 L 410 378 L 315 378 L 252 346 L 189 284 L 158 220 M 410 662 L 346 630 L 284 567 L 220 472 L 189 378 L 158 252 L 158 94.5 L 189 31.5 M 346 0 L 410 31.5 L 472 94.5 L 504 158 L 504 284 L 472 346" />
<glyph unicode="7" glyph-name="seven" horiz-adv-x="662" d="M 220 662 L 158 472 M 630 662 L 598 567 L 536 472 L 378 284 L 315 189 L 284 126 L 252 0 M 536 472 L 346 284 L 284 189 L 252 126 L 220 0 M 189 567 L 284 662 L 346 662 L 504 567 M 220 598 L 284 630 L 346 630 L 504 567 L 567 567 L 598 598 L 630 662" />
<glyph unicode="8" glyph-name="eight" horiz-adv-x="662" d="M 378 662 L 284 630 L 252 598 L 220 536 L 220 441 L 252 378 L 315 346 L 410 346 L 536 378 L 567 410 L 598 472 L 598 567 L 567 630 L 472 662 L 378 662 M 378 662 L 315 630 L 284 598 L 252 536 L 252 441 L 284 378 L 315 346 M 410 346 L 504 378 L 536 410 L 567 472 L 567 567 L 536 630 L 472 662 M 315 346 L 189 315 L 126 252 L 94.5 189 L 94.5 94.5 L 126 31.5 L 220 0 L 346 0 L 472 31.5 L 504 63 L 536 126 L 536 220 L 504 284 L 472 315 L 410 346 M 315 346 L 220 315 L 158 252 L 126 189 L 126 94.5 L 158 31.5 L 220 0 M 346 0 L 441 31.5 L 472 63 L 504 126 L 504 252 L 472 315" />
<glyph unicode="9" glyph-name="nine" horiz-adv-x="662" d="M 567 441 L 536 378 L 472 315 L 410 284 L 315 284 L 252 315 L 220 346 L 189 410 L 189 504 L 220 567 L 284 630 L 378 662 L 472 662 L 536 630 L 567 598 L 598 536 L 598 410 L 567 284 L 536 189 L 472 94.5 L 410 31.5 L 315 0 L 220 0 L 158 31.5 L 126 94.5 L 126 126 L 158 158 L 189 126 L 158 94.5 M 252 315 L 220 378 L 220 504 L 252 567 L 315 630 L 378 662 M 536 630 L 567 567 L 567 410 L 536 284 L 504 189 L 441 94.5 L 378 31.5 L 315 0" />
<glyph unicode=":" glyph-name="colon" horiz-adv-x="346" d="M 220 441 L 189 410 L 220 378 L 252 410 L 220 441 M 126 63 L 94.5 31.5 L 126 0 L 158 31.5" />
<glyph unicode=";" glyph-name="semicolon" horiz-adv-x="346" d="M 220 441 L 189 410 L 220 378 L 252 410 L 220 441 M 126 0 L 94.5 31.5 L 126 63 L 158 31.5 L 158 0 L 126 -63 L 63 -126" />
<glyph unicode="&#x3c;" glyph-name="less" horiz-adv-x="756" d="M 662 567 L 158 284 L 662 0" />
<glyph unicode="=" glyph-name="equal" horiz-adv-x="819" d="M 158 378 L 724 378 M 158 189 L 724 189" />
<glyph unicode="&#x3e;" glyph-name="greater" horiz-adv-x="756" d="M 158 567 L 662 284 L 158 0" />
<glyph unicode="?" glyph-name="question" horiz-adv-x="662" d="M 252 536 L 284 504 L 252 472 L 220 504 L 220 536 L 252 598 L 284 630 L 378 662 L 504 662 L 598 630 L 630 567 L 630 504 L 598 441 L 567 410 L 378 346 L 315 315 L 315 252 L 346 220 L 410 220 M 504 662 L 567 630 L 598 567 L 598 504 L 567 441 L 536 410 L 472 378 M 284 63 L 252 31.5 L 284 0 L 315 31.5 L 284 63" />
<glyph unicode="@" glyph-name="at" horiz-adv-x="850" d="M 598 410 L 567 472 L 504 504 L 410 504 L 346 472 L 315 441 L 284 346 L 284 252 L 315 189 L 378 158 L 472 158 L 536 189 L 567 252 M 410 504 L 346 441 L 315 346 L 315 252 L 346 189 L 378 158 M 598 504 L 567 252 L 567 189 L 630 158 L 693 158 L 756 220 L 788 315 L 788 378 L 756 472 L 724 536 L 662 598 L 598 630 L 504 662 L 410 662 L 315 630 L 252 598 L 189 536 L 158 472 L 126 378 L 126 284 L 158 189 L 189 126 L 252 63 L 315 31.5 L 410 0 L 504 0 L 598 31.5 L 662 63 L 693 94.5 M 630 504 L 598 252 L 598 189 L 630 158" />
<glyph unicode="A" glyph-name="A" horiz-adv-x="630" d="M 31.5 0 L 94.5 31.5 L 189 126 L 284 252 L 410 472 L 504 662 L 504 0 L 472 94.5 L 410 189 L 346 252 L 252 315 L 189 315 L 158 284 L 158 220 L 189 158 L 252 94.5 L 346 31.5 L 441 0 L 598 0" />
<glyph unicode="B" glyph-name="B" horiz-adv-x="724" d="M 441 598 L 472 567 L 472 472 L 441 346 L 410 252 L 378 189 L 315 94.5 L 252 31.5 L 189 0 L 158 0 L 126 31.5 L 126 126 L 158 284 L 189 378 L 220 441 L 284 536 L 346 598 L 410 630 L 504 662 L 598 662 L 662 630 L 693 567 L 693 504 L 662 441 L 630 410 L 567 378 L 472 346 M 441 346 L 472 346 L 567 315 L 598 284 L 630 220 L 630 126 L 598 63 L 567 31.5 L 504 0 L 410 0 L 346 31.5 L 315 94.5" />
<glyph unicode="C" glyph-name="C" horiz-adv-x="630" d="M 410 472 L 410 441 L 441 410 L 504 410 L 567 441 L 598 504 L 598 567 L 567 630 L 504 662 L 410 662 L 315 630 L 252 567 L 189 472 L 158 410 L 126 284 L 126 158 L 158 63 L 189 31.5 L 252 0 L 315 0 L 410 31.5 L 472 94.5 L 504 158" />
<glyph unicode="D" glyph-name="D" horiz-adv-x="724" d="M 441 662 L 378 630 L 346 567 L 315 441 L 284 252 L 252 158 L 220 94.5 L 158 31.5 L 94.5 0 L 31.5 0 L 0 31.5 L 0 94.5 L 31.5 126 L 94.5 126 L 158 94.5 L 220 31.5 L 315 0 L 410 0 L 504 31.5 L 567 94.5 L 630 220 L 662 378 L 662 504 L 630 598 L 598 630 L 536 662 L 441 662 L 378 598 L 378 536 L 410 441 L 472 346 L 536 284 L 630 220 L 693 189" />
<glyph unicode="E" glyph-name="E" horiz-adv-x="630" d="M 472 536 L 472 504 L 504 472 L 567 472 L 598 504 L 598 567 L 567 630 L 472 662 L 346 662 L 252 630 L 220 567 L 220 472 L 252 410 L 284 378 L 378 346 L 284 346 L 189 315 L 158 284 L 126 220 L 126 126 L 158 63 L 189 31.5 L 284 0 L 378 0 L 472 31.5 L 536 94.5 L 567 158" />
<glyph unicode="F" glyph-name="F" horiz-adv-x="630" d="M 346 472 L 284 472 L 220 504 L 189 567 L 220 630 L 315 662 L 410 662 L 536 630 L 630 630 L 693 662 M 536 630 L 472 410 L 410 220 L 346 94.5 L 284 31.5 L 220 0 L 158 0 L 94.5 31.5 L 63 94.5 L 63 158 L 94.5 189 L 158 189 L 220 158 M 315 346 L 598 346" />
<glyph unicode="G" glyph-name="G" horiz-adv-x="724" d="M 31.5 0 L 94.5 31.5 L 220 158 L 315 315 L 346 410 L 378 536 L 378 630 L 346 662 L 315 662 L 284 630 L 252 567 L 252 472 L 284 410 L 346 378 L 472 378 L 567 410 L 598 441 L 630 504 L 630 315 L 598 158 L 567 94.5 L 504 31.5 L 410 0 L 284 0 L 189 31.5 L 126 94.5 L 94.5 158 L 94.5 220" />
<glyph unicode="H" glyph-name="H" horiz-adv-x="756" d="M 252 441 L 189 472 L 158 536 L 158 567 L 189 630 L 252 662 L 284 662 L 346 630 L 378 567 L 378 504 L 346 378 L 284 189 L 220 63 L 158 0 L 94.5 0 L 63 31.5 L 63 94.5 M 252 284 L 536 378 L 598 410 L 693 472 L 756 536 L 788 598 L 788 630 L 756 662 L 724 662 L 662 598 L 598 472 L 536 284 L 504 126 L 504 31.5 L 536 0 L 567 0 L 630 31.5 L 662 63 L 724 158" />
<glyph unicode="I" glyph-name="I" horiz-adv-x="536" d="M 472 158 L 410 220 L 346 315 L 315 378 L 284 472 L 284 567 L 315 630 L 346 662 L 410 662 L 441 630 L 472 567 L 472 472 L 441 315 L 378 158 L 346 94.5 L 284 31.5 L 220 0 L 158 0 L 94.5 31.5 L 63 94.5 L 63 158 L 94.5 189 L 158 189 L 220 158" />
<glyph unicode="J" glyph-name="J" horiz-adv-x="472" d="M 346 -94.5 L 284 0 L 220 158 L 189 346 L 189 536 L 220 630 L 284 662 L 346 662 L 378 630 L 410 536 L 410 441 L 378 284 L 284 0 L 220 -189 L 189 -284 L 158 -346 L 94.5 -378 L 63 -346 L 63 -284 L 94.5 -189 L 158 -94.5 L 220 -31.5 L 315 31.5 L 441 94.5" />
<glyph unicode="K" glyph-name="K" horiz-adv-x="756" d="M 252 441 L 189 472 L 158 536 L 158 567 L 189 630 L 252 662 L 284 662 L 346 630 L 378 567 L 378 504 L 346 378 L 284 189 L 220 63 L 158 0 L 94.5 0 L 63 31.5 L 63 94.5 M 788 567 L 788 630 L 756 662 L 724 662 L 662 630 L 598 567 L 536 472 L 472 410 L 410 378 L 346 378 M 410 378 L 441 315 L 441 94.5 L 472 31.5 L 504 0 L 536 0 L 598 31.5 L 630 63 L 693 158" />
<glyph unicode="L" glyph-name="L" horiz-adv-x="598" d="M 158 284 L 220 284 L 346 315 L 441 378 L 504 441 L 536 504 L 536 598 L 504 662 L 441 662 L 410 630 L 378 567 L 346 410 L 315 252 L 284 158 L 252 94.5 L 189 31.5 L 126 0 L 63 0 L 31.5 31.5 L 31.5 94.5 L 63 126 L 126 126 L 189 94.5 L 284 31.5 L 378 0 L 441 0 L 536 31.5 L 598 94.5" />
<glyph unicode="M" glyph-name="M" horiz-adv-x="1039.5" d="M 189 441 L 126 472 L 94.5 536 L 94.5 567 L 126 630 L 189 662 L 220 662 L 284 630 L 315 567 L 315 504 L 284 346 L 252 220 L 189 0 M 252 220 L 346 472 L 410 598 L 441 630 L 504 662 L 536 662 L 598 630 L 630 567 L 630 504 L 598 346 L 567 220 L 504 0 M 567 220 L 662 472 L 724 598 L 756 630 L 819 662 L 850 662 L 914 630 L 945 567 L 945 504 L 914 346 L 850 126 L 850 31.5 L 882 0 L 914 0 L 976 31.5 L 1008.0 63 L 1071.0 158" />
<glyph unicode="N" glyph-name="N" horiz-adv-x="756" d="M 189 441 L 126 472 L 94.5 536 L 94.5 567 L 126 630 L 189 662 L 220 662 L 284 630 L 315 567 L 315 504 L 284 346 L 252 220 L 189 0 M 252 220 L 346 472 L 410 598 L 441 630 L 504 662 L 567 662 L 630 630 L 662 567 L 662 504 L 630 346 L 567 126 L 567 31.5 L 598 0 L 630 0 L 693 31.5 L 724 63 L 788 158" />
<glyph unicode="O" glyph-name="O" horiz-adv-x="662" d="M 410 662 L 315 630 L 252 567 L 189 472 L 158 410 L 126 284 L 126 158 L 158 63 L 189 31.5 L 252 0 L 315 0 L 410 31.5 L 472 94.5 L 536 189 L 567 252 L 598 378 L 598 504 L 567 598 L 536 630 L 472 662 L 410 662 L 346 598 L 346 504 L 378 410 L 441 315 L 504 252 L 598 189 L 662 158" />
<glyph unicode="P" glyph-name="P" horiz-adv-x="788" d="M 441 598 L 472 567 L 472 472 L 441 346 L 410 252 L 378 189 L 315 94.5 L 252 31.5 L 189 0 L 158 0 L 126 31.5 L 126 126 L 158 284 L 189 378 L 220 441 L 284 536 L 346 598 L 410 630 L 504 662 L 662 662 L 724 630 L 756 598 L 788 536 L 788 441 L 756 378 L 724 346 L 662 315 L 567 315 L 504 346 L 472 378" />
<glyph unicode="Q" glyph-name="Q" horiz-adv-x="693" d="M 441 472 L 410 410 L 378 378 L 315 346 L 252 346 L 220 410 L 220 472 L 252 567 L 315 630 L 410 662 L 504 662 L 567 630 L 598 567 L 598 441 L 567 346 L 504 252 L 378 126 L 284 63 L 220 31.5 L 126 0 L 63 0 L 31.5 31.5 L 31.5 94.5 L 63 126 L 126 126 L 189 94.5 L 284 31.5 L 378 0 L 472 0 L 567 31.5 L 630 94.5" />
<glyph unicode="R" glyph-name="R" horiz-adv-x="788" d="M 441 598 L 472 567 L 472 472 L 441 346 L 410 252 L 378 189 L 315 94.5 L 252 31.5 L 189 0 L 158 0 L 126 31.5 L 126 126 L 158 284 L 189 378 L 220 441 L 284 536 L 346 598 L 410 630 L 504 662 L 630 662 L 693 630 L 724 598 L 756 536 L 756 441 L 724 378 L 693 346 L 630 315 L 536 315 L 441 346 L 472 315 L 504 252 L 504 94.5 L 536 31.5 L 598 0 L 662 31.5 L 693 63 L 756 158" />
<glyph unicode="S" glyph-name="S" horiz-adv-x="630" d="M 31.5 0 L 94.5 31.5 L 158 94.5 L 252 220 L 315 315 L 378 441 L 410 536 L 410 630 L 378 662 L 346 662 L 315 630 L 284 567 L 284 504 L 315 441 L 378 378 L 472 315 L 536 252 L 567 189 L 567 126 L 536 63 L 504 31.5 L 410 0 L 284 0 L 189 31.5 L 126 94.5 L 94.5 158 L 94.5 220" />
<glyph unicode="T" glyph-name="T" horiz-adv-x="598" d="M 346 472 L 284 472 L 220 504 L 189 567 L 220 630 L 315 662 L 410 662 L 536 630 L 630 630 L 693 662 M 536 630 L 472 410 L 410 220 L 346 94.5 L 284 31.5 L 220 0 L 158 0 L 94.5 31.5 L 63 94.5 L 63 158 L 94.5 189 L 158 189 L 220 158" />
<glyph unicode="U" glyph-name="U" horiz-adv-x="756" d="M 189 441 L 126 472 L 94.5 536 L 94.5 567 L 126 630 L 189 662 L 220 662 L 284 630 L 315 567 L 315 504 L 284 378 L 252 284 L 220 158 L 220 94.5 L 252 31.5 L 315 0 L 378 0 L 441 31.5 L 472 63 L 536 189 L 630 441 L 693 662 M 630 441 L 598 315 L 567 126 L 567 31.5 L 598 0 L 630 0 L 693 31.5 L 724 63 L 788 158" />
<glyph unicode="V" glyph-name="V" horiz-adv-x="724" d="M 189 441 L 126 472 L 94.5 536 L 94.5 567 L 126 630 L 189 662 L 220 662 L 284 630 L 315 567 L 315 504 L 284 378 L 252 284 L 220 158 L 220 63 L 252 0 L 315 0 L 378 31.5 L 472 126 L 536 220 L 598 346 L 630 441 L 662 567 L 662 630 L 630 662 L 598 662 L 567 630 L 536 567 L 536 504 L 567 410 L 630 346 L 693 315" />
<glyph unicode="W" glyph-name="W" horiz-adv-x="882" d="M 189 441 L 126 472 L 94.5 536 L 94.5 567 L 126 630 L 189 662 L 220 662 L 284 630 L 315 567 L 315 472 L 284 0 M 598 662 L 284 0 M 598 662 L 536 0 M 976 662 L 914 630 L 819 536 L 724 410 L 630 220 L 536 0" />
<glyph unicode="X" glyph-name="X" horiz-adv-x="756" d="M 284 472 L 220 472 L 189 504 L 189 567 L 220 630 L 284 662 L 346 662 L 410 630 L 441 567 L 441 472 L 378 189 L 378 94.5 L 410 31.5 L 472 0 L 536 0 L 598 31.5 L 630 94.5 L 630 158 L 598 189 L 536 189 M 756 567 L 756 630 L 724 662 L 662 662 L 598 630 L 536 567 L 472 472 L 346 189 L 284 94.5 L 220 31.5 L 158 0 L 94.5 0 L 63 31.5 L 63 94.5" />
<glyph unicode="Y" glyph-name="Y" horiz-adv-x="724" d="M 189 441 L 126 472 L 94.5 536 L 94.5 567 L 126 630 L 189 662 L 220 662 L 284 630 L 315 567 L 315 504 L 284 378 L 252 284 L 220 158 L 220 94.5 L 252 31.5 L 284 0 L 346 0 L 410 31.5 L 472 94.5 L 536 189 L 567 252 L 630 441 M 693 662 L 630 441 L 536 126 L 472 -63 L 410 -220 L 346 -346 L 284 -378 L 252 -346 L 252 -284 L 284 -189 L 346 -94.5 L 441 0 L 536 63 L 693 158" />
<glyph unicode="Z" glyph-name="Z" horiz-adv-x="662" d="M 441 472 L 410 410 L 378 378 L 315 346 L 252 346 L 220 410 L 220 472 L 252 567 L 315 630 L 410 662 L 504 662 L 567 630 L 598 567 L 598 441 L 567 346 L 504 220 L 410 126 L 284 31.5 L 220 0 L 126 0 L 94.5 31.5 L 94.5 94.5 L 126 126 L 220 126 L 284 94.5 L 315 63 L 346 0 L 346 -94.5 L 315 -189 L 284 -252 L 220 -346 L 158 -378 L 126 -346 L 126 -284 L 158 -189 L 220 -94.5 L 315 0 L 410 63 L 598 158" />
<glyph unicode="[" glyph-name="bracketleft" horiz-adv-x="441" d="M 158 788 L 158 -220 M 189 788 L 189 -220 M 158 788 L 378 788 M 158 -220 L 378 -220" />
<glyph unicode="\" glyph-name="backslash" horiz-adv-x="441" d="M 31.5 662 L 472 -94.5" />
<glyph unicode="]" glyph-name="bracketright" horiz-adv-x="441" d="M 315 788 L 315 -220 M 346 788 L 346 -220 M 126 788 L 346 788 M 126 -220 L 346 -220" />
<glyph unicode="^" glyph-name="asciicircum" horiz-adv-x="504" d="M 284 724 L 31.5 284 M 284 724 L 536 284" />
<glyph unicode="_" glyph-name="underscore" horiz-adv-x="504" d="M 31.5 -63 L 536 -63" />
<glyph unicode="`" glyph-name="grave" horiz-adv-x="346" d="M 315 662 L 252 598 L 220 536 L 220 504 L 252 472 L 284 504 L 252 536" />
<glyph unicode="a" glyph-name="a" horiz-adv-x="504" d="M 315 189 L 284 252 L 220 284 L 158 284 L 94.5 252 L 63 220 L 31.5 158 L 31.5 94.5 L 63 31.5 L 126 0 L 189 0 L 252 31.5 L 284 94.5 L 346 284 L 315 126 L 315 31.5 L 346 0 L 378 0 L 441 31.5 L 472 63 L 536 158" />
<glyph unicode="b" glyph-name="b" horiz-adv-x="441" d="M 31.5 158 L 94.5 252 L 189 410 L 220 472 L 252 567 L 252 630 L 220 662 L 158 630 L 126 567 L 94.5 441 L 63 220 L 63 31.5 L 94.5 0 L 126 0 L 189 31.5 L 252 94.5 L 284 189 L 284 284 L 315 158 L 346 126 L 410 126 L 472 158" />
<glyph unicode="c" glyph-name="c" horiz-adv-x="346" d="M 252 220 L 252 252 L 220 284 L 158 284 L 94.5 252 L 63 220 L 31.5 158 L 31.5 94.5 L 63 31.5 L 126 0 L 220 0 L 315 63 L 378 158" />
<glyph unicode="d" glyph-name="d" horiz-adv-x="504" d="M 315 189 L 284 252 L 220 284 L 158 284 L 94.5 252 L 63 220 L 31.5 158 L 31.5 94.5 L 63 31.5 L 126 0 L 189 0 L 252 31.5 L 284 94.5 L 472 662 M 346 284 L 315 126 L 315 31.5 L 346 0 L 378 0 L 441 31.5 L 472 63 L 536 158" />
<glyph unicode="e" glyph-name="e" horiz-adv-x="315" d="M 63 63 L 126 94.5 L 158 126 L 189 189 L 189 252 L 158 284 L 126 284 L 63 252 L 31.5 189 L 31.5 94.5 L 63 31.5 L 126 0 L 189 0 L 252 31.5 L 284 63 L 346 158" />
<glyph unicode="f" glyph-name="f" horiz-adv-x="252" d="M 31.5 158 L 158 315 L 220 410 L 252 472 L 284 567 L 284 630 L 252 662 L 189 630 L 158 567 L 94.5 315 L 0 31.5 L -94.5 -189 L -126 -284 L -126 -346 L -94.5 -378 L -31.5 -346 L 0 -252 L 31.5 31.5 L 63 0 L 126 0 L 189 31.5 L 220 63 L 284 158" />
<glyph unicode="g" glyph-name="g" horiz-adv-x="472" d="M 315 189 L 284 252 L 220 284 L 158 284 L 94.5 252 L 63 220 L 31.5 158 L 31.5 94.5 L 63 31.5 L 126 0 L 189 0 L 252 31.5 L 284 63 M 346 284 L 284 63 L 158 -284 L 126 -346 L 63 -378 L 31.5 -346 L 31.5 -284 L 63 -189 L 158 -94.5 L 252 -31.5 L 315 0 L 410 63 L 504 158" />
<glyph unicode="h" glyph-name="h" horiz-adv-x="472" d="M 31.5 158 L 94.5 252 L 189 410 L 220 472 L 252 567 L 252 630 L 220 662 L 158 630 L 126 567 L 94.5 441 L 63 252 L 31.5 0 M 31.5 0 L 63 94.5 L 94.5 158 L 158 252 L 220 284 L 284 284 L 315 252 L 315 189 L 284 94.5 L 284 31.5 L 315 0 L 346 0 L 410 31.5 L 441 63 L 504 158" />
<glyph unicode="i" glyph-name="i" horiz-adv-x="220" d="M 126 441 L 126 410 L 158 410 L 158 441 L 126 441 M 31.5 158 L 94.5 284 L 31.5 94.5 L 31.5 31.5 L 63 0 L 94.5 0 L 158 31.5 L 189 63 L 252 158" />
<glyph unicode="j" glyph-name="j" horiz-adv-x="220" d="M 126 441 L 126 410 L 158 410 L 158 441 L 126 441 M 31.5 158 L 94.5 284 L -94.5 -284 L -126 -346 L -189 -378 L -220 -346 L -220 -284 L -189 -189 L -94.5 -94.5 L 0 -31.5 L 63 0 L 158 63 L 252 158" />
<glyph unicode="k" glyph-name="k" horiz-adv-x="441" d="M 31.5 158 L 94.5 252 L 189 410 L 220 472 L 252 567 L 252 630 L 220 662 L 158 630 L 126 567 L 94.5 441 L 63 252 L 31.5 0 M 31.5 0 L 63 94.5 L 94.5 158 L 158 252 L 220 284 L 284 284 L 315 252 L 315 189 L 252 158 L 158 158 M 158 158 L 220 126 L 252 31.5 L 284 0 L 315 0 L 378 31.5 L 410 63 L 472 158" />
<glyph unicode="l" glyph-name="l" horiz-adv-x="252" d="M 31.5 158 L 94.5 252 L 189 410 L 220 472 L 252 567 L 252 630 L 220 662 L 158 630 L 126 567 L 94.5 441 L 63 220 L 63 31.5 L 94.5 0 L 126 0 L 189 31.5 L 220 63 L 284 158" />
<glyph unicode="m" glyph-name="m" horiz-adv-x="788" d="M 31.5 158 L 94.5 252 L 158 284 L 189 252 L 189 220 L 158 94.5 L 126 0 M 158 94.5 L 189 158 L 252 252 L 315 284 L 378 284 L 410 252 L 410 220 L 378 94.5 L 346 0 M 378 94.5 L 410 158 L 472 252 L 536 284 L 598 284 L 630 252 L 630 189 L 598 94.5 L 598 31.5 L 630 0 L 662 0 L 724 31.5 L 756 63 L 819 158" />
<glyph unicode="n" glyph-name="n" horiz-adv-x="567" d="M 31.5 158 L 94.5 252 L 158 284 L 189 252 L 189 220 L 158 94.5 L 126 0 M 158 94.5 L 189 158 L 252 252 L 315 284 L 378 284 L 410 252 L 410 189 L 378 94.5 L 378 31.5 L 410 0 L 441 0 L 504 31.5 L 536 63 L 598 158" />
<glyph unicode="o" glyph-name="o" horiz-adv-x="441" d="M 220 284 L 158 284 L 94.5 252 L 63 220 L 31.5 158 L 31.5 94.5 L 63 31.5 L 126 0 L 189 0 L 252 31.5 L 284 63 L 315 126 L 315 189 L 284 252 L 220 284 L 189 252 L 189 189 L 220 126 L 284 94.5 L 378 94.5 L 441 126 L 472 158" />
<glyph unicode="p" glyph-name="p" horiz-adv-x="472" d="M 31.5 158 L 94.5 252 L 126 315 L 94.5 189 L -94.5 -378 M 94.5 189 L 126 252 L 189 284 L 252 284 L 315 252 L 346 189 L 346 126 L 315 63 L 284 31.5 L 220 0 M 94.5 31.5 L 158 0 L 252 0 L 346 31.5 L 410 63 L 504 158" />
<glyph unicode="q" glyph-name="q" horiz-adv-x="472" d="M 315 189 L 284 252 L 220 284 L 158 284 L 94.5 252 L 63 220 L 31.5 158 L 31.5 94.5 L 63 31.5 L 126 0 L 189 0 L 252 31.5 M 346 284 L 315 189 L 252 31.5 L 158 -189 L 126 -284 L 126 -346 L 158 -378 L 220 -346 L 252 -252 L 252 -31.5 L 315 0 L 410 63 L 504 158" />
<glyph unicode="r" glyph-name="r" horiz-adv-x="410" d="M 31.5 158 L 94.5 252 L 126 315 L 126 252 L 220 252 L 252 220 L 252 158 L 220 63 L 220 31.5 L 252 0 L 284 0 L 346 31.5 L 378 63 L 441 158" />
<glyph unicode="s" glyph-name="s" horiz-adv-x="346" d="M 31.5 158 L 94.5 252 L 126 315 L 126 252 L 189 158 L 220 94.5 L 220 31.5 L 158 0 M 31.5 31.5 L 94.5 0 L 220 0 L 284 31.5 L 315 63 L 378 158" />
<glyph unicode="t" glyph-name="t" horiz-adv-x="284" d="M 31.5 158 L 94.5 252 L 158 378 M 252 662 L 63 94.5 L 63 31.5 L 94.5 0 L 158 0 L 220 31.5 L 252 63 L 315 158 M 63 410 L 284 410" />
<glyph unicode="u" glyph-name="u" horiz-adv-x="472" d="M 31.5 158 L 94.5 284 L 31.5 94.5 L 31.5 31.5 L 63 0 L 126 0 L 189 31.5 L 252 94.5 L 315 189 M 346 284 L 284 94.5 L 284 31.5 L 315 0 L 346 0 L 410 31.5 L 441 63 L 504 158" />
<glyph unicode="v" glyph-name="v" horiz-adv-x="472" d="M 31.5 158 L 94.5 284 L 63 126 L 63 31.5 L 94.5 0 L 126 0 L 220 31.5 L 284 94.5 L 315 189 L 315 284 M 315 284 L 346 158 L 378 126 L 441 126 L 504 158" />
<glyph unicode="w" glyph-name="w" horiz-adv-x="662" d="M 126 284 L 63 220 L 31.5 126 L 31.5 63 L 63 0 L 126 0 L 189 31.5 L 252 94.5 M 315 284 L 252 94.5 L 252 31.5 L 284 0 L 346 0 L 410 31.5 L 472 94.5 L 504 189 L 504 284 M 504 284 L 536 158 L 567 126 L 630 126 L 693 158" />
<glyph unicode="x" glyph-name="x" horiz-adv-x="504" d="M 31.5 158 L 94.5 252 L 158 284 L 220 284 L 252 252 L 252 31.5 L 284 0 L 378 0 L 472 63 L 536 158 M 441 252 L 410 284 L 346 284 L 315 252 L 189 31.5 L 158 0 L 94.5 0 L 63 31.5" />
<glyph unicode="y" glyph-name="y" horiz-adv-x="472" d="M 31.5 158 L 94.5 284 L 31.5 94.5 L 31.5 31.5 L 63 0 L 126 0 L 189 31.5 L 252 94.5 L 315 189 M 346 284 L 158 -284 L 126 -346 L 63 -378 L 31.5 -346 L 31.5 -284 L 63 -189 L 158 -94.5 L 252 -31.5 L 315 0 L 410 63 L 504 158" />
<glyph unicode="z" glyph-name="z" horiz-adv-x="441" d="M 31.5 158 L 94.5 252 L 158 284 L 220 284 L 284 220 L 284 158 L 252 94.5 L 189 31.5 L 94.5 0 L 158 -31.5 L 189 -94.5 L 189 -189 L 158 -284 L 126 -346 L 63 -378 L 31.5 -346 L 31.5 -284 L 63 -189 L 158 -94.5 L 252 -31.5 L 378 63 L 472 158" />
<glyph unicode="{" glyph-name="braceleft" horiz-adv-x="441" d="M 315 788 L 252 756 L 220 724 L 189 662 L 189 598 L 220 536 L 252 504 L 284 441 L 284 378 L 220 315 M 252 756 L 220 693 L 220 630 L 252 567 L 284 536 L 315 472 L 315 410 L 284 346 L 158 284 L 284 220 L 315 158 L 315 94.5 L 284 31.5 L 252 0 L 220 -63 L 220 -126 L 252 -189 M 220 252 L 284 189 L 284 126 L 252 63 L 220 31.5 L 189 -31.5 L 189 -94.5 L 220 -158 L 252 -189 L 315 -220" />
<glyph unicode="|" glyph-name="bar" horiz-adv-x="252" d="M 158 788 L 158 -220" />
<glyph unicode="}" glyph-name="braceright" horiz-adv-x="441" d="M 189 788 L 252 756 L 284 724 L 315 662 L 315 598 L 284 536 L 252 504 L 220 441 L 220 378 L 284 315 M 252 756 L 284 693 L 284 630 L 252 567 L 220 536 L 189 472 L 189 410 L 220 346 L 346 284 L 220 220 L 189 158 L 189 94.5 L 220 31.5 L 252 0 L 284 -63 L 284 -126 L 252 -189 M 284 252 L 220 189 L 220 126 L 252 63 L 284 31.5 L 315 -31.5 L 315 -94.5 L 284 -158 L 252 -189 L 189 -220" />
<glyph unicode="~" glyph-name="asciitilde" horiz-adv-x="756" d="M 126 189 L 126 252 L 158 346 L 220 378 L 284 378 L 346 346 L 472 252 L 536 220 L 598 220 L 662 252 L 693 315 M 126 252 L 158 315 L 220 346 L 284 346 L 346 315 L 472 220 L 536 189 L 598 189 L 662 220 L 693 315 L 693 378" />
<glyph unicode="&#xa0;" glyph-name="nbspace" horiz-adv-x="378" />
<glyph unicode="&#xa1;" glyph-name="exclamdown" horiz-adv-x="346" d="M 126 0 L 158 31.5 L 220 410 M 126 31.5 L 220 410 M 126 0 L 94.5 31.5 L 220 410 M 284 598 L 315 630 L 284 662 L 252 630 L 284 598" />
<glyph unicode="&#xa2;" glyph-name="cent" horiz-adv-x="151" d="M 252 220 L 252 252 L 220 284 L 158 284 L 94.5 252 L 63 220 L 31.5 158 L 31.5 94.5 L 63 31.5 L 126 0 L 220 0 L 315 63 L 378 158 M 227 472 L 227 -132" />
<glyph unicode="&#xa3;" glyph-name="sterling" horiz-adv-x="598" d="M 110 205 L 394 205 M 158 284 L 220 284 L 346 315 L 441 378 L 504 441 L 536 504 L 536 598 L 504 662 L 441 662 L 410 630 L 378 567 L 346 410 L 315 252 L 284 158 L 252 94.5 L 189 31.5 L 126 0 L 63 0 L 31.5 31.5 L 31.5 94.5 L 63 126 L 126 126 L 189 94.5 L 284 31.5 L 378 0 L 441 0 L 536 31.5 L 598 94.5" />
<glyph unicode="&#xa5;" glyph-name="yen" horiz-adv-x="567" d="M 126 662 L 378 346 L 378 0 M 630 662 L 378 346 M 236 158 L 520 158 M 236 252 L 520 252" />
<glyph unicode="&#xa6;" glyph-name="brokenbar" horiz-adv-x="75.6" d="M 217 236 L 217 -66.1 M 217 709 L 217 406" />
<glyph unicode="&#xa8;" glyph-name="dieresis" horiz-adv-x="567" d=" M 185 567 L 153 536 L 185 504 L 216 536 L 185 567 M 445 567 L 414 536 L 445 504 L 477 536 L 445 567" />
<glyph unicode="&#xa9;" glyph-name="copyright" horiz-adv-x="693" d="M 378 662 L 315 630 L 252 567 L 220 504 L 189 410 L 189 252 L 220 158 L 252 94.5 L 315 31.5 L 378 0 L 504 0 L 567 31.5 L 630 94.5 L 662 158 L 693 252 L 693 410 L 662 504 L 630 567 L 567 630 L 504 662 L 378 662 M 551 410 L 536 441 L 504 472 L 472 488 L 410 488 L 378 472 L 346 441 L 331 410 L 315 362 L 315 284 L 331 236 L 346 205 L 378 173 L 410 158 L 472 158 L 504 173 L 536 205 L 551 236" />
<glyph unicode="&#xaa;" glyph-name="ordfeminine" horiz-adv-x="252" d="M 284 724 L 268 756 L 236 772 L 205 772 L 173 756 L 158 740 L 142 709 L 142 677 L 158 646 L 189 630 L 220 630 L 252 646 L 268 677 L 299 772 L 284 693 L 284 646 L 299 630 L 315 630 L 346 646 L 362 662 L 394 709" />
<glyph unicode="&#xab;" glyph-name="guillemotleft" horiz-adv-x="454" d="M 265 454 L 63 227 L 265 0 M 391 454 L 189 227 L 391 0" />
<glyph unicode="&#xae;" glyph-name="registered" horiz-adv-x="693" d="M 378 662 L 315 630 L 252 567 L 220 504 L 189 410 L 189 252 L 220 158 L 252 94.5 L 315 31.5 L 378 0 L 504 0 L 567 31.5 L 630 94.5 L 662 158 L 693 252 L 693 410 L 662 504 L 630 567 L 567 630 L 504 662 L 378 662 M 331 488 L 331 158 M 331 488 L 472 488 L 520 472 L 536 457 L 551 425 L 551 394 L 536 362 L 520 346 L 472 331 L 331 331 M 441 331 L 551 158" />
<glyph unicode="&#xb0;" glyph-name="degree" horiz-adv-x="238" d="M 246 765 L 217 756 L 198 737 L 180 709 L 170 690 L 161 652 L 161 614 L 170 586 L 180 576 L 198 567 L 217 567 L 246 576 L 265 595 L 284 624 L 293 643 L 302 680 L 302 718 L 293 747 L 284 756 L 265 765 L 246 765 L 227 747 L 227 718 L 236 690 L 255 662 L 274 643 L 302 624 L 321 614" />
<glyph unicode="&#xb1;" glyph-name="plusminus" horiz-adv-x="655" d="M 580 37.8 L 1033.2 37.8 M 806 643 L 806 189 M 580 416 L 1033.2 416" />
<glyph unicode="&#xb2;" glyph-name="twosuperior" horiz-adv-x="331" d="M 252 898 L 268 882 L 252 866 L 236 882 L 236 898 L 252 929 L 268 945 L 315 961 L 362 961 L 410 945 L 425 914 L 425 882 L 410 850 L 378 819 L 331 788 L 268 756 L 220 724 L 189 693 L 158 630 M 362 961 L 394 945 L 410 914 L 410 882 L 394 850 L 362 819 L 268 756 M 173 662 L 189 677 L 220 677 L 299 646 L 346 646 L 378 662 L 394 693 M 220 677 L 299 630 L 346 630 L 378 646 L 394 693" />
<glyph unicode="&#xb3;" glyph-name="threesuperior" horiz-adv-x="331" d="M 252 898 L 268 882 L 252 866 L 236 882 L 236 898 L 252 929 L 268 945 L 315 961 L 362 961 L 410 945 L 425 914 L 425 882 L 410 850 L 362 819 L 315 803 M 362 961 L 394 945 L 410 914 L 410 882 L 394 850 L 362 819 M 284 803 L 315 803 L 362 788 L 378 772 L 394 740 L 394 693 L 378 662 L 362 646 L 315 630 L 252 630 L 205 646 L 189 662 L 173 693 L 173 709 L 189 724 L 205 709 L 189 693 M 315 803 L 346 788 L 362 772 L 378 740 L 378 693 L 362 662 L 346 646 L 315 630" />
<glyph unicode="&#xb4;" glyph-name="acute" horiz-adv-x="378" d=" M 322 664 L 196 493" />
<glyph unicode="&#xb7;" glyph-name="middot" horiz-adv-x="340" d="M 340 378 L 302 340 L 340 302 L 378 340 L 340 378" />
<glyph unicode="&#xb9;" glyph-name="onesuperior" horiz-adv-x="331" d="M 331 898 L 252 630 M 362 961 L 268 630 M 362 961 L 315 914 L 268 882 L 236 866 M 346 914 L 284 882 L 236 866" />
<glyph unicode="&#xba;" glyph-name="ordmasculine" horiz-adv-x="238" d="M 246 765 L 217 756 L 198 737 L 180 709 L 170 690 L 161 652 L 161 614 L 170 586 L 180 576 L 198 567 L 217 567 L 246 576 L 265 595 L 284 624 L 293 643 L 302 680 L 302 718 L 293 747 L 284 756 L 265 765 L 246 765 L 227 747 L 227 718 L 236 690 L 255 662 L 274 643 L 302 624 L 321 614" />
<glyph unicode="&#xbb;" glyph-name="guillemotright" horiz-adv-x="454" d="M 63 454 L 265 227 L 63 0 M 189 454 L 391 227 L 189 0" />
<glyph unicode="&#xbc;" glyph-name="onequarter" horiz-adv-x="714" d="M 246 699 L 151 378 M 284 775 L 170 378 M 284 775 L 227 718 L 170 680 L 132 662 M 265 718 L 189 680 L 132 662 M 819 788 L 0 -220 M 794 378 L 680 0 M 813 397 L 699 0 M 813 397 L 529 113 L 832 113" />
<glyph unicode="&#xbd;" glyph-name="onehalf" horiz-adv-x="714" d="M 246 699 L 151 378 M 284 775 L 170 378 M 284 775 L 227 718 L 170 680 L 132 662 M 265 718 L 189 680 L 132 662 M 819 788 L 0 -220 M 624 321 L 643 302 L 624 284 L 605 302 L 605 321 L 624 359 L 643 378 L 699 397 L 756 397 L 813 378 L 832 340 L 832 302 L 813 265 L 775 227 L 718 189 L 643 151 L 586 113 L 548 75.6 L 510 0 M 756 397 L 794 378 L 813 340 L 813 302 L 794 265 L 756 227 L 643 151 M 529 37.8 L 548 56.7 L 586 56.7 L 680 18.9 L 737 18.9 L 775 37.8 L 794 75.6 M 586 56.7 L 680 0 L 737 0 L 775 18.9 L 794 75.6" />
<glyph unicode="&#xbe;" glyph-name="threequarters" horiz-adv-x="714" d="M 151 699 L 170 680 L 151 662 L 132 680 L 132 699 L 151 737 L 170 756 L 227 775 L 284 775 L 340 756 L 359 718 L 359 680 L 340 643 L 284 605 L 227 586 M 284 775 L 321 756 L 340 718 L 340 680 L 321 643 L 284 605 M 189 586 L 227 586 L 284 567 L 302 548 L 321 510 L 321 454 L 302 416 L 284 397 L 227 378 L 151 378 L 94.5 397 L 75.6 416 L 56.7 454 L 56.7 472 L 75.6 491 L 94.5 472 L 75.6 454 M 227 586 L 265 567 L 284 548 L 302 510 L 302 454 L 284 416 L 265 397 L 227 378 M 819 788 L 0 -220 M 794 378 L 680 0 M 813 397 L 699 0 M 813 397 L 529 113 L 832 113" />
<glyph unicode="&#xbf;" glyph-name="questiondown" horiz-adv-x="662" d="M 472 126 L 441 158 L 472 189 L 504 158 L 504 126 L 472 63 L 441 31.5 L 346 0 L 220 0 L 126 31.5 L 94.5 94.5 L 94.5 158 L 126 220 L 158 252 L 346 315 L 410 346 L 410 410 L 378 441 L 315 441 M 220 0 L 158 31.5 L 126 94.5 L 126 158 L 158 220 L 189 252 L 252 284 M 441 598 L 472 630 L 441 662 L 410 630 L 441 598" />
<glyph unicode="&#xc0;" glyph-name="Agrave" horiz-adv-x="630" d="M 31.5 0 L 94.5 31.5 L 189 126 L 284 252 L 410 472 L 504 662 L 504 0 L 472 94.5 L 410 189 L 346 252 L 252 315 L 189 315 L 158 284 L 158 220 L 189 158 L 252 94.5 L 346 31.5 L 441 0 L 598 0 M 346 1023.8 L 438 901" />
<glyph unicode="&#xc1;" glyph-name="Aacute" horiz-adv-x="630" d="M 31.5 0 L 94.5 31.5 L 189 126 L 284 252 L 410 472 L 504 662 L 504 0 L 472 94.5 L 410 189 L 346 252 L 252 315 L 189 315 L 158 284 L 158 220 L 189 158 L 252 94.5 L 346 31.5 L 441 0 L 598 0 M 576 1005.0 L 450 835" />
<glyph unicode="&#xc2;" glyph-name="Acircumflex" horiz-adv-x="630" d="M 31.5 0 L 94.5 31.5 L 189 126 L 284 252 L 410 472 L 504 662 L 504 0 L 472 94.5 L 410 189 L 346 252 L 252 315 L 189 315 L 158 284 L 158 220 L 189 158 L 252 94.5 L 346 31.5 L 441 0 L 598 0 M 373 769 L 493 896 L 567 769" />
<glyph unicode="&#xc3;" glyph-name="Atilde" horiz-adv-x="630" d="M 31.5 0 L 94.5 31.5 L 189 126 L 284 252 L 410 472 L 504 662 L 504 0 L 472 94.5 L 410 189 L 346 252 L 252 315 L 189 315 L 158 284 L 158 220 L 189 158 L 252 94.5 L 346 31.5 L 441 0 L 598 0 M 341 803 L 341 835 L 357 882 L 388 898 L 420 898 L 452 882 L 515 835 L 546 819 L 578 819 L 609 835 L 625 866 M 341 835 L 357 866 L 388 882 L 420 882 L 452 866 L 515 819 L 546 803 L 578 803 L 609 819 L 625 866 L 625 898" />
<glyph unicode="&#xc4;" glyph-name="Adieresis" horiz-adv-x="630" d="M 31.5 0 L 94.5 31.5 L 189 126 L 284 252 L 410 472 L 504 662 L 504 0 L 472 94.5 L 410 189 L 346 252 L 252 315 L 189 315 L 158 284 L 158 220 L 189 158 L 252 94.5 L 346 31.5 L 441 0 L 598 0 M 379 914 L 348 882 L 379 850 L 411 882 L 379 914 M 629 914 L 597 882 L 629 850 L 660 882 L 629 914" />
<glyph unicode="&#xc5;" glyph-name="Aring" horiz-adv-x="567" d="M 378 662 L 126 0 M 378 662 L 630 0 M 220 220 L 536 220 M 359 1048.9 L 340 1039.5 L 321 1020.6 L 312 1001.7 L 302 973 L 302 926 L 312 898 L 321 879 L 340 860 L 359 850 L 397 850 L 416 860 L 435 879 L 444 898 L 454 926 L 454 973 L 444 1001.7 L 435 1020.6 L 416 1039.5 L 397 1048.9 L 359 1048.9" />
<glyph unicode="&#xc8;" glyph-name="Egrave" horiz-adv-x="630" d="M 472 536 L 472 504 L 504 472 L 567 472 L 598 504 L 598 567 L 567 630 L 472 662 L 346 662 L 252 630 L 220 567 L 220 472 L 252 410 L 284 378 L 378 346 L 284 346 L 189 315 L 158 284 L 126 220 L 126 126 L 158 63 L 189 31.5 L 284 0 L 378 0 L 472 31.5 L 536 94.5 L 567 158 M 346 1023.8 L 438 901" />
<glyph unicode="&#xc9;" glyph-name="Eacute" horiz-adv-x="630" d="M 472 536 L 472 504 L 504 472 L 567 472 L 598 504 L 598 567 L 567 630 L 472 662 L 346 662 L 252 630 L 220 567 L 220 472 L 252 410 L 284 378 L 378 346 L 284 346 L 189 315 L 158 284 L 126 220 L 126 126 L 158 63 L 189 31.5 L 284 0 L 378 0 L 472 31.5 L 536 94.5 L 567 158 M 571 1019.2 L 445 849" />
<glyph unicode="&#xca;" glyph-name="Ecircumflex" horiz-adv-x="630" d="M 472 536 L 472 504 L 504 472 L 567 472 L 598 504 L 598 567 L 567 630 L 472 662 L 346 662 L 252 630 L 220 567 L 220 472 L 252 410 L 284 378 L 378 346 L 284 346 L 189 315 L 158 284 L 126 220 L 126 126 L 158 63 L 189 31.5 L 284 0 L 378 0 L 472 31.5 L 536 94.5 L 567 158 M 310 769 L 430 896 L 504 769" />
<glyph unicode="&#xcb;" glyph-name="Edieresis" horiz-adv-x="630" d="M 472 536 L 472 504 L 504 472 L 567 472 L 598 504 L 598 567 L 567 630 L 472 662 L 346 662 L 252 630 L 220 567 L 220 472 L 252 410 L 284 378 L 378 346 L 284 346 L 189 315 L 158 284 L 126 220 L 126 126 L 158 63 L 189 31.5 L 284 0 L 378 0 L 472 31.5 L 536 94.5 L 567 158 M 382 914 L 351 882 L 382 850 L 414 882 L 382 914 M 626 914 L 594 882 L 626 850 L 657 882 L 626 914" />
<glyph unicode="&#xcc;" glyph-name="Igrave" horiz-adv-x="536" d="M 472 158 L 410 220 L 346 315 L 315 378 L 284 472 L 284 567 L 315 630 L 346 662 L 410 662 L 441 630 L 472 567 L 472 472 L 441 315 L 378 158 L 346 94.5 L 284 31.5 L 220 0 L 158 0 L 94.5 31.5 L 63 94.5 L 63 158 L 94.5 189 L 158 189 L 220 158 M 299 1023.8 L 391 901" />
<glyph unicode="&#xcd;" glyph-name="Iacute" horiz-adv-x="536" d="M 472 158 L 410 220 L 346 315 L 315 378 L 284 472 L 284 567 L 315 630 L 346 662 L 410 662 L 441 630 L 472 567 L 472 472 L 441 315 L 378 158 L 346 94.5 L 284 31.5 L 220 0 L 158 0 L 94.5 31.5 L 63 94.5 L 63 158 L 94.5 189 L 158 189 L 220 158 M 536 1001.8 L 410 832" />
<glyph unicode="&#xce;" glyph-name="Icircumflex" horiz-adv-x="536" d="M 472 158 L 410 220 L 346 315 L 315 378 L 284 472 L 284 567 L 315 630 L 346 662 L 410 662 L 441 630 L 472 567 L 472 472 L 441 315 L 378 158 L 346 94.5 L 284 31.5 L 220 0 L 158 0 L 94.5 31.5 L 63 94.5 L 63 158 L 94.5 189 L 158 189 L 220 158 M 287 769 L 406 896 L 480 769" />
<glyph unicode="&#xcf;" glyph-name="Idieresis" horiz-adv-x="536" d="M 472 158 L 410 220 L 346 315 L 315 378 L 284 472 L 284 567 L 315 630 L 346 662 L 410 662 L 441 630 L 472 567 L 472 472 L 441 315 L 378 158 L 346 94.5 L 284 31.5 L 220 0 L 158 0 L 94.5 31.5 L 63 94.5 L 63 158 L 94.5 189 L 158 189 L 220 158 M 325 914 L 294 882 L 325 850 L 357 882 L 325 914 M 588 914 L 557 882 L 588 850 L 620 882 L 588 914" />
<glyph unicode="&#xd0;" glyph-name="Eth" horiz-adv-x="724" d="M 110 236 L 394 236 M 441 662 L 378 630 L 346 567 L 315 441 L 284 252 L 252 158 L 220 94.5 L 158 31.5 L 94.5 0 L 31.5 0 L 0 31.5 L 0 94.5 L 31.5 126 L 94.5 126 L 158 94.5 L 220 31.5 L 315 0 L 410 0 L 504 31.5 L 567 94.5 L 630 220 L 662 378 L 662 504 L 630 598 L 598 630 L 536 662 L 441 662 L 378 598 L 378 536 L 410 441 L 472 346 L 536 284 L 630 220 L 693 189" />
<glyph unicode="&#xd1;" glyph-name="Ntilde" horiz-adv-x="756" d="M 189 441 L 126 472 L 94.5 536 L 94.5 567 L 126 630 L 189 662 L 220 662 L 284 630 L 315 567 L 315 504 L 284 346 L 252 220 L 189 0 M 252 220 L 346 472 L 410 598 L 441 630 L 504 662 L 567 662 L 630 630 L 662 567 L 662 504 L 630 346 L 567 126 L 567 31.5 L 598 0 L 630 0 L 693 31.5 L 724 63 L 788 158 M 299 803 L 299 835 L 315 882 L 346 898 L 378 898 L 410 882 L 472 835 L 504 819 L 536 819 L 567 835 L 583 866 M 299 835 L 315 866 L 346 882 L 378 882 L 410 866 L 472 819 L 504 803 L 536 803 L 567 819 L 583 866 L 583 898" />
<glyph unicode="&#xd2;" glyph-name="Ograve" horiz-adv-x="662" d="M 410 662 L 315 630 L 252 567 L 189 472 L 158 410 L 126 284 L 126 158 L 158 63 L 189 31.5 L 252 0 L 315 0 L 410 31.5 L 472 94.5 L 536 189 L 567 252 L 598 378 L 598 504 L 567 598 L 536 630 L 472 662 L 410 662 L 346 598 L 346 504 L 378 410 L 441 315 L 504 252 L 598 189 L 662 158 M 362 1023.8 L 454 901" />
<glyph unicode="&#xd3;" glyph-name="Oacute" horiz-adv-x="662" d="M 410 662 L 315 630 L 252 567 L 189 472 L 158 410 L 126 284 L 126 158 L 158 63 L 189 31.5 L 252 0 L 315 0 L 410 31.5 L 472 94.5 L 536 189 L 567 252 L 598 378 L 598 504 L 567 598 L 536 630 L 472 662 L 410 662 L 346 598 L 346 504 L 378 410 L 441 315 L 504 252 L 598 189 L 662 158 M 603 992 L 477 822" />
<glyph unicode="&#xd4;" glyph-name="Ocircumflex" horiz-adv-x="662" d="M 410 662 L 315 630 L 252 567 L 189 472 L 158 410 L 126 284 L 126 158 L 158 63 L 189 31.5 L 252 0 L 315 0 L 410 31.5 L 472 94.5 L 536 189 L 567 252 L 598 378 L 598 504 L 567 598 L 536 630 L 472 662 L 410 662 L 346 598 L 346 504 L 378 410 L 441 315 L 504 252 L 598 189 L 662 158 M 318 769 L 438 896 L 512 769" />
<glyph unicode="&#xd5;" glyph-name="Otilde" horiz-adv-x="662" d="M 410 662 L 315 630 L 252 567 L 189 472 L 158 410 L 126 284 L 126 158 L 158 63 L 189 31.5 L 252 0 L 315 0 L 410 31.5 L 472 94.5 L 536 189 L 567 252 L 598 378 L 598 504 L 567 598 L 536 630 L 472 662 L 410 662 L 346 598 L 346 504 L 378 410 L 441 315 L 504 252 L 598 189 L 662 158 M 284 803 L 284 835 L 299 882 L 331 898 L 362 898 L 394 882 L 457 835 L 488 819 L 520 819 L 551 835 L 567 866 M 284 835 L 299 866 L 331 882 L 362 882 L 394 866 L 457 819 L 488 803 L 520 803 L 551 819 L 567 866 L 567 898" />
<glyph unicode="&#xd6;" glyph-name="Odieresis" horiz-adv-x="662" d="M 410 662 L 315 630 L 252 567 L 189 472 L 158 410 L 126 284 L 126 158 L 158 63 L 189 31.5 L 252 0 L 315 0 L 410 31.5 L 472 94.5 L 536 189 L 567 252 L 598 378 L 598 504 L 567 598 L 536 630 L 472 662 L 410 662 L 346 598 L 346 504 L 378 410 L 441 315 L 504 252 L 598 189 L 662 158 M 398 914 L 366 882 L 398 850 L 429 882 L 398 914 M 642 914 L 610 882 L 642 850 L 673 882 L 642 914" />
<glyph unicode="&#xd7;" glyph-name="multiply" horiz-adv-x="536" d="M 189 441 L 536 0 M 536 441 L 189 0" />
<glyph unicode="&#xd8;" glyph-name="Oslash" horiz-adv-x="662" d="M 410 662 L 315 630 L 252 567 L 189 472 L 158 410 L 126 284 L 126 158 L 158 63 L 189 31.5 L 252 0 L 315 0 L 410 31.5 L 472 94.5 L 536 189 L 567 252 L 598 378 L 598 504 L 567 598 L 536 630 L 472 662 L 410 662 L 346 598 L 346 504 L 378 410 L 441 315 L 504 252 L 598 189 L 662 158 M 740 791 L 76.5 -25.5" />
<glyph unicode="&#xd9;" glyph-name="Ugrave" horiz-adv-x="756" d="M 189 441 L 126 472 L 94.5 536 L 94.5 567 L 126 630 L 189 662 L 220 662 L 284 630 L 315 567 L 315 504 L 284 378 L 252 284 L 220 158 L 220 94.5 L 252 31.5 L 315 0 L 378 0 L 441 31.5 L 472 63 L 536 189 L 630 441 L 693 662 M 630 441 L 598 315 L 567 126 L 567 31.5 L 598 0 L 630 0 L 693 31.5 L 724 63 L 788 158 M 410 1023.8 L 501 901" />
<glyph unicode="&#xda;" glyph-name="Uacute" horiz-adv-x="756" d="M 189 441 L 126 472 L 94.5 536 L 94.5 567 L 126 630 L 189 662 L 220 662 L 284 630 L 315 567 L 315 504 L 284 378 L 252 284 L 220 158 L 220 94.5 L 252 31.5 L 315 0 L 378 0 L 441 31.5 L 472 63 L 536 189 L 630 441 L 693 662 M 630 441 L 598 315 L 567 126 L 567 31.5 L 598 0 L 630 0 L 693 31.5 L 724 63 L 788 158 M 643 1003.2 L 517 833" />
<glyph unicode="&#xdb;" glyph-name="Ucircumflex" horiz-adv-x="756" d="M 189 441 L 126 472 L 94.5 536 L 94.5 567 L 126 630 L 189 662 L 220 662 L 284 630 L 315 567 L 315 504 L 284 378 L 252 284 L 220 158 L 220 94.5 L 252 31.5 L 315 0 L 378 0 L 441 31.5 L 472 63 L 536 189 L 630 441 L 693 662 M 630 441 L 598 315 L 567 126 L 567 31.5 L 598 0 L 630 0 L 693 31.5 L 724 63 L 788 158 M 342 769 L 461 896 L 536 769" />
<glyph unicode="&#xdc;" glyph-name="Udieresis" horiz-adv-x="756" d="M 189 441 L 126 472 L 94.5 536 L 94.5 567 L 126 630 L 189 662 L 220 662 L 284 630 L 315 567 L 315 504 L 284 378 L 252 284 L 220 158 L 220 94.5 L 252 31.5 L 315 0 L 378 0 L 441 31.5 L 472 63 L 536 189 L 630 441 L 693 662 M 630 441 L 598 315 L 567 126 L 567 31.5 L 598 0 L 630 0 L 693 31.5 L 724 63 L 788 158 M 434 914 L 403 882 L 434 850 L 466 882 L 434 914 M 700 914 L 668 882 L 700 850 L 731 882 L 700 914" />
<glyph unicode="&#xdd;" glyph-name="Yacute" horiz-adv-x="724" d="M 189 441 L 126 472 L 94.5 536 L 94.5 567 L 126 630 L 189 662 L 220 662 L 284 630 L 315 567 L 315 504 L 284 378 L 252 284 L 220 158 L 220 94.5 L 252 31.5 L 284 0 L 346 0 L 410 31.5 L 472 94.5 L 536 189 L 567 252 L 630 441 M 693 662 L 630 441 L 536 126 L 472 -63 L 410 -220 L 346 -346 L 284 -378 L 252 -346 L 252 -284 L 284 -189 L 346 -94.5 L 441 0 L 536 63 L 693 158 M 635 1034.4 L 509 864" />
<glyph unicode="&#xe0;" glyph-name="agrave" horiz-adv-x="504" d="M 315 189 L 284 252 L 220 284 L 158 284 L 94.5 252 L 63 220 L 31.5 158 L 31.5 94.5 L 63 31.5 L 126 0 L 189 0 L 252 31.5 L 284 94.5 L 346 284 L 315 126 L 315 31.5 L 346 0 L 378 0 L 441 31.5 L 472 63 L 536 158 M 158 614 L 249 491" />
<glyph unicode="&#xe1;" glyph-name="aacute" horiz-adv-x="504" d="M 315 189 L 284 252 L 220 284 L 158 284 L 94.5 252 L 63 220 L 31.5 158 L 31.5 94.5 L 63 31.5 L 126 0 L 189 0 L 252 31.5 L 284 94.5 L 346 284 L 315 126 L 315 31.5 L 346 0 L 378 0 L 441 31.5 L 472 63 L 536 158 M 380 649 L 254 479" />
<glyph unicode="&#xe2;" glyph-name="acircumflex" horiz-adv-x="504" d="M 315 189 L 284 252 L 220 284 L 158 284 L 94.5 252 L 63 220 L 31.5 158 L 31.5 94.5 L 63 31.5 L 126 0 L 189 0 L 252 31.5 L 284 94.5 L 346 284 L 315 126 L 315 31.5 L 346 0 L 378 0 L 441 31.5 L 472 63 L 536 158 M 106 422 L 225 550 L 299 422" />
<glyph unicode="&#xe3;" glyph-name="atilde" horiz-adv-x="504" d="M 315 189 L 284 252 L 220 284 L 158 284 L 94.5 252 L 63 220 L 31.5 158 L 31.5 94.5 L 63 31.5 L 126 0 L 189 0 L 252 31.5 L 284 94.5 L 346 284 L 315 126 L 315 31.5 L 346 0 L 378 0 L 441 31.5 L 472 63 L 536 158 M 84 425 L 84 457 L 99.8 504 L 131 520 L 163 520 L 194 504 L 257 457 L 289 441 L 320 441 L 352 457 L 368 488 M 84 457 L 99.8 488 L 131 504 L 163 504 L 194 488 L 257 441 L 289 425 L 320 425 L 352 441 L 368 488 L 368 520" />
<glyph unicode="&#xe4;" glyph-name="adieresis" horiz-adv-x="504" d="M 315 189 L 284 252 L 220 284 L 158 284 L 94.5 252 L 63 220 L 31.5 158 L 31.5 94.5 L 63 31.5 L 126 0 L 189 0 L 252 31.5 L 284 94.5 L 346 284 L 315 126 L 315 31.5 L 346 0 L 378 0 L 441 31.5 L 472 63 L 536 158 M 165 567 L 133 536 L 165 504 L 196 536 L 165 567 M 402 567 L 371 536 L 402 504 L 434 536 L 402 567" />
<glyph unicode="&#xe5;" glyph-name="aring" horiz-adv-x="598" d="M 567 441 L 567 0 M 567 346 L 504 410 L 441 441 L 346 441 L 284 410 L 220 346 L 189 252 L 189 189 L 220 94.5 L 284 31.5 L 346 0 L 441 0 L 504 31.5 L 567 94.5 M 397 860 L 378 850 L 359 832 L 350 813 L 340 784 L 340 737 L 350 709 L 359 690 L 378 671 L 397 662 L 435 662 L 454 671 L 472 690 L 482 709 L 491 737 L 491 784 L 482 813 L 472 832 L 454 850 L 435 860 L 397 860" />
<glyph unicode="&#xe8;" glyph-name="egrave" horiz-adv-x="315" d="M 63 63 L 126 94.5 L 158 126 L 189 189 L 189 252 L 158 284 L 126 284 L 63 252 L 31.5 189 L 31.5 94.5 L 63 31.5 L 126 0 L 189 0 L 252 31.5 L 284 63 L 346 158 M 63 614 L 154 491" />
<glyph unicode="&#xe9;" glyph-name="eacute" horiz-adv-x="315" d="M 63 63 L 126 94.5 L 158 126 L 189 189 L 189 252 L 158 284 L 126 284 L 63 252 L 31.5 189 L 31.5 94.5 L 63 31.5 L 126 0 L 189 0 L 252 31.5 L 284 63 L 346 158 M 291 655 L 165 485" />
<glyph unicode="&#xea;" glyph-name="ecircumflex" horiz-adv-x="315" d="M 63 63 L 126 94.5 L 158 126 L 189 189 L 189 252 L 158 284 L 126 284 L 63 252 L 31.5 189 L 31.5 94.5 L 63 31.5 L 126 0 L 189 0 L 252 31.5 L 284 63 L 346 158 M 58.3 422 L 178 550 L 252 422" />
<glyph unicode="&#xeb;" glyph-name="edieresis" horiz-adv-x="315" d="M 63 63 L 126 94.5 L 158 126 L 189 189 L 189 252 L 158 284 L 126 284 L 63 252 L 31.5 189 L 31.5 94.5 L 63 31.5 L 126 0 L 189 0 L 252 31.5 L 284 63 L 346 158 M 62.6 567 L 31.1 536 L 62.6 504 L 94.1 536 L 62.6 567 M 315 567 L 284 536 L 315 504 L 347 536 L 315 567" />
<glyph unicode="&#xec;" glyph-name="igrave" horiz-adv-x="220" d="M 31.5 158 L 94.5 284 L 31.5 94.5 L 31.5 31.5 L 63 0 L 94.5 0 L 158 31.5 L 189 63 L 252 158 M 15.8 614 L 107 491" />
<glyph unicode="&#xed;" glyph-name="iacute" horiz-adv-x="220" d="M 31.5 158 L 94.5 284 L 31.5 94.5 L 31.5 31.5 L 63 0 L 94.5 0 L 158 31.5 L 189 63 L 252 158 M 234 668 L 108 498" />
<glyph unicode="&#xee;" glyph-name="icircumflex" horiz-adv-x="220" d="M 31.5 158 L 94.5 284 L 31.5 94.5 L 31.5 31.5 L 63 0 L 94.5 0 L 158 31.5 L 189 63 L 252 158 M 34.6 422 L 154 550 L 228 422" />
<glyph unicode="&#xef;" glyph-name="idieresis" horiz-adv-x="220" d="M 31.5 158 L 94.5 284 L 31.5 94.5 L 31.5 31.5 L 63 0 L 94.5 0 L 158 31.5 L 189 63 L 252 158 M 10.7 567 L -20.8 536 L 10.7 504 L 42.2 536 L 10.7 567 M 273 567 L 241 536 L 273 504 L 304 536 L 273 567" />
<glyph unicode="&#xf1;" glyph-name="ntilde" horiz-adv-x="567" d="M 31.5 158 L 94.5 252 L 158 284 L 189 252 L 189 220 L 158 94.5 L 126 0 M 158 94.5 L 189 158 L 252 252 L 315 284 L 378 284 L 410 252 L 410 189 L 378 94.5 L 378 31.5 L 410 0 L 441 0 L 504 31.5 L 536 63 L 598 158 M 94.5 425 L 94.5 457 L 110 504 L 142 520 L 173 520 L 205 504 L 268 457 L 299 441 L 331 441 L 362 457 L 378 488 M 94.5 457 L 110 488 L 142 504 L 173 504 L 205 488 L 268 441 L 299 425 L 331 425 L 362 441 L 378 488 L 378 520" />
<glyph unicode="&#xf2;" glyph-name="ograve" horiz-adv-x="441" d="M 220 284 L 158 284 L 94.5 252 L 63 220 L 31.5 158 L 31.5 94.5 L 63 31.5 L 126 0 L 189 0 L 252 31.5 L 284 63 L 315 126 L 315 189 L 284 252 L 220 284 L 189 252 L 189 189 L 220 126 L 284 94.5 L 378 94.5 L 441 126 L 472 158 M 126 614 L 217 491" />
<glyph unicode="&#xf3;" glyph-name="oacute" horiz-adv-x="441" d="M 220 284 L 158 284 L 94.5 252 L 63 220 L 31.5 158 L 31.5 94.5 L 63 31.5 L 126 0 L 189 0 L 252 31.5 L 284 63 L 315 126 L 315 189 L 284 252 L 220 284 L 189 252 L 189 189 L 220 126 L 284 94.5 L 378 94.5 L 441 126 L 472 158 M 348 665 L 222 494" />
<glyph unicode="&#xf4;" glyph-name="ocircumflex" horiz-adv-x="441" d="M 220 284 L 158 284 L 94.5 252 L 63 220 L 31.5 158 L 31.5 94.5 L 63 31.5 L 126 0 L 189 0 L 252 31.5 L 284 63 L 315 126 L 315 189 L 284 252 L 220 284 L 189 252 L 189 189 L 220 126 L 284 94.5 L 378 94.5 L 441 126 L 472 158 M 89.8 422 L 209 550 L 284 422" />
<glyph unicode="&#xf5;" glyph-name="otilde" horiz-adv-x="441" d="M 220 284 L 158 284 L 94.5 252 L 63 220 L 31.5 158 L 31.5 94.5 L 63 31.5 L 126 0 L 189 0 L 252 31.5 L 284 63 L 315 126 L 315 189 L 284 252 L 220 284 L 189 252 L 189 189 L 220 126 L 284 94.5 L 378 94.5 L 441 126 L 472 158 M 73.5 425 L 73.5 457 L 89.2 504 L 121 520 L 152 520 L 184 504 L 247 457 L 278 441 L 310 441 L 341 457 L 357 488 M 73.5 457 L 89.2 488 L 121 504 L 152 504 L 184 488 L 247 441 L 278 425 L 310 425 L 341 441 L 357 488 L 357 520" />
<glyph unicode="&#xf6;" glyph-name="odieresis" horiz-adv-x="441" d="M 220 284 L 158 284 L 94.5 252 L 63 220 L 31.5 158 L 31.5 94.5 L 63 31.5 L 126 0 L 189 0 L 252 31.5 L 284 63 L 315 126 L 315 189 L 284 252 L 220 284 L 189 252 L 189 189 L 220 126 L 284 94.5 L 378 94.5 L 441 126 L 472 158 M 121 567 L 89.4 536 L 121 504 L 152 536 L 121 567 M 383 567 L 352 536 L 383 504 L 415 536 L 383 567" />
<glyph unicode="&#xf7;" glyph-name="divide" horiz-adv-x="614" d="M 346 551 L 315 520 L 346 488 L 378 520 L 346 551 M 346 268 L 315 236 L 346 205 L 378 236 L 346 268 M 118 378 L 543 378" />
<glyph unicode="&#xf8;" glyph-name="oslash" horiz-adv-x="441" d="M 220 284 L 158 284 L 94.5 252 L 63 220 L 31.5 158 L 31.5 94.5 L 63 31.5 L 126 0 L 189 0 L 252 31.5 L 284 63 L 315 126 L 315 189 L 284 252 L 220 284 L 189 252 L 189 189 L 220 126 L 284 94.5 L 378 94.5 L 441 126 L 472 158 M 355 386 L -46.3 -108" />
<glyph unicode="&#xf9;" glyph-name="ugrave" horiz-adv-x="472" d="M 31.5 158 L 94.5 284 L 31.5 94.5 L 31.5 31.5 L 63 0 L 126 0 L 189 31.5 L 252 94.5 L 315 189 M 346 284 L 284 94.5 L 284 31.5 L 315 0 L 346 0 L 410 31.5 L 441 63 L 504 158 M 142 614 L 233 491" />
<glyph unicode="&#xfa;" glyph-name="uacute" horiz-adv-x="472" d="M 31.5 158 L 94.5 284 L 31.5 94.5 L 31.5 31.5 L 63 0 L 126 0 L 189 31.5 L 252 94.5 L 315 189 M 346 284 L 284 94.5 L 284 31.5 L 315 0 L 346 0 L 410 31.5 L 441 63 L 504 158 M 363 685 L 237 515" />
<glyph unicode="&#xfb;" glyph-name="ucircumflex" horiz-adv-x="472" d="M 31.5 158 L 94.5 284 L 31.5 94.5 L 31.5 31.5 L 63 0 L 126 0 L 189 31.5 L 252 94.5 L 315 189 M 346 284 L 284 94.5 L 284 31.5 L 315 0 L 346 0 L 410 31.5 L 441 63 L 504 158 M 97.6 422 L 217 550 L 291 422" />
<glyph unicode="&#xfc;" glyph-name="udieresis" horiz-adv-x="472" d="M 31.5 158 L 94.5 284 L 31.5 94.5 L 31.5 31.5 L 63 0 L 126 0 L 189 31.5 L 252 94.5 L 315 189 M 346 284 L 284 94.5 L 284 31.5 L 315 0 L 346 0 L 410 31.5 L 441 63 L 504 158 M 143 567 L 112 536 L 143 504 L 175 536 L 143 567 M 392 567 L 361 536 L 392 504 L 424 536 L 392 567" />
<glyph unicode="&#xfd;" glyph-name="yacute" horiz-adv-x="472" d="M 31.5 158 L 94.5 284 L 31.5 94.5 L 31.5 31.5 L 63 0 L 126 0 L 189 31.5 L 252 94.5 L 315 189 M 346 284 L 158 -284 L 126 -346 L 63 -378 L 31.5 -346 L 31.5 -284 L 63 -189 L 158 -94.5 L 252 -31.5 L 315 0 L 410 63 L 504 158 M 384 654 L 258 483" />
<glyph unicode="&#xff;" glyph-name="ydieresis" horiz-adv-x="472" d="M 31.5 158 L 94.5 284 L 31.5 94.5 L 31.5 31.5 L 63 0 L 126 0 L 189 31.5 L 252 94.5 L 315 189 M 346 284 L 158 -284 L 126 -346 L 63 -378 L 31.5 -346 L 31.5 -284 L 63 -189 L 158 -94.5 L 252 -31.5 L 315 0 L 410 63 L 504 158 M 141 567 L 110 536 L 141 504 L 173 536 L 141 567 M 394 567 L 363 536 L 394 504 L 426 536 L 394 567" />
<glyph unicode="&#x2013;" glyph-name="endash" horiz-adv-x="614" d="M 118 284 L 543 284" />
<glyph unicode="&#x2014;" glyph-name="emdash" horiz-adv-x="819" d="M 158 284 L 724 284" />
<glyph unicode="&#x201c;" glyph-name="quotedblleft" horiz-adv-x="378" d="M 213 850 L 184 794 L 170 737 L 170 709 L 184 680 L 198 709 L 184 737 M 354 850 L 326 794 L 312 737 L 312 709 L 326 680 L 340 709 L 326 737" />
<glyph unicode="&#x201d;" glyph-name="quotedblright" horiz-adv-x="378" d="M 184 539 L 170 567 L 184 595 L 198 567 L 198 539 L 184 482 L 156 425 M 326 539 L 312 567 L 326 595 L 340 567 L 340 539 L 326 482 L 298 425" />
<glyph unicode="&#x2039;" glyph-name="guilsinglleft" horiz-adv-x="302" d="M 265 454 L 63 227 L 265 0" />
<glyph unicode="&#x203a;" glyph-name="guilsinglright" horiz-adv-x="302" d="M 63 454 L 265 227 L 63 0" />
<glyph unicode="&#x20ac;" glyph-name="Euro" horiz-adv-x="662" d="M 662 504 L 630 567 L 567 630 L 504 662 L 378 662 L 315 630 L 252 567 L 220 504 L 189 410 L 189 252 L 220 158 L 252 94.5 L 315 31.5 L 378 0 L 504 0 L 567 31.5 L 630 94.5 L 662 158 M 142 236 L 425 236 M 142 331 L 425 331" />
<glyph unicode="&#x0178;" glyph-name="Ydieresis" horiz-adv-x="724" d="M 189 441 L 126 472 L 94.5 536 L 94.5 567 L 126 630 L 189 662 L 220 662 L 284 630 L 315 567 L 315 504 L 284 378 L 252 284 L 220 158 L 220 94.5 L 252 31.5 L 284 0 L 346 0 L 410 31.5 L 472 94.5 L 536 189 L 567 252 L 630 441 M 693 662 L 630 441 L 536 126 L 472 -63 L 410 -220 L 346 -346 L 284 -378 L 252 -346 L 252 -284 L 284 -189 L 346 -94.5 L 441 0 L 536 63 L 693 158 M 418 914 L 387 882 L 418 850 L 450 882 L 418 914 M 684 914 L 653 882 L 684 850 L 716 882 L 684 914" />
<glyph unicode="&#xdf;" glyph-name="germandbls" horiz-adv-x="724" d="M234.1391 131.11625C234.1391 131.11625 315.4279 52.079278 346.0 31.500000000000014C376.5721 10.920722000000016 383.16209 6.246817700000015 410.0 1.4210854715202004e-14C436.83791 -6.246817699999986 477.32712 -6.296613999999986 504.0 1.4210854715202004e-14C530.67288 6.296614000000014 567.0 31.500000000000014 567.0 31.500000000000014L598.0 63.000000000000014L630.0 126.00000000000001L630.0 220.0L598.0 284.0L567.0 315.0L472.0 346.0L567.0 378.0L630.0 410.0L662.0 441.0L693.0 504.0L693.0 567.0L662.0 630.0L598.0 662.0L504.0 662.0C504.0 662.0 436.2729 640.98075 410.0 630.0C383.7271 619.01925 367.52975 613.51776 346.0 598.0C324.47025 582.48224 305.43145 562.01345 284.0 536.0C262.56855 509.98655 235.43574 467.64538 220.0 441.0C204.56426 414.35462 199.66657 404.10375 189.0 378.0C178.33343 351.89625 158.0 284.0 158.0 284.0C78.409961 -108.97582 170.96319 178.71224 -16.115307 -174.76038999999997C-16.115307 -174.76038999999997 77.655328 -41.915163999999976 110.23956 31.98774000000003C133.64778 85.07894300000004 158.67043999999999 199.18235000000004 158.67043999999999 199.18235000000004" />
<glyph unicode="&#xc7;" glyph-name="Ccedilla" horiz-adv-x="630" d="M247.79438 -108.00539C278.16246 -107.85567 303.42239 -106.34929000000001 310.8282 -79.28568100000001C319.51005 -42.632472000000014 284.48431 -49.859133000000014 285.56201 1.3564649999999858M410.0 472.0L410.0 441.0L441.0 410.0L504.0 410.0L567.0 441.0L598.0 504.0L598.0 567.0L567.0 630.0L504.0 662.0L410.0 662.0L315.0 630.0L252.0 567.0L189.0 472.0L158.0 410.0L126.0 284.0L126.0 158.0L158.0 63.0L189.0 31.5L252.0 -0.0L315.0 -0.0L410.0 31.5L472.0 94.5L504.0 158.0" />
<glyph unicode="&#xa3;" glyph-name="sterling" horiz-adv-x="598" d="M190.0 205.0L494.0 205.0M441.0 378.0C518.88478 436.3281 536.15277 519.18094 536.0 598.0C527.99764 651.03799 503.13567 677.83704 441.0 662.0C407.62831 633.86805 398.82168 639.12603 378.0 567.0C357.17832 494.87397 335.6165 357.07767 315.0 252.0C315.0 252.0 296.62521 188.48212 284.0 158.0C274.93002 136.10158 252.0 94.5 252.0 94.5C216.70484 50.690999 178.10225 21.318014 126.0 -0.0C78.257195 -5.5938051 63.121106 -0.12110619 31.5 31.5C31.5 31.5 22.346777 51.699954000000005 22.312933 62.637412999999995C22.279089 73.574873 31.5 94.5 31.5 94.5C59.190571 131.89229 74.368391 138.53282000000002 126.0 126.0C177.63161 113.46718 231.73664 61.23349 284.0 31.5C336.26336 1.7665099 384.68363 -0.0 441.0 -0.0C513.54367 12.67425 548.92717 44.63567 598.0 94.5" />
<glyph unicode="&#xe7;" glyph-name="ccedilla" horiz-adv-x="346" d="M175.05034 0.51079164C181.05523 -70.843573 197.19667 -89.813785 149.28931 -87.007879M252.0 220.0L252.0 252.0L220.0 284.0L158.0 284.0L94.5 252.0L63.0 220.0L31.5 158.0L31.5 94.5L63.0 31.5L126.0 -0.0L220.0 -0.0L315.0 63.0L378.0 158.0" />
<glyph unicode="&#xb8;" glyph-name="cedilla" horiz-adv-x="630" d="M221.46545 -91.998222C250.34817 -101.3807 274.80599 -107.87285 290.32635 -84.49759C310.06605 -52.416808 274.54107 -48.293348 291.62766 -0.0" />
</font>
</defs>
</svg>
assets/strokefonts/HersheyScriptMed.svg
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >

<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1">

<metadata>
Font name: Hershey Script medium

Originally prepared in 2011 and converted to SVG fonts
in 2019 by Windell H. Oskay, www.evilmadscientist.com

Contents adapted from emergent.unpythonic.net/software/hershey
 by way of "Hershey Fonts in SVG" by Marty McGuire
 http://www.thingiverse.com/thing:6168
 
-------------------------------------------------------------------
The Hershey Fonts are a set of vector fonts with a liberal license.

USE RESTRICTION:
    This distribution of the Hershey Fonts may be used by anyone for
    any purpose, commercial or otherwise, providing that:
        1. The following acknowledgements must be distributed with
            the font data:
            - The Hershey Fonts were originally created by Dr.
                A. V. Hershey while working at the U. S.
                National Bureau of Standards.
            - The format of the Font data in this distribution
                was originally created by
                    James Hurt
                    Cognition, Inc.
                    900 Technology Park Drive
                    Billerica, MA 01821
                    (mit-eddie!ci-dandelion!hurt)
        2. The font data in this distribution may be converted into
            any other format *EXCEPT* the format distributed by
            the U.S. NTIS where each point is described
            in eight bytes as "xxx yyy:", where xxx and yyy are
            the coordinate values as ASCII numbers.

</metadata>
<defs>
<font id="HersheyScriptMed" horiz-adv-x="378" >
<font-face
font-family="Hershey Script medium"
units-per-em="1000"
ascent="800"
descent="-200"
cap-height="500"
x-height="300"
/>
<missing-glyph horiz-adv-x="378" />
<glyph unicode=" " glyph-name="space" horiz-adv-x="378" />
<glyph unicode="!" glyph-name="exclam" horiz-adv-x="346" d="M 353 662 L 321 630 L 258 252 M 353 630 L 258 252 M 353 662 L 384 630 L 258 252 M 195 63 L 164 31.5 L 195 0 L 227 31.5 L 195 63" />
<glyph unicode="&#x22;" glyph-name="quotedbl" horiz-adv-x="567" d="M 252 662 L 189 441 M 284 662 L 189 441 M 536 662 L 472 441 M 567 662 L 472 441" />
<glyph unicode="#" glyph-name="numbersign" horiz-adv-x="662" d="M 378 662 L 158 -220 M 567 662 L 346 -220 M 158 315 L 598 315 M 126 126 L 567 126" />
<glyph unicode="$" glyph-name="dollar" horiz-adv-x="662" d="M 410 788 L 158 -126 M 567 788 L 315 -126 M 598 536 L 567 504 L 598 472 L 630 504 L 630 536 L 598 598 L 567 630 L 472 662 L 346 662 L 252 630 L 189 567 L 189 504 L 220 441 L 252 410 L 472 284 L 536 220 M 189 504 L 252 441 L 472 315 L 504 284 L 536 220 L 536 126 L 504 63 L 472 31.5 L 378 0 L 252 0 L 158 31.5 L 126 63 L 94.5 126 L 94.5 158 L 126 189 L 158 158 L 126 126" />
<glyph unicode="%" glyph-name="percent" horiz-adv-x="756" d="M 693 662 L 126 0 M 284 662 L 346 598 L 346 536 L 315 472 L 252 441 L 189 441 L 126 504 L 126 567 L 158 630 L 220 662 L 284 662 L 346 630 L 441 598 L 536 598 L 630 630 L 693 662 M 567 220 L 504 189 L 472 126 L 472 63 L 536 0 L 598 0 L 662 31.5 L 693 94.5 L 693 158 L 630 220 L 567 220" />
<glyph unicode="&amp;" glyph-name="ampersand" horiz-adv-x="819" d="M 756 410 L 724 378 L 756 346 L 788 378 L 788 410 L 756 441 L 724 441 L 662 410 L 598 346 L 441 94.5 L 378 31.5 L 315 0 L 220 0 L 126 31.5 L 94.5 94.5 L 94.5 158 L 126 220 L 158 252 L 220 284 L 378 346 L 441 378 L 504 441 L 536 504 L 536 567 L 504 630 L 441 662 L 378 630 L 346 567 L 346 472 L 378 284 L 410 189 L 472 94.5 L 536 31.5 L 598 0 L 662 0 L 693 63 L 693 94.5 M 220 0 L 158 31.5 L 126 94.5 L 126 158 L 158 220 L 189 252 L 378 346 M 346 472 L 378 315 L 410 220 L 472 126 L 536 63 L 598 31.5 L 662 31.5 L 693 63" />
<glyph unicode="&apos;" glyph-name="quotesingle" horiz-adv-x="346" d="M 284 598 L 252 630 L 284 662 L 315 630 L 315 598 L 284 536 L 220 472" />
<glyph unicode="(" glyph-name="parenleft" horiz-adv-x="472" d="M 504 788 L 378 693 L 284 598 L 220 504 L 158 378 L 126 220 L 126 94.5 L 158 -63 L 189 -158 L 220 -220 M 378 693 L 284 567 L 220 441 L 189 346 L 158 189 L 158 31.5 L 189 -126 L 220 -220" />
<glyph unicode=")" glyph-name="parenright" horiz-adv-x="472" d="M 315 788 L 346 724 L 378 630 L 410 472 L 410 346 L 378 189 L 315 63 L 252 -31.5 L 158 -126 L 31.5 -220 M 315 788 L 346 693 L 378 536 L 378 378 L 346 220 L 315 126 L 252 0 L 158 -126" />
<glyph unicode="*" glyph-name="asterisk" horiz-adv-x="536" d="M 346 662 L 346 284 M 189 567 L 504 378 M 504 567 L 189 378" />
<glyph unicode="+" glyph-name="plus" horiz-adv-x="819" d="M 441 567 L 441 0 M 158 284 L 724 284" />
<glyph unicode="," glyph-name="comma" horiz-adv-x="346" d="M 126 0 L 94.5 31.5 L 126 63 L 158 31.5 L 158 0 L 126 -63 L 63 -126" />
<glyph unicode="-" glyph-name="hyphen" horiz-adv-x="819" d="M 158 284 L 724 284" />
<glyph unicode="." glyph-name="period" horiz-adv-x="346" d="M 126 63 L 94.5 31.5 L 126 0 L 158 31.5 L 126 63" />
<glyph unicode="/" glyph-name="slash" horiz-adv-x="693" d="M 788 788 L -31.5 -220" />
<glyph unicode="0" glyph-name="zero" horiz-adv-x="662" d="M 410 662 L 315 630 L 252 567 L 189 472 L 158 378 L 126 252 L 126 158 L 158 63 L 189 31.5 L 252 0 L 315 0 L 410 31.5 L 472 94.5 L 536 189 L 567 284 L 598 410 L 598 504 L 567 598 L 536 630 L 472 662 L 410 662 M 410 662 L 346 630 L 284 567 L 220 472 L 189 378 L 158 252 L 158 158 L 189 63 L 252 0 M 315 0 L 378 31.5 L 441 94.5 L 504 189 L 536 284 L 567 410 L 567 504 L 536 598 L 472 662" />
<glyph unicode="1" glyph-name="one" horiz-adv-x="662" d="M 410 536 L 252 0 M 472 662 L 284 0 M 472 662 L 378 567 L 284 504 L 220 472 M 441 567 L 315 504 L 220 472" />
<glyph unicode="2" glyph-name="two" horiz-adv-x="662" d="M 252 536 L 284 504 L 252 472 L 220 504 L 220 536 L 252 598 L 284 630 L 378 662 L 472 662 L 567 630 L 598 567 L 598 504 L 567 441 L 504 378 L 410 315 L 284 252 L 189 189 L 126 126 L 63 0 M 472 662 L 536 630 L 567 567 L 567 504 L 536 441 L 472 378 L 284 252 M 94.5 63 L 126 94.5 L 189 94.5 L 346 31.5 L 441 31.5 L 504 63 L 536 126 M 189 94.5 L 346 0 L 441 0 L 504 31.5 L 536 126" />
<glyph unicode="3" glyph-name="three" horiz-adv-x="662" d="M 252 536 L 284 504 L 252 472 L 220 504 L 220 536 L 252 598 L 284 630 L 378 662 L 472 662 L 567 630 L 598 567 L 598 504 L 567 441 L 472 378 L 378 346 M 472 662 L 536 630 L 567 567 L 567 504 L 536 441 L 472 378 M 315 346 L 378 346 L 472 315 L 504 284 L 536 220 L 536 126 L 504 63 L 472 31.5 L 378 0 L 252 0 L 158 31.5 L 126 63 L 94.5 126 L 94.5 158 L 126 189 L 158 158 L 126 126 M 378 346 L 441 315 L 472 284 L 504 220 L 504 126 L 472 63 L 441 31.5 L 378 0" />
<glyph unicode="4" glyph-name="four" horiz-adv-x="662" d="M 536 630 L 346 0 M 567 662 L 378 0 M 567 662 L 94.5 189 L 598 189" />
<glyph unicode="5" glyph-name="five" horiz-adv-x="662" d="M 315 662 L 158 346 M 315 662 L 630 662 M 315 630 L 472 630 L 630 662 M 158 346 L 189 378 L 284 410 L 378 410 L 472 378 L 504 346 L 536 284 L 536 189 L 504 94.5 L 441 31.5 L 346 0 L 252 0 L 158 31.5 L 126 63 L 94.5 126 L 94.5 158 L 126 189 L 158 158 L 126 126 M 378 410 L 441 378 L 472 346 L 504 284 L 504 189 L 472 94.5 L 410 31.5 L 346 0" />
<glyph unicode="6" glyph-name="six" horiz-adv-x="662" d="M 567 567 L 536 536 L 567 504 L 598 536 L 598 567 L 567 630 L 504 662 L 410 662 L 315 630 L 252 567 L 189 472 L 158 378 L 126 252 L 126 126 L 158 63 L 189 31.5 L 252 0 L 346 0 L 441 31.5 L 504 94.5 L 536 158 L 536 252 L 504 315 L 472 346 L 410 378 L 315 378 L 252 346 L 189 284 L 158 220 M 410 662 L 346 630 L 284 567 L 220 472 L 189 378 L 158 252 L 158 94.5 L 189 31.5 M 346 0 L 410 31.5 L 472 94.5 L 504 158 L 504 284 L 472 346" />
<glyph unicode="7" glyph-name="seven" horiz-adv-x="662" d="M 220 662 L 158 472 M 630 662 L 598 567 L 536 472 L 378 284 L 315 189 L 284 126 L 252 0 M 536 472 L 346 284 L 284 189 L 252 126 L 220 0 M 189 567 L 284 662 L 346 662 L 504 567 M 220 598 L 284 630 L 346 630 L 504 567 L 567 567 L 598 598 L 630 662" />
<glyph unicode="8" glyph-name="eight" horiz-adv-x="662" d="M 378 662 L 284 630 L 252 598 L 220 536 L 220 441 L 252 378 L 315 346 L 410 346 L 536 378 L 567 410 L 598 472 L 598 567 L 567 630 L 472 662 L 378 662 M 378 662 L 315 630 L 284 598 L 252 536 L 252 441 L 284 378 L 315 346 M 410 346 L 504 378 L 536 410 L 567 472 L 567 567 L 536 630 L 472 662 M 315 346 L 189 315 L 126 252 L 94.5 189 L 94.5 94.5 L 126 31.5 L 220 0 L 346 0 L 472 31.5 L 504 63 L 536 126 L 536 220 L 504 284 L 472 315 L 410 346 M 315 346 L 220 315 L 158 252 L 126 189 L 126 94.5 L 158 31.5 L 220 0 M 346 0 L 441 31.5 L 472 63 L 504 126 L 504 252 L 472 315" />
<glyph unicode="9" glyph-name="nine" horiz-adv-x="662" d="M 567 441 L 536 378 L 472 315 L 410 284 L 315 284 L 252 315 L 220 346 L 189 410 L 189 504 L 220 567 L 284 630 L 378 662 L 472 662 L 536 630 L 567 598 L 598 536 L 598 410 L 567 284 L 536 189 L 472 94.5 L 410 31.5 L 315 0 L 220 0 L 158 31.5 L 126 94.5 L 126 126 L 158 158 L 189 126 L 158 94.5 M 252 315 L 220 378 L 220 504 L 252 567 L 315 630 L 378 662 M 536 630 L 567 567 L 567 410 L 536 284 L 504 189 L 441 94.5 L 378 31.5 L 315 0" />
<glyph unicode=":" glyph-name="colon" horiz-adv-x="346" d="M 220 441 L 189 410 L 220 378 L 252 410 L 220 441 M 126 63 L 94.5 31.5 L 126 0 L 158 31.5" />
<glyph unicode=";" glyph-name="semicolon" horiz-adv-x="346" d="M 220 441 L 189 410 L 220 378 L 252 410 L 220 441 M 126 0 L 94.5 31.5 L 126 63 L 158 31.5 L 158 0 L 126 -63 L 63 -126" />
<glyph unicode="&#x3c;" glyph-name="less" horiz-adv-x="756" d="M 662 567 L 158 284 L 662 0" />
<glyph unicode="=" glyph-name="equal" horiz-adv-x="819" d="M 158 378 L 724 378 M 158 189 L 724 189" />
<glyph unicode="&#x3e;" glyph-name="greater" horiz-adv-x="756" d="M 158 567 L 662 284 L 158 0" />
<glyph unicode="?" glyph-name="question" horiz-adv-x="662" d="M 252 536 L 284 504 L 252 472 L 220 504 L 220 536 L 252 598 L 284 630 L 378 662 L 504 662 L 598 630 L 630 567 L 630 504 L 598 441 L 567 410 L 378 346 L 315 315 L 315 252 L 346 220 L 410 220 M 504 662 L 567 630 L 598 567 L 598 504 L 567 441 L 536 410 L 472 378 M 284 63 L 252 31.5 L 284 0 L 315 31.5 L 284 63" />
<glyph unicode="@" glyph-name="at" horiz-adv-x="850" d="M 598 410 L 567 472 L 504 504 L 410 504 L 346 472 L 315 441 L 284 346 L 284 252 L 315 189 L 378 158 L 472 158 L 536 189 L 567 252 M 410 504 L 346 441 L 315 346 L 315 252 L 346 189 L 378 158 M 598 504 L 567 252 L 567 189 L 630 158 L 693 158 L 756 220 L 788 315 L 788 378 L 756 472 L 724 536 L 662 598 L 598 630 L 504 662 L 410 662 L 315 630 L 252 598 L 189 536 L 158 472 L 126 378 L 126 284 L 158 189 L 189 126 L 252 63 L 315 31.5 L 410 0 L 504 0 L 598 31.5 L 662 63 L 693 94.5 M 630 504 L 598 252 L 598 189 L 630 158" />
<glyph unicode="A" glyph-name="A" horiz-adv-x="724" d="M 630 662 L 567 598 L 504 504 L 410 346 L 346 252 L 252 126 L 158 31.5 L 94.5 0 L 31.5 0 L 0 31.5 L 0 94.5 L 31.5 126 L 63 94.5 L 31.5 63 M 630 662 L 598 536 L 536 220 L 504 0 M 630 662 L 536 0 M 504 0 L 504 63 L 472 158 L 441 220 L 378 284 L 315 315 L 252 315 L 220 284 L 220 220 L 252 126 L 346 31.5 L 441 0 L 567 0 L 630 31.5" />
<glyph unicode="B" glyph-name="B" horiz-adv-x="756" d="M 504 630 L 472 598 L 441 536 L 378 378 L 315 189 L 284 126 L 220 31.5 L 158 0 M 472 598 L 441 504 L 378 252 L 346 158 L 315 94.5 L 252 31.5 L 158 0 L 94.5 0 L 63 31.5 L 63 94.5 L 94.5 126 L 126 94.5 L 94.5 63 M 315 472 L 284 410 L 252 378 L 189 378 L 158 410 L 158 472 L 189 536 L 252 598 L 315 630 L 410 662 L 598 662 L 662 630 L 693 567 L 693 504 L 662 441 L 598 410 L 472 378 L 410 378 M 598 662 L 630 630 L 662 567 L 662 504 L 630 441 L 598 410 M 472 378 L 567 346 L 598 315 L 630 252 L 630 158 L 598 63 L 567 31.5 L 504 0 L 441 0 L 410 31.5 L 410 94.5 L 441 189 M 472 378 L 536 346 L 567 315 L 598 252 L 598 158 L 567 63 L 504 0" />
<glyph unicode="C" glyph-name="C" horiz-adv-x="662" d="M 126 598 L 94.5 536 L 94.5 472 L 126 410 L 220 378 L 315 378 L 441 410 L 504 441 L 567 504 L 598 567 L 598 630 L 567 662 L 504 662 L 410 630 L 315 536 L 252 441 L 189 315 L 158 189 L 158 94.5 L 189 31.5 L 284 0 L 346 0 L 441 31.5 L 504 94.5 L 536 158 L 536 220 L 504 284 L 441 284 L 378 252 L 346 189 M 504 662 L 441 630 L 346 536 L 284 441 L 220 315 L 189 189 L 189 94.5 L 220 31.5 L 284 0" />
<glyph unicode="D" glyph-name="D" horiz-adv-x="724" d="M 504 630 L 472 598 L 441 536 L 378 378 L 315 189 L 284 126 L 220 31.5 L 158 0 M 472 598 L 441 504 L 378 252 L 346 158 L 315 94.5 L 252 31.5 L 158 0 L 94.5 0 L 63 31.5 L 63 94.5 L 94.5 126 L 158 126 L 220 94.5 L 284 31.5 L 346 0 L 441 0 L 504 31.5 L 567 94.5 L 630 220 L 662 378 L 662 472 L 630 567 L 567 630 L 504 662 L 346 662 L 252 630 L 189 567 L 158 504 L 158 441 L 189 410 L 252 410 L 284 441 L 315 504" />
<glyph unicode="E" glyph-name="E" horiz-adv-x="598" d="M 472 567 L 441 536 L 441 472 L 472 441 L 536 441 L 567 504 L 567 567 L 536 630 L 472 662 L 378 662 L 315 630 L 284 598 L 252 536 L 252 472 L 284 410 L 346 378 M 378 662 L 315 598 L 284 536 L 284 441 L 346 378 M 346 378 L 284 378 L 189 346 L 126 284 L 94.5 220 L 94.5 126 L 126 63 L 158 31.5 L 220 0 L 315 0 L 410 31.5 L 472 94.5 L 504 158 L 504 220 L 472 284 L 410 284 L 346 252 L 315 189 M 284 378 L 220 346 L 158 284 L 126 220 L 126 94.5 L 158 31.5" />
<glyph unicode="F" glyph-name="F" horiz-adv-x="662" d="M 536 598 L 504 536 L 441 378 L 378 189 L 346 126 L 284 31.5 L 220 0 M 346 472 L 315 410 L 252 378 L 189 378 L 158 441 L 158 504 L 189 567 L 252 630 L 346 662 L 662 662 L 567 630 L 536 598 L 504 504 L 441 252 L 410 158 L 378 94.5 L 315 31.5 L 220 0 L 158 0 L 94.5 31.5 L 63 63 L 63 94.5 L 94.5 126 L 126 94.5 L 94.5 63 M 410 662 L 536 630 L 567 630 M 284 252 L 315 284 L 378 315 L 504 315 L 567 346 L 630 441 L 567 220" />
<glyph unicode="G" glyph-name="G" horiz-adv-x="693" d="M 126 567 L 94.5 504 L 94.5 441 L 126 378 L 189 346 L 284 346 L 378 378 L 441 410 L 536 504 L 567 598 L 567 630 L 536 662 L 504 662 L 441 630 L 378 567 L 346 504 L 315 410 L 315 315 L 346 252 L 410 220 L 472 220 L 536 252 L 598 315 L 630 378 M 536 662 L 472 630 L 410 567 L 378 504 L 346 410 L 346 284 L 410 220 M 630 378 L 598 252 L 536 126 L 472 63 L 410 31.5 L 284 0 L 189 0 L 126 31.5 L 94.5 94.5 L 94.5 126 L 126 158 L 158 126 L 126 94.5 M 598 252 L 536 158 L 472 94.5 L 378 31.5 L 284 0" />
<glyph unicode="H" glyph-name="H" horiz-adv-x="756" d="M 220 472 L 189 504 L 189 567 L 220 630 L 315 662 L 410 662 L 315 315 L 252 126 L 220 63 L 189 31.5 L 126 0 L 63 0 L 31.5 31.5 L 31.5 94.5 L 63 126 L 94.5 94.5 L 63 63 M 410 662 L 315 378 L 284 284 L 220 126 L 189 63 L 126 0 M 158 220 L 189 252 L 252 284 L 536 378 L 598 410 L 693 472 L 756 536 L 788 598 L 788 630 L 756 662 L 724 662 L 662 630 L 598 536 L 567 472 L 504 284 L 472 158 L 472 63 L 536 0 L 567 0 L 630 31.5 L 693 94.5 M 724 662 L 662 598 L 598 472 L 536 284 L 504 158 L 504 63 L 536 0" />
<glyph unicode="I" glyph-name="I" horiz-adv-x="504" d="M 472 598 L 410 504 L 346 346 L 284 189 L 252 126 L 189 31.5 L 126 0 M 536 472 L 472 410 L 378 378 L 284 378 L 220 410 L 189 472 L 189 536 L 220 598 L 284 630 L 410 662 L 536 662 L 472 598 L 441 536 L 378 346 L 315 158 L 284 94.5 L 220 31.5 L 126 0 L 63 0 L 31.5 31.5 L 31.5 94.5 L 63 126 L 94.5 94.5 L 63 63" />
<glyph unicode="J" glyph-name="J" horiz-adv-x="536" d="M 536 662 L 472 598 L 410 504 L 346 346 L 252 63 L 189 -63 M 536 441 L 472 378 L 378 346 L 284 346 L 220 378 L 189 441 L 189 504 L 220 567 L 284 630 L 410 662 L 536 662 L 472 567 L 441 504 L 346 220 L 284 94.5 L 252 31.5 L 189 -63 L 158 -94.5 L 94.5 -126 L 63 -94.5 L 63 -31.5 L 94.5 31.5 L 158 94.5 L 220 126 L 315 158 L 441 189" />
<glyph unicode="K" glyph-name="K" horiz-adv-x="756" d="M 220 472 L 189 504 L 189 567 L 252 630 L 346 662 L 410 662 L 315 315 L 252 126 L 220 63 L 189 31.5 L 126 0 L 63 0 L 31.5 31.5 L 31.5 94.5 L 63 126 L 94.5 94.5 L 63 63 M 410 662 L 315 378 L 284 284 L 220 126 L 189 63 L 126 0 M 662 630 L 567 504 L 504 441 L 441 410 L 346 378 M 756 630 L 724 598 L 756 567 L 788 598 L 788 630 L 756 662 L 724 662 L 662 630 L 567 472 L 536 441 L 472 410 L 346 378 M 346 378 L 441 346 L 472 284 L 504 63 L 536 0 M 346 378 L 410 346 L 441 284 L 472 63 L 536 0 L 567 0 L 630 31.5 L 693 94.5" />
<glyph unicode="L" glyph-name="L" horiz-adv-x="567" d="M 158 567 L 126 504 L 126 441 L 158 378 L 220 346 L 315 346 L 410 378 L 472 410 L 567 504 L 598 598 L 598 630 L 567 662 L 536 662 L 472 630 L 441 598 L 378 504 L 252 189 L 220 126 L 158 31.5 L 94.5 0 M 441 598 L 378 472 L 315 252 L 284 158 L 252 94.5 L 189 31.5 L 94.5 0 L 31.5 0 L 0 31.5 L 0 94.5 L 31.5 126 L 94.5 126 L 158 94.5 L 252 31.5 L 315 0 L 410 0 L 472 31.5 L 536 94.5" />
<glyph unicode="M" glyph-name="M" horiz-adv-x="882" d="M 472 662 L 346 378 L 252 189 L 189 94.5 L 126 31.5 L 63 0 L 0 0 L -31.5 31.5 L -31.5 94.5 L 0 126 L 31.5 94.5 L 0 63 M 472 662 L 410 441 L 378 315 L 346 158 L 346 31.5 L 410 0 M 472 662 L 441 536 L 410 378 L 378 158 L 378 31.5 L 410 0 M 756 662 L 630 378 L 472 94.5 L 410 0 M 756 662 L 693 441 L 662 315 L 630 158 L 630 31.5 L 693 0 L 724 0 L 788 31.5 L 850 94.5 M 756 662 L 724 536 L 693 378 L 662 158 L 662 31.5 L 693 0" />
<glyph unicode="N" glyph-name="N" horiz-adv-x="724" d="M 378 662 L 346 536 L 284 346 L 220 189 L 189 126 L 126 31.5 L 63 0 L 0 0 L -31.5 31.5 L -31.5 94.5 L 0 126 L 31.5 94.5 L 0 63 M 378 662 L 378 504 L 410 158 L 441 0 M 378 662 L 410 504 L 441 158 L 441 0 M 819 630 L 788 598 L 819 567 L 850 598 L 850 630 L 819 662 L 756 662 L 693 630 L 630 536 L 598 472 L 536 315 L 472 126 L 441 0" />
<glyph unicode="O" glyph-name="O" horiz-adv-x="662" d="M 378 662 L 315 630 L 252 567 L 189 472 L 158 410 L 126 284 L 126 158 L 158 63 L 189 31.5 L 252 0 L 315 0 L 410 31.5 L 472 94.5 L 536 189 L 567 252 L 598 378 L 598 504 L 567 598 L 536 630 L 504 630 L 441 598 L 378 536 L 315 410 L 284 252 L 284 158 M 315 630 L 252 536 L 189 410 L 158 284 L 158 158 L 189 63 L 252 0" />
<glyph unicode="P" glyph-name="P" horiz-adv-x="724" d="M 504 630 L 472 598 L 441 536 L 378 378 L 315 189 L 284 126 L 220 31.5 L 158 0 M 472 598 L 441 504 L 378 252 L 346 158 L 315 94.5 L 252 31.5 L 158 0 L 94.5 0 L 63 31.5 L 63 94.5 L 94.5 126 L 126 94.5 L 94.5 63 M 315 472 L 284 410 L 252 378 L 189 378 L 158 410 L 158 472 L 189 536 L 252 598 L 315 630 L 410 662 L 536 662 L 630 630 L 662 598 L 693 536 L 693 441 L 662 378 L 630 346 L 536 315 L 472 315 L 410 346 M 536 662 L 598 630 L 630 598 L 662 536 L 662 441 L 630 378 L 598 346 L 536 315" />
<glyph unicode="Q" glyph-name="Q" horiz-adv-x="662" d="M 441 536 L 441 472 L 410 410 L 378 378 L 315 346 L 252 346 L 220 410 L 220 472 L 252 567 L 315 630 L 410 662 L 504 662 L 567 630 L 598 567 L 598 441 L 567 346 L 504 252 L 378 126 L 284 63 L 220 31.5 L 126 0 L 63 0 L 31.5 31.5 L 31.5 94.5 L 63 126 L 126 126 L 189 94.5 L 284 31.5 L 378 0 L 472 0 L 536 31.5 L 598 94.5 M 504 662 L 536 630 L 567 567 L 567 441 L 536 346 L 472 252 L 378 158 L 252 63 L 126 0" />
<glyph unicode="R" glyph-name="R" horiz-adv-x="756" d="M 504 630 L 472 598 L 441 536 L 378 378 L 315 189 L 284 126 L 220 31.5 L 158 0 M 472 598 L 441 504 L 378 252 L 346 158 L 315 94.5 L 252 31.5 L 158 0 L 94.5 0 L 63 31.5 L 63 94.5 L 94.5 126 L 126 94.5 L 94.5 63 M 315 472 L 284 410 L 252 378 L 189 378 L 158 410 L 158 472 L 189 536 L 252 598 L 315 630 L 410 662 L 567 662 L 662 630 L 693 567 L 693 504 L 662 441 L 630 410 L 536 378 L 410 378 M 567 662 L 630 630 L 662 567 L 662 504 L 630 441 L 598 410 L 536 378 M 410 378 L 504 346 L 536 284 L 567 63 L 598 0 M 410 378 L 472 346 L 504 284 L 536 63 L 598 0 L 630 0 L 693 31.5 L 756 94.5" />
<glyph unicode="S" glyph-name="S" horiz-adv-x="630" d="M 220 567 L 189 504 L 189 441 L 220 378 L 284 346 L 378 346 L 472 378 L 536 410 L 630 504 L 662 598 L 662 630 L 630 662 L 598 662 L 536 630 L 504 598 L 472 536 L 441 441 L 378 220 L 346 126 L 284 31.5 L 220 0 M 472 536 L 441 410 L 410 189 L 378 94.5 L 315 31.5 L 220 0 L 126 0 L 63 31.5 L 31.5 94.5 L 31.5 126 L 63 158 L 94.5 126 L 63 94.5" />
<glyph unicode="T" glyph-name="T" horiz-adv-x="567" d="M 536 598 L 504 536 L 441 378 L 378 189 L 346 126 L 284 31.5 L 220 0 M 346 472 L 315 410 L 252 378 L 189 378 L 158 441 L 158 504 L 189 567 L 252 630 L 346 662 L 630 662 L 567 630 L 536 598 L 504 504 L 441 252 L 410 158 L 378 94.5 L 315 31.5 L 220 0 L 158 0 L 94.5 31.5 L 63 63 L 63 94.5 L 94.5 126 L 126 94.5 L 94.5 63 M 410 662 L 536 630 L 567 630" />
<glyph unicode="U" glyph-name="U" horiz-adv-x="693" d="M 63 536 L 126 630 L 189 662 L 220 662 L 284 598 L 284 504 L 252 410 L 158 158 L 158 63 L 189 0 M 220 662 L 252 598 L 252 504 L 158 252 L 126 158 L 126 63 L 189 0 L 252 0 L 315 31.5 L 410 126 L 472 220 L 504 284 M 630 662 L 504 284 L 472 158 L 472 63 L 536 0 L 567 0 L 630 31.5 L 693 94.5 M 662 662 L 536 284 L 504 158 L 504 63 L 536 0" />
<glyph unicode="V" glyph-name="V" horiz-adv-x="662" d="M 63 536 L 126 630 L 189 662 L 220 662 L 284 598 L 284 504 L 252 378 L 189 158 L 189 63 L 220 0 M 220 662 L 252 598 L 252 504 L 189 284 L 158 158 L 158 63 L 220 0 L 252 0 L 346 31.5 L 441 126 L 504 220 L 567 346 L 598 441 L 630 567 L 630 630 L 598 662 L 567 662 L 536 630 L 504 567 L 504 472 L 536 410 L 598 346 L 662 315 L 724 315" />
<glyph unicode="W" glyph-name="W" horiz-adv-x="724" d="M 126 472 L 94.5 472 L 63 504 L 63 567 L 94.5 630 L 158 662 L 284 662 L 252 598 L 220 472 L 189 189 L 158 0 M 220 472 L 220 189 L 189 0 M 536 662 L 472 598 L 410 472 L 315 189 L 252 63 L 189 0 M 536 662 L 504 598 L 472 472 L 441 189 L 410 0 M 472 472 L 472 189 L 441 0 M 850 662 L 788 630 L 724 567 L 662 472 L 567 189 L 504 63 L 441 0" />
<glyph unicode="X" glyph-name="X" horiz-adv-x="630" d="M 284 504 L 252 472 L 189 472 L 158 504 L 158 567 L 189 630 L 252 662 L 315 662 L 378 630 L 410 567 L 410 472 L 378 346 L 315 189 L 252 94.5 L 189 31.5 L 94.5 0 L 31.5 0 L 0 31.5 L 0 94.5 L 31.5 126 L 63 94.5 L 31.5 63 M 315 662 L 346 630 L 378 567 L 378 472 L 346 346 L 284 189 L 220 94.5 L 158 31.5 L 94.5 0 M 693 630 L 662 598 L 693 567 L 724 598 L 724 630 L 693 662 L 630 662 L 567 630 L 504 567 L 441 472 L 378 346 L 346 189 L 346 94.5 L 378 31.5 L 410 0 L 441 0 L 504 31.5 L 567 94.5" />
<glyph unicode="Y" glyph-name="Y" horiz-adv-x="693" d="M 126 536 L 189 630 L 252 662 L 284 662 L 346 630 L 346 567 L 284 378 L 284 284 L 315 220 M 284 662 L 315 630 L 315 567 L 252 378 L 252 284 L 315 220 L 378 220 L 472 252 L 536 315 L 598 410 L 630 472 M 693 662 L 630 472 L 536 220 L 472 94.5 M 724 662 L 662 472 L 598 315 L 536 189 L 472 94.5 L 410 31.5 L 315 0 L 189 0 L 126 31.5 L 94.5 94.5 L 94.5 126 L 126 158 L 158 126 L 126 94.5" />
<glyph unicode="Z" glyph-name="Z" horiz-adv-x="662" d="M 630 598 L 598 536 L 536 378 L 504 284 L 472 220 L 410 126 L 346 63 L 284 31.5 L 189 0 M 410 472 L 378 410 L 315 378 L 252 378 L 220 441 L 220 504 L 252 567 L 315 630 L 410 662 L 724 662 L 662 630 L 630 598 L 598 504 L 567 378 L 504 189 L 441 94.5 L 346 31.5 L 189 0 L 63 0 L 31.5 31.5 L 31.5 94.5 L 63 126 L 126 126 L 189 94.5 L 284 31.5 L 346 0 L 441 0 L 536 31.5 L 598 94.5 M 504 662 L 630 630 L 662 630" />
<glyph unicode="[" glyph-name="bracketleft" horiz-adv-x="441" d="M 158 788 L 158 -220 M 189 788 L 189 -220 M 158 788 L 378 788 M 158 -220 L 378 -220" />
<glyph unicode="\" glyph-name="backslash" horiz-adv-x="441" d="M 31.5 662 L 472 -94.5" />
<glyph unicode="]" glyph-name="bracketright" horiz-adv-x="441" d="M 315 788 L 315 -220 M 346 788 L 346 -220 M 126 788 L 346 788 M 126 -220 L 346 -220" />
<glyph unicode="^" glyph-name="asciicircum" horiz-adv-x="693" d="M 126 220 L 378 378 L 630 220 M 126 220 L 378 346 L 630 220" />
<glyph unicode="_" glyph-name="underscore" horiz-adv-x="504" d="M 31.5 -63 L 536 -63" />
<glyph unicode="`" glyph-name="grave" horiz-adv-x="346" d="M 315 662 L 252 598 L 220 536 L 220 504 L 252 472 L 284 504 L 252 536" />
<glyph unicode="a" glyph-name="a" horiz-adv-x="504" d="M 346 189 L 315 252 L 252 284 L 189 284 L 126 252 L 94.5 220 L 63 158 L 63 94.5 L 94.5 31.5 L 158 0 L 220 0 L 284 31.5 L 315 94.5 M 189 284 L 126 220 L 94.5 158 L 94.5 63 L 158 0 M 378 284 L 315 94.5 L 315 31.5 L 378 0 L 441 31.5 L 472 63 L 536 158 M 410 284 L 346 94.5 L 346 31.5 L 378 0" />
<glyph unicode="b" glyph-name="b" horiz-adv-x="441" d="M 31.5 158 L 94.5 252 L 158 378 M 252 662 L 63 94.5 L 63 31.5 L 126 0 L 158 0 L 220 31.5 L 284 94.5 L 315 189 L 315 284 L 346 158 L 378 126 L 410 126 L 472 158 M 284 662 L 94.5 94.5 L 94.5 31.5 L 126 0" />
<glyph unicode="c" glyph-name="c" horiz-adv-x="378" d="M 284 252 L 252 220 L 284 220 L 284 252 L 252 284 L 189 284 L 126 252 L 94.5 220 L 63 158 L 63 94.5 L 94.5 31.5 L 158 0 L 252 0 L 346 63 L 410 158 M 189 284 L 126 220 L 94.5 158 L 94.5 63 L 158 0" />
<glyph unicode="d" glyph-name="d" horiz-adv-x="504" d="M 346 189 L 315 252 L 252 284 L 189 284 L 126 252 L 94.5 220 L 63 158 L 63 94.5 L 94.5 31.5 L 158 0 L 220 0 L 284 31.5 L 315 94.5 M 189 284 L 126 220 L 94.5 158 L 94.5 63 L 158 0 M 504 662 L 315 94.5 L 315 31.5 L 378 0 L 441 31.5 L 472 63 L 536 158 M 536 662 L 346 94.5 L 346 31.5 L 378 0" />
<glyph unicode="e" glyph-name="e" horiz-adv-x="378" d="M 126 63 L 189 94.5 L 220 126 L 252 189 L 252 252 L 220 284 L 189 284 L 126 252 L 94.5 220 L 63 158 L 63 94.5 L 94.5 31.5 L 158 0 L 252 0 L 346 63 L 410 158 M 189 284 L 126 220 L 94.5 158 L 94.5 63 L 158 0" />
<glyph unicode="f" glyph-name="f" horiz-adv-x="284" d="M 126 284 L 220 378 L 284 472 L 315 567 L 315 630 L 284 662 L 220 630 L 189 567 L -94.5 -284 L -94.5 -346 L -63 -378 L 0 -346 L 31.5 -252 L 63 31.5 L 94.5 0 L 158 0 L 220 31.5 L 252 63 L 315 158 M 189 567 L 158 410 L 126 284 L 31.5 0 L -31.5 -158 L -94.5 -284" />
<glyph unicode="g" glyph-name="g" horiz-adv-x="504" d="M 346 189 L 315 252 L 252 284 L 189 284 L 126 252 L 94.5 220 L 63 158 L 63 94.5 L 94.5 31.5 L 158 0 L 220 0 L 284 31.5 L 315 94.5 M 189 284 L 126 220 L 94.5 158 L 94.5 63 L 158 0 M 378 284 L 189 -284 M 410 284 L 315 0 L 252 -158 L 189 -284 L 158 -346 L 94.5 -378 L 63 -346 L 63 -284 L 94.5 -189 L 158 -126 L 252 -63 L 378 0 L 472 63 L 536 158" />
<glyph unicode="h" glyph-name="h" horiz-adv-x="472" d="M 31.5 158 L 94.5 252 L 158 378 M 252 662 L 31.5 0 M 284 662 L 63 0 M 126 189 L 189 252 L 252 284 L 284 284 L 346 252 L 346 189 L 315 94.5 L 315 31.5 L 346 0 M 284 284 L 315 252 L 315 189 L 284 94.5 L 284 31.5 L 346 0 L 410 31.5 L 441 63 L 504 158" />
<glyph unicode="i" glyph-name="i" horiz-adv-x="252" d="M 189 472 L 158 441 L 189 410 L 220 441 L 189 472 M 126 284 L 63 94.5 L 63 31.5 L 126 0 L 189 31.5 L 220 63 L 284 158 M 158 284 L 94.5 94.5 L 94.5 31.5 L 126 0" />
<glyph unicode="j" glyph-name="j" horiz-adv-x="252" d="M 189 472 L 158 441 L 189 410 L 220 441 L 189 472 M 126 284 L -63 -284 M 158 284 L 63 0 L 0 -158 L -63 -284 L -94.5 -346 L -158 -378 L -189 -346 L -189 -284 L -158 -189 L -94.5 -126 L 0 -63 L 126 0 L 220 63 L 284 158" />
<glyph unicode="k" glyph-name="k" horiz-adv-x="441" d="M 31.5 158 L 94.5 252 L 158 378 M 252 662 L 31.5 0 M 284 662 L 63 0 M 315 284 L 315 252 L 346 252 L 315 284 L 284 284 L 220 220 L 126 189 M 126 189 L 220 158 L 252 31.5 L 284 0 M 126 189 L 189 158 L 220 31.5 L 284 0 L 315 0 L 410 63 L 472 158" />
<glyph unicode="l" glyph-name="l" horiz-adv-x="252" d="M 31.5 158 L 94.5 252 L 158 378 M 252 662 L 63 94.5 L 63 31.5 L 126 0 L 189 31.5 L 220 63 L 284 158 M 284 662 L 94.5 94.5 L 94.5 31.5 L 126 0" />
<glyph unicode="m" glyph-name="m" horiz-adv-x="788" d="M 31.5 158 L 94.5 252 L 158 284 L 220 252 L 220 189 L 158 0 M 158 284 L 189 252 L 189 189 L 126 0 M 220 189 L 284 252 L 346 284 L 378 284 L 441 252 L 441 189 L 378 0 M 378 284 L 410 252 L 410 189 L 346 0 M 441 189 L 504 252 L 567 284 L 598 284 L 662 252 L 662 189 L 630 94.5 L 630 31.5 L 662 0 M 598 284 L 630 252 L 630 189 L 598 94.5 L 598 31.5 L 662 0 L 724 31.5 L 756 63 L 819 158" />
<glyph unicode="n" glyph-name="n" horiz-adv-x="567" d="M 31.5 158 L 94.5 252 L 158 284 L 220 252 L 220 189 L 158 0 M 158 284 L 189 252 L 189 189 L 126 0 M 220 189 L 284 252 L 346 284 L 378 284 L 441 252 L 441 189 L 410 94.5 L 410 31.5 L 441 0 M 378 284 L 410 252 L 410 189 L 378 94.5 L 378 31.5 L 441 0 L 504 31.5 L 536 63 L 598 158" />
<glyph unicode="o" glyph-name="o" horiz-adv-x="441" d="M 252 284 L 189 284 L 126 252 L 94.5 220 L 63 158 L 63 94.5 L 94.5 31.5 L 158 0 L 220 0 L 284 31.5 L 315 63 L 346 126 L 346 189 L 315 252 L 252 284 L 220 252 L 220 189 L 252 126 L 315 94.5 L 378 94.5 L 441 126 L 472 158 M 189 284 L 126 220 L 94.5 158 L 94.5 63 L 158 0" />
<glyph unicode="p" glyph-name="p" horiz-adv-x="472" d="M 31.5 158 L 94.5 252 L 158 378 M 189 472 L -94.5 -378 M 220 472 L -63 -378 M 126 189 L 189 252 L 252 284 L 284 284 L 346 252 L 346 189 L 315 94.5 L 315 31.5 L 346 0 M 284 284 L 315 252 L 315 189 L 284 94.5 L 284 31.5 L 346 0 L 410 31.5 L 441 63 L 504 158" />
<glyph unicode="q" glyph-name="q" horiz-adv-x="504" d="M 346 189 L 315 252 L 252 284 L 189 284 L 126 252 L 94.5 220 L 63 158 L 63 94.5 L 94.5 31.5 L 158 0 L 220 0 L 284 31.5 M 189 284 L 126 220 L 94.5 158 L 94.5 63 L 158 0 M 378 284 L 189 -284 L 189 -346 L 220 -378 L 284 -346 L 315 -252 L 315 0 L 378 0 L 472 63 L 536 158 M 410 284 L 315 0 L 252 -158 L 189 -284" />
<glyph unicode="r" glyph-name="r" horiz-adv-x="441" d="M 31.5 158 L 94.5 252 L 158 284 L 220 252 L 220 189 L 158 0 M 158 284 L 189 252 L 189 189 L 126 0 M 220 189 L 284 252 L 346 284 L 378 284 L 346 189 M 346 284 L 346 189 L 378 126 L 410 126 L 472 158" />
<glyph unicode="s" glyph-name="s" horiz-adv-x="378" d="M 31.5 158 L 94.5 252 L 126 315 L 126 252 L 220 189 L 252 126 L 252 63 L 220 31.5 L 158 0 M 126 252 L 189 189 L 220 126 L 220 63 L 158 0 M 31.5 31.5 L 94.5 0 L 252 0 L 346 63 L 410 158" />
<glyph unicode="t" glyph-name="t" horiz-adv-x="252" d="M 31.5 158 L 94.5 252 L 158 378 M 252 662 L 63 94.5 L 63 31.5 L 126 0 L 189 31.5 L 220 63 L 284 158 M 284 662 L 94.5 94.5 L 94.5 31.5 L 126 0 M 94.5 410 L 284 410" />
<glyph unicode="u" glyph-name="u" horiz-adv-x="504" d="M 126 284 L 63 94.5 L 63 31.5 L 126 0 L 158 0 L 220 31.5 L 284 94.5 L 346 189 M 158 284 L 94.5 94.5 L 94.5 31.5 L 126 0 M 378 284 L 315 94.5 L 315 31.5 L 378 0 L 441 31.5 L 472 63 L 536 158 M 410 284 L 346 94.5 L 346 31.5 L 378 0" />
<glyph unicode="v" glyph-name="v" horiz-adv-x="472" d="M 126 284 L 94.5 220 L 63 126 L 63 31.5 L 126 0 L 158 0 L 252 31.5 L 315 94.5 L 346 189 L 346 284 M 158 284 L 126 220 L 94.5 126 L 94.5 31.5 L 126 0 M 346 284 L 378 158 L 410 126 L 441 126 L 504 158" />
<glyph unicode="w" glyph-name="w" horiz-adv-x="662" d="M 158 284 L 94.5 220 L 63 126 L 63 31.5 L 126 0 L 158 0 L 220 31.5 L 284 94.5 M 189 284 L 126 220 L 94.5 126 L 94.5 31.5 L 126 0 M 346 284 L 284 94.5 L 284 31.5 L 346 0 L 378 0 L 441 31.5 L 504 94.5 L 536 189 L 536 284 M 378 284 L 315 94.5 L 315 31.5 L 346 0 M 536 284 L 567 158 L 598 126 L 630 126 L 693 158" />
<glyph unicode="x" glyph-name="x" horiz-adv-x="504" d="M 31.5 158 L 94.5 252 L 158 284 L 220 284 L 252 252 L 252 189 L 220 94.5 L 189 31.5 L 126 0 L 94.5 0 L 63 31.5 L 63 63 L 94.5 63 L 63 31.5 M 441 252 L 410 220 L 441 220 L 441 252 L 410 284 L 378 284 L 315 252 L 284 189 L 252 94.5 L 252 31.5 L 284 0 L 378 0 L 472 63 L 536 158 M 252 252 L 284 189 M 315 252 L 252 189 M 220 94.5 L 252 31.5 M 252 94.5 L 189 31.5" />
<glyph unicode="y" glyph-name="y" horiz-adv-x="504" d="M 126 284 L 63 94.5 L 63 31.5 L 126 0 L 158 0 L 220 31.5 L 284 94.5 L 346 189 M 158 284 L 94.5 94.5 L 94.5 31.5 L 126 0 M 378 284 L 189 -284 M 410 284 L 315 0 L 252 -158 L 189 -284 L 158 -346 L 94.5 -378 L 63 -346 L 63 -284 L 94.5 -189 L 158 -126 L 252 -63 L 378 0 L 472 63 L 536 158" />
<glyph unicode="z" glyph-name="z" horiz-adv-x="410" d="M 31.5 158 L 94.5 252 L 158 284 L 220 284 L 284 252 L 284 158 L 252 94.5 L 158 31.5 L 94.5 0 M 220 284 L 252 252 L 252 158 L 220 94.5 L 158 31.5 M 94.5 0 L 158 -31.5 L 189 -94.5 L 189 -189 L 158 -284 L 94.5 -346 L 31.5 -378 L 0 -346 L 0 -284 L 31.5 -189 L 126 -94.5 L 220 -31.5 L 346 63 L 441 158 M 94.5 0 L 126 -31.5 L 158 -94.5 L 158 -189 L 126 -284 L 94.5 -346" />
<glyph unicode="{" glyph-name="braceleft" horiz-adv-x="441" d="M 315 788 L 252 756 L 220 724 L 189 662 L 189 598 L 220 536 L 252 504 L 284 441 L 284 378 L 220 315 M 252 756 L 220 693 L 220 630 L 252 567 L 284 536 L 315 472 L 315 410 L 284 346 L 158 284 L 284 220 L 315 158 L 315 94.5 L 284 31.5 L 252 0 L 220 -63 L 220 -126 L 252 -189 M 220 252 L 284 189 L 284 126 L 252 63 L 220 31.5 L 189 -31.5 L 189 -94.5 L 220 -158 L 252 -189 L 315 -220" />
<glyph unicode="|" glyph-name="bar" horiz-adv-x="252" d="M 158 788 L 158 -220" />
<glyph unicode="}" glyph-name="braceright" horiz-adv-x="441" d="M 189 788 L 252 756 L 284 724 L 315 662 L 315 598 L 284 536 L 252 504 L 220 441 L 220 378 L 284 315 M 252 756 L 284 693 L 284 630 L 252 567 L 220 536 L 189 472 L 189 410 L 220 346 L 346 284 L 220 220 L 189 158 L 189 94.5 L 220 31.5 L 252 0 L 284 -63 L 284 -126 L 252 -189 M 284 252 L 220 189 L 220 126 L 252 63 L 284 31.5 L 315 -31.5 L 315 -94.5 L 284 -158 L 252 -189 L 189 -220" />
<glyph unicode="~" glyph-name="asciitilde" horiz-adv-x="756" d="M 126 189 L 126 252 L 158 346 L 220 378 L 284 378 L 346 346 L 472 252 L 536 220 L 598 220 L 662 252 L 693 315 M 126 252 L 158 315 L 220 346 L 284 346 L 346 315 L 472 220 L 536 189 L 598 189 L 662 220 L 693 315 L 693 378" />
<glyph unicode="&#xa0;" glyph-name="nbspace" horiz-adv-x="378" />
<glyph unicode="&#xa1;" glyph-name="exclamdown" horiz-adv-x="346" d="M 126 0 L 158 31.5 L 220 410 M 126 31.5 L 220 410 M 126 0 L 94.5 31.5 L 220 410 M 284 598 L 315 630 L 284 662 L 252 630 L 284 598" />
<glyph unicode="&#xa2;" glyph-name="cent" horiz-adv-x="151" d="M 284 252 L 252 220 L 284 220 L 284 252 L 252 284 L 189 284 L 126 252 L 94.5 220 L 63 158 L 63 94.5 L 94.5 31.5 L 158 0 L 252 0 L 346 63 L 410 158 M 189 284 L 126 220 L 94.5 158 L 94.5 63 L 158 0 M 227 472 L 227 -132" />
<glyph unicode="&#xa3;" glyph-name="sterling" horiz-adv-x="567" d="M 158 236 L 441 236 M 158 567 L 126 504 L 126 441 L 158 378 L 220 346 L 315 346 L 410 378 L 472 410 L 567 504 L 598 598 L 598 630 L 567 662 L 536 662 L 472 630 L 441 598 L 378 504 L 252 189 L 220 126 L 158 31.5 L 94.5 0 M 441 598 L 378 472 L 315 252 L 284 158 L 252 94.5 L 189 31.5 L 94.5 0 L 31.5 0 L 0 31.5 L 0 94.5 L 31.5 126 L 94.5 126 L 158 94.5 L 252 31.5 L 315 0 L 410 0 L 472 31.5 L 536 94.5" />
<glyph unicode="&#xa5;" glyph-name="yen" horiz-adv-x="598" d="M 158 662 L 378 346 L 378 0 L 410 0 M 158 662 L 189 662 L 410 346 M 630 662 L 598 662 L 378 346 M 630 662 L 410 346 L 410 0 M 227 189 L 567 189 M 227 265 L 567 265" />
<glyph unicode="&#xa6;" glyph-name="brokenbar" horiz-adv-x="75.6" d="M 217 236 L 217 -66.1 M 217 709 L 217 406" />
<glyph unicode="&#xa8;" glyph-name="dieresis" horiz-adv-x="567" d=" M 218 536 L 186 504 L 218 472 L 249 504 L 218 536 M 475 536 L 444 504 L 475 472 L 507 504 L 475 536" />
<glyph unicode="&#xa9;" glyph-name="copyright" horiz-adv-x="693" d="M 378 662 L 315 630 L 252 567 L 220 504 L 189 410 L 189 252 L 220 158 L 252 94.5 L 315 31.5 L 378 0 L 504 0 L 567 31.5 L 630 94.5 L 662 158 L 693 252 L 693 410 L 662 504 L 630 567 L 567 630 L 504 662 L 378 662 M 410 630 L 315 598 L 252 504 L 220 410 L 220 252 L 252 158 L 315 63 L 410 31.5 L 472 31.5 L 567 63 L 630 158 L 662 252 L 662 410 L 630 504 L 567 598 L 472 630 L 410 630 M 551 410 L 536 441 L 504 472 L 472 488 L 410 488 L 378 472 L 346 441 L 331 410 L 315 362 L 315 284 L 331 236 L 346 205 L 378 173 L 410 158 L 472 158 L 504 173 L 536 205 L 551 236 M 551 410 L 536 410 L 520 441 L 504 457 L 472 472 L 410 472 L 378 457 L 346 410 L 331 362 L 331 284 L 346 236 L 378 189 L 410 173 L 472 173 L 504 189 L 520 205 L 536 236 L 551 236" />
<glyph unicode="&#xaa;" glyph-name="ordfeminine" horiz-adv-x="252" d="M 299 724 L 284 756 L 252 772 L 220 772 L 189 756 L 173 740 L 158 709 L 158 677 L 173 646 L 205 630 L 236 630 L 268 646 L 284 677 M 220 772 L 189 740 L 173 709 L 173 662 L 205 630 M 315 772 L 284 677 L 284 646 L 315 630 L 346 646 L 362 662 L 394 709 M 331 772 L 299 677 L 299 646 L 315 630" />
<glyph unicode="&#xab;" glyph-name="guillemotleft" horiz-adv-x="454" d="M 265 454 L 63 227 L 265 0 M 391 454 L 189 227 L 391 0" />
<glyph unicode="&#xae;" glyph-name="registered" horiz-adv-x="693" d="M 378 662 L 315 630 L 252 567 L 220 504 L 189 410 L 189 252 L 220 158 L 252 94.5 L 315 31.5 L 378 0 L 504 0 L 567 31.5 L 630 94.5 L 662 158 L 693 252 L 693 410 L 662 504 L 630 567 L 567 630 L 504 662 L 378 662 M 410 630 L 315 598 L 252 504 L 220 410 L 220 252 L 252 158 L 315 63 L 410 31.5 L 472 31.5 L 567 63 L 630 158 L 662 252 L 662 410 L 630 504 L 567 598 L 472 630 L 410 630 M 331 488 L 331 158 M 346 472 L 346 158 L 331 158 M 331 488 L 457 488 L 504 472 L 520 457 L 536 425 L 536 378 L 520 346 L 504 331 L 457 315 L 346 315 M 346 472 L 457 472 L 504 457 L 520 425 L 520 378 L 504 346 L 457 331 L 346 331 M 425 315 L 520 158 L 536 158 M 441 315 L 536 158" />
<glyph unicode="&#xb0;" glyph-name="degree" horiz-adv-x="238" d="M 236 765 L 217 756 L 198 737 L 180 709 L 170 690 L 161 652 L 161 614 L 170 586 L 180 576 L 198 567 L 217 567 L 246 576 L 265 595 L 284 624 L 293 643 L 302 680 L 302 718 L 293 747 L 284 756 L 274 756 L 255 747 L 236 728 L 217 690 L 208 643 L 208 614 M 217 756 L 198 728 L 180 690 L 170 652 L 170 614 L 180 586 L 198 567" />
<glyph unicode="&#xb1;" glyph-name="plusminus" horiz-adv-x="655" d="M 580 37.8 L 1033.2 37.8 M 806 643 L 806 189 M 580 416 L 1033.2 416" />
<glyph unicode="&#xb2;" glyph-name="twosuperior" horiz-adv-x="331" d="M 252 898 L 268 882 L 252 866 L 236 882 L 236 898 L 252 929 L 268 945 L 315 961 L 362 961 L 410 945 L 425 914 L 425 882 L 410 850 L 378 819 L 331 788 L 268 756 L 220 724 L 189 693 L 158 630 M 362 961 L 394 945 L 410 914 L 410 882 L 394 850 L 362 819 L 268 756 M 173 662 L 189 677 L 220 677 L 299 646 L 346 646 L 378 662 L 394 693 M 220 677 L 299 630 L 346 630 L 378 646 L 394 693" />
<glyph unicode="&#xb3;" glyph-name="threesuperior" horiz-adv-x="331" d="M 252 898 L 268 882 L 252 866 L 236 882 L 236 898 L 252 929 L 268 945 L 315 961 L 362 961 L 410 945 L 425 914 L 425 882 L 410 850 L 362 819 L 315 803 M 362 961 L 394 945 L 410 914 L 410 882 L 394 850 L 362 819 M 284 803 L 315 803 L 362 788 L 378 772 L 394 740 L 394 693 L 378 662 L 362 646 L 315 630 L 252 630 L 205 646 L 189 662 L 173 693 L 173 709 L 189 724 L 205 709 L 189 693 M 315 803 L 346 788 L 362 772 L 378 740 L 378 693 L 362 662 L 346 646 L 315 630" />
<glyph unicode="&#xb4;" glyph-name="acute" horiz-adv-x="378" d=" M 378 693 L 220 504 M 378 693 L 410 662 L 220 504" />
<glyph unicode="&#xb7;" glyph-name="middot" horiz-adv-x="374" d="M 265 378 L 227 340 L 265 302 L 302 340 L 265 378" />
<glyph unicode="&#xb9;" glyph-name="onesuperior" horiz-adv-x="331" d="M 331 898 L 252 630 M 362 961 L 268 630 M 362 961 L 315 914 L 268 882 L 236 866 M 346 914 L 284 882 L 236 866" />
<glyph unicode="&#xba;" glyph-name="ordmasculine" horiz-adv-x="238" d="M 236 765 L 217 756 L 198 737 L 180 709 L 170 690 L 161 652 L 161 614 L 170 586 L 180 576 L 198 567 L 217 567 L 246 576 L 265 595 L 284 624 L 293 643 L 302 680 L 302 718 L 293 747 L 284 756 L 274 756 L 255 747 L 236 728 L 217 690 L 208 643 L 208 614 M 217 756 L 198 728 L 180 690 L 170 652 L 170 614 L 180 586 L 198 567" />
<glyph unicode="&#xbb;" glyph-name="guillemotright" horiz-adv-x="454" d="M 63 454 L 265 227 L 63 0 M 189 454 L 391 227 L 189 0" />
<glyph unicode="&#xbc;" glyph-name="onequarter" horiz-adv-x="714" d="M 246 699 L 151 378 M 284 775 L 170 378 M 284 775 L 227 718 L 170 680 L 132 662 M 265 718 L 189 680 L 132 662 M 819 788 L 0 -220 M 813 378 L 699 0 M 832 397 L 718 0 M 832 397 L 548 113 L 850 113" />
<glyph unicode="&#xbd;" glyph-name="onehalf" horiz-adv-x="714" d="M 246 699 L 151 378 M 284 775 L 170 378 M 284 775 L 227 718 L 170 680 L 132 662 M 265 718 L 189 680 L 132 662 M 819 788 L 0 -220 M 643 321 L 662 302 L 643 284 L 624 302 L 624 321 L 643 359 L 662 378 L 718 397 L 775 397 L 832 378 L 850 340 L 850 302 L 832 265 L 794 227 L 737 189 L 662 151 L 605 113 L 567 75.6 L 529 0 M 775 397 L 813 378 L 832 340 L 832 302 L 813 265 L 775 227 L 662 151 M 548 37.8 L 567 56.7 L 605 56.7 L 699 18.9 L 756 18.9 L 794 37.8 L 813 75.6 M 605 56.7 L 699 0 L 756 0 L 794 18.9 L 813 75.6" />
<glyph unicode="&#xbe;" glyph-name="threequarters" horiz-adv-x="714" d="M 151 699 L 170 680 L 151 662 L 132 680 L 132 699 L 151 737 L 170 756 L 227 775 L 284 775 L 340 756 L 359 718 L 359 680 L 340 643 L 284 605 L 227 586 M 284 775 L 321 756 L 340 718 L 340 680 L 321 643 L 284 605 M 189 586 L 227 586 L 284 567 L 302 548 L 321 510 L 321 454 L 302 416 L 284 397 L 227 378 L 151 378 L 94.5 397 L 75.6 416 L 56.7 454 L 56.7 472 L 75.6 491 L 94.5 472 L 75.6 454 M 227 586 L 265 567 L 284 548 L 302 510 L 302 454 L 284 416 L 265 397 L 227 378 M 819 788 L 0 -220 M 813 378 L 699 0 M 832 397 L 718 0 M 832 397 L 548 113 L 850 113" />
<glyph unicode="&#xbf;" glyph-name="questiondown" horiz-adv-x="662" d="M 472 126 L 441 158 L 472 189 L 504 158 L 504 126 L 472 63 L 441 31.5 L 346 0 L 220 0 L 126 31.5 L 94.5 94.5 L 94.5 158 L 126 220 L 158 252 L 346 315 L 410 346 L 410 410 L 378 441 L 315 441 M 220 0 L 158 31.5 L 126 94.5 L 126 158 L 158 220 L 189 252 L 252 284 M 441 598 L 472 630 L 441 662 L 410 630 L 441 598" />
<glyph unicode="&#xc0;" glyph-name="Agrave" horiz-adv-x="724" d="M 630 662 L 567 598 L 504 504 L 410 346 L 346 252 L 252 126 L 158 31.5 L 94.5 0 L 31.5 0 L 0 31.5 L 0 94.5 L 31.5 126 L 63 94.5 L 31.5 63 M 630 662 L 598 536 L 536 220 L 504 0 M 630 662 L 536 0 M 504 0 L 504 63 L 472 158 L 441 220 L 378 284 L 315 315 L 252 315 L 220 284 L 220 220 L 252 126 L 346 31.5 L 441 0 L 567 0 L 630 31.5 M 394 1039.5 L 551 850 M 394 1039.5 L 362 1008.0 L 551 850" />
<glyph unicode="&#xc1;" glyph-name="Aacute" horiz-adv-x="724" d="M 630 662 L 567 598 L 504 504 L 410 346 L 346 252 L 252 126 L 158 31.5 L 94.5 0 L 31.5 0 L 0 31.5 L 0 94.5 L 31.5 126 L 63 94.5 L 31.5 63 M 630 662 L 598 536 L 536 220 L 504 0 M 630 662 L 536 0 M 504 0 L 504 63 L 472 158 L 441 220 L 378 284 L 315 315 L 252 315 L 220 284 L 220 220 L 252 126 L 346 31.5 L 441 0 L 567 0 L 630 31.5 M 740 1071.0 L 583 882 M 740 1071.0 L 772 1039.5 L 583 882" />
<glyph unicode="&#xc2;" glyph-name="Acircumflex" horiz-adv-x="724" d="M 630 662 L 567 598 L 504 504 L 410 346 L 346 252 L 252 126 L 158 31.5 L 94.5 0 L 31.5 0 L 0 31.5 L 0 94.5 L 31.5 126 L 63 94.5 L 31.5 63 M 630 662 L 598 536 L 536 220 L 504 0 M 630 662 L 536 0 M 504 0 L 504 63 L 472 158 L 441 220 L 378 284 L 315 315 L 252 315 L 220 284 L 220 220 L 252 126 L 346 31.5 L 441 0 L 567 0 L 630 31.5 M 387 756 L 539 850 L 690 756 M 387 756 L 539 832 L 690 756" />
<glyph unicode="&#xc3;" glyph-name="Atilde" horiz-adv-x="724" d="M 630 662 L 567 598 L 504 504 L 410 346 L 346 252 L 252 126 L 158 31.5 L 94.5 0 L 31.5 0 L 0 31.5 L 0 94.5 L 31.5 126 L 63 94.5 L 31.5 63 M 630 662 L 598 536 L 536 220 L 504 0 M 630 662 L 536 0 M 504 0 L 504 63 L 472 158 L 441 220 L 378 284 L 315 315 L 252 315 L 220 284 L 220 220 L 252 126 L 346 31.5 L 441 0 L 567 0 L 630 31.5 M 420 803 L 420 835 L 436 882 L 467 898 L 499 898 L 530 882 L 593 835 L 625 819 L 656 819 L 688 835 L 704 866 M 420 835 L 436 866 L 467 882 L 499 882 L 530 866 L 593 819 L 625 803 L 656 803 L 688 819 L 704 866 L 704 898" />
<glyph unicode="&#xc4;" glyph-name="Adieresis" horiz-adv-x="724" d="M 630 662 L 567 598 L 504 504 L 410 346 L 346 252 L 252 126 L 158 31.5 L 94.5 0 L 31.5 0 L 0 31.5 L 0 94.5 L 31.5 126 L 63 94.5 L 31.5 63 M 630 662 L 598 536 L 536 220 L 504 0 M 630 662 L 536 0 M 504 0 L 504 63 L 472 158 L 441 220 L 378 284 L 315 315 L 252 315 L 220 284 L 220 220 L 252 126 L 346 31.5 L 441 0 L 567 0 L 630 31.5 M 423 914 L 392 882 L 423 850 L 455 882 L 423 914 M 679 914 L 648 882 L 679 850 L 711 882 L 679 914" />
<glyph unicode="&#xc5;" glyph-name="Aring" horiz-adv-x="630" d="M 410 662 L 158 0 M 410 567 L 189 0 L 158 0 M 410 567 L 630 0 L 662 0 M 410 662 L 662 0 M 252 189 L 567 189 M 220 158 L 598 158 M 378 1048.9 L 359 1039.5 L 340 1020.6 L 331 1001.7 L 321 973 L 321 926 L 331 898 L 340 879 L 359 860 L 378 850 L 416 850 L 435 860 L 454 879 L 463 898 L 472 926 L 472 973 L 463 1001.7 L 454 1020.6 L 435 1039.5 L 416 1048.9 L 378 1048.9 M 387 1039.5 L 359 1030.0 L 340 1001.7 L 331 973 L 331 926 L 340 898 L 359 869 L 387 860 L 406 860 L 435 869 L 454 898 L 463 926 L 463 973 L 454 1001.7 L 435 1030.0 L 406 1039.5 L 387 1039.5" />
<glyph unicode="&#xc8;" glyph-name="Egrave" horiz-adv-x="598" d="M 472 567 L 441 536 L 441 472 L 472 441 L 536 441 L 567 504 L 567 567 L 536 630 L 472 662 L 378 662 L 315 630 L 284 598 L 252 536 L 252 472 L 284 410 L 346 378 M 378 662 L 315 598 L 284 536 L 284 441 L 346 378 M 346 378 L 284 378 L 189 346 L 126 284 L 94.5 220 L 94.5 126 L 126 63 L 158 31.5 L 220 0 L 315 0 L 410 31.5 L 472 94.5 L 504 158 L 504 220 L 472 284 L 410 284 L 346 252 L 315 189 M 284 378 L 220 346 L 158 284 L 126 220 L 126 94.5 L 158 31.5 M 331 1039.5 L 488 850 M 331 1039.5 L 299 1008.0 L 488 850" />
<glyph unicode="&#xc9;" glyph-name="Eacute" horiz-adv-x="598" d="M 472 567 L 441 536 L 441 472 L 472 441 L 536 441 L 567 504 L 567 567 L 536 630 L 472 662 L 378 662 L 315 630 L 284 598 L 252 536 L 252 472 L 284 410 L 346 378 M 378 662 L 315 598 L 284 536 L 284 441 L 346 378 M 346 378 L 284 378 L 189 346 L 126 284 L 94.5 220 L 94.5 126 L 126 63 L 158 31.5 L 220 0 L 315 0 L 410 31.5 L 472 94.5 L 504 158 L 504 220 L 472 284 L 410 284 L 346 252 L 315 189 M 284 378 L 220 346 L 158 284 L 126 220 L 126 94.5 L 158 31.5 M 677 1071.0 L 520 882 M 677 1071.0 L 709 1039.5 L 520 882" />
<glyph unicode="&#xca;" glyph-name="Ecircumflex" horiz-adv-x="598" d="M 472 567 L 441 536 L 441 472 L 472 441 L 536 441 L 567 504 L 567 567 L 536 630 L 472 662 L 378 662 L 315 630 L 284 598 L 252 536 L 252 472 L 284 410 L 346 378 M 378 662 L 315 598 L 284 536 L 284 441 L 346 378 M 346 378 L 284 378 L 189 346 L 126 284 L 94.5 220 L 94.5 126 L 126 63 L 158 31.5 L 220 0 L 315 0 L 410 31.5 L 472 94.5 L 504 158 L 504 220 L 472 284 L 410 284 L 346 252 L 315 189 M 284 378 L 220 346 L 158 284 L 126 220 L 126 94.5 L 158 31.5 M 274 756 L 425 850 L 576 756 M 274 756 L 425 832 L 576 756" />
<glyph unicode="&#xcb;" glyph-name="Edieresis" horiz-adv-x="598" d="M 472 567 L 441 536 L 441 472 L 472 441 L 536 441 L 567 504 L 567 567 L 536 630 L 472 662 L 378 662 L 315 630 L 284 598 L 252 536 L 252 472 L 284 410 L 346 378 M 378 662 L 315 598 L 284 536 L 284 441 L 346 378 M 346 378 L 284 378 L 189 346 L 126 284 L 94.5 220 L 94.5 126 L 126 63 L 158 31.5 L 220 0 L 315 0 L 410 31.5 L 472 94.5 L 504 158 L 504 220 L 472 284 L 410 284 L 346 252 L 315 189 M 284 378 L 220 346 L 158 284 L 126 220 L 126 94.5 L 158 31.5 M 365 914 L 334 882 L 365 850 L 397 882 L 365 914 M 611 914 L 580 882 L 611 850 L 643 882 L 611 914" />
<glyph unicode="&#xcc;" glyph-name="Igrave" horiz-adv-x="504" d="M 472 598 L 410 504 L 346 346 L 284 189 L 252 126 L 189 31.5 L 126 0 M 536 472 L 472 410 L 378 378 L 284 378 L 220 410 L 189 472 L 189 536 L 220 598 L 284 630 L 410 662 L 536 662 L 472 598 L 441 536 L 378 346 L 315 158 L 284 94.5 L 220 31.5 L 126 0 L 63 0 L 31.5 31.5 L 31.5 94.5 L 63 126 L 94.5 94.5 L 63 63 M 284 1039.5 L 441 850 M 284 1039.5 L 252 1008.0 L 441 850" />
<glyph unicode="&#xcd;" glyph-name="Iacute" horiz-adv-x="504" d="M 472 598 L 410 504 L 346 346 L 284 189 L 252 126 L 189 31.5 L 126 0 M 536 472 L 472 410 L 378 378 L 284 378 L 220 410 L 189 472 L 189 536 L 220 598 L 284 630 L 410 662 L 536 662 L 472 598 L 441 536 L 378 346 L 315 158 L 284 94.5 L 220 31.5 L 126 0 L 63 0 L 31.5 31.5 L 31.5 94.5 L 63 126 L 94.5 94.5 L 63 63 M 630 1071.0 L 472 882 M 630 1071.0 L 662 1039.5 L 472 882" />
<glyph unicode="&#xce;" glyph-name="Icircumflex" horiz-adv-x="504" d="M 472 598 L 410 504 L 346 346 L 284 189 L 252 126 L 189 31.5 L 126 0 M 536 472 L 472 410 L 378 378 L 284 378 L 220 410 L 189 472 L 189 536 L 220 598 L 284 630 L 410 662 L 536 662 L 472 598 L 441 536 L 378 346 L 315 158 L 284 94.5 L 220 31.5 L 126 0 L 63 0 L 31.5 31.5 L 31.5 94.5 L 63 126 L 94.5 94.5 L 63 63 M 246 756 L 397 850 L 548 756 M 246 756 L 397 832 L 548 756" />
<glyph unicode="&#xcf;" glyph-name="Idieresis" horiz-adv-x="504" d="M 472 598 L 410 504 L 346 346 L 284 189 L 252 126 L 189 31.5 L 126 0 M 536 472 L 472 410 L 378 378 L 284 378 L 220 410 L 189 472 L 189 536 L 220 598 L 284 630 L 410 662 L 536 662 L 472 598 L 441 536 L 378 346 L 315 158 L 284 94.5 L 220 31.5 L 126 0 L 63 0 L 31.5 31.5 L 31.5 94.5 L 63 126 L 94.5 94.5 L 63 63 M 321 914 L 289 882 L 321 850 L 352 882 L 321 914 M 561 914 L 530 882 L 561 850 L 593 882 L 561 914" />
<glyph unicode="&#xd0;" glyph-name="Eth" horiz-adv-x="724" d="M 158 236 L 441 236 M 504 630 L 472 598 L 441 536 L 378 378 L 315 189 L 284 126 L 220 31.5 L 158 0 M 472 598 L 441 504 L 378 252 L 346 158 L 315 94.5 L 252 31.5 L 158 0 L 94.5 0 L 63 31.5 L 63 94.5 L 94.5 126 L 158 126 L 220 94.5 L 284 31.5 L 346 0 L 441 0 L 504 31.5 L 567 94.5 L 630 220 L 662 378 L 662 472 L 630 567 L 567 630 L 504 662 L 346 662 L 252 630 L 189 567 L 158 504 L 158 441 L 189 410 L 252 410 L 284 441 L 315 504" />
<glyph unicode="&#xd1;" glyph-name="Ntilde" horiz-adv-x="724" d="M 378 662 L 346 536 L 284 346 L 220 189 L 189 126 L 126 31.5 L 63 0 L 0 0 L -31.5 31.5 L -31.5 94.5 L 0 126 L 31.5 94.5 L 0 63 M 378 662 L 378 504 L 410 158 L 441 0 M 378 662 L 410 504 L 441 158 L 441 0 M 819 630 L 788 598 L 819 567 L 850 598 L 850 630 L 819 662 L 756 662 L 693 630 L 630 536 L 598 472 L 536 315 L 472 126 L 441 0 M 404 803 L 404 835 L 420 882 L 452 898 L 483 898 L 515 882 L 578 835 L 609 819 L 641 819 L 672 835 L 688 866 M 404 835 L 420 866 L 452 882 L 483 882 L 515 866 L 578 819 L 609 803 L 641 803 L 672 819 L 688 866 L 688 898" />
<glyph unicode="&#xd2;" glyph-name="Ograve" horiz-adv-x="662" d="M 378 662 L 315 630 L 252 567 L 189 472 L 158 410 L 126 284 L 126 158 L 158 63 L 189 31.5 L 252 0 L 315 0 L 410 31.5 L 472 94.5 L 536 189 L 567 252 L 598 378 L 598 504 L 567 598 L 536 630 L 504 630 L 441 598 L 378 536 L 315 410 L 284 252 L 284 158 M 315 630 L 252 536 L 189 410 L 158 284 L 158 158 L 189 63 L 252 0 M 362 1039.5 L 520 850 M 362 1039.5 L 331 1008.0 L 520 850" />
<glyph unicode="&#xd3;" glyph-name="Oacute" horiz-adv-x="662" d="M 378 662 L 315 630 L 252 567 L 189 472 L 158 410 L 126 284 L 126 158 L 158 63 L 189 31.5 L 252 0 L 315 0 L 410 31.5 L 472 94.5 L 536 189 L 567 252 L 598 378 L 598 504 L 567 598 L 536 630 L 504 630 L 441 598 L 378 536 L 315 410 L 284 252 L 284 158 M 315 630 L 252 536 L 189 410 L 158 284 L 158 158 L 189 63 L 252 0 M 709 1071.0 L 551 882 M 709 1071.0 L 740 1039.5 L 551 882" />
<glyph unicode="&#xd4;" glyph-name="Ocircumflex" horiz-adv-x="662" d="M 378 662 L 315 630 L 252 567 L 189 472 L 158 410 L 126 284 L 126 158 L 158 63 L 189 31.5 L 252 0 L 315 0 L 410 31.5 L 472 94.5 L 536 189 L 567 252 L 598 378 L 598 504 L 567 598 L 536 630 L 504 630 L 441 598 L 378 536 L 315 410 L 284 252 L 284 158 M 315 630 L 252 536 L 189 410 L 158 284 L 158 158 L 189 63 L 252 0 M 293 756 L 444 850 L 595 756 M 293 756 L 444 832 L 595 756" />
<glyph unicode="&#xd5;" glyph-name="Otilde" horiz-adv-x="662" d="M 378 662 L 315 630 L 252 567 L 189 472 L 158 410 L 126 284 L 126 158 L 158 63 L 189 31.5 L 252 0 L 315 0 L 410 31.5 L 472 94.5 L 536 189 L 567 252 L 598 378 L 598 504 L 567 598 L 536 630 L 504 630 L 441 598 L 378 536 L 315 410 L 284 252 L 284 158 M 315 630 L 252 536 L 189 410 L 158 284 L 158 158 L 189 63 L 252 0 M 284 803 L 284 835 L 299 882 L 331 898 L 362 898 L 394 882 L 457 835 L 488 819 L 520 819 L 551 835 L 567 866 M 284 835 L 299 866 L 331 882 L 362 882 L 394 866 L 457 819 L 488 803 L 520 803 L 551 819 L 567 866 L 567 898" />
<glyph unicode="&#xd6;" glyph-name="Odieresis" horiz-adv-x="662" d="M 378 662 L 315 630 L 252 567 L 189 472 L 158 410 L 126 284 L 126 158 L 158 63 L 189 31.5 L 252 0 L 315 0 L 410 31.5 L 472 94.5 L 536 189 L 567 252 L 598 378 L 598 504 L 567 598 L 536 630 L 504 630 L 441 598 L 378 536 L 315 410 L 284 252 L 284 158 M 315 630 L 252 536 L 189 410 L 158 284 L 158 158 L 189 63 L 252 0 M 396 914 L 364 882 L 396 850 L 427 882 L 396 914 M 644 914 L 612 882 L 644 850 L 675 882 L 644 914" />
<glyph unicode="&#xd7;" glyph-name="multiply" horiz-adv-x="567" d="M 189 441 L 536 0 L 567 0 M 189 441 L 220 441 L 567 0 M 567 441 L 536 441 L 189 0 M 567 441 L 220 0 L 189 0" />
<glyph unicode="&#xd8;" glyph-name="Oslash" horiz-adv-x="662" d="M 378 662 L 315 630 L 252 567 L 189 472 L 158 410 L 126 284 L 126 158 L 158 63 L 189 31.5 L 252 0 L 315 0 L 410 31.5 L 472 94.5 L 536 189 L 567 252 L 598 378 L 598 504 L 567 598 L 536 630 L 504 630 L 441 598 L 378 536 L 315 410 L 284 252 L 284 158 M 315 630 L 252 536 L 189 410 L 158 284 L 158 158 L 189 63 L 252 0 M 680 658 L 90.7 -68" />
<glyph unicode="&#xd9;" glyph-name="Ugrave" horiz-adv-x="693" d="M 63 536 L 126 630 L 189 662 L 220 662 L 284 598 L 284 504 L 252 410 L 158 158 L 158 63 L 189 0 M 220 662 L 252 598 L 252 504 L 158 252 L 126 158 L 126 63 L 189 0 L 252 0 L 315 31.5 L 410 126 L 472 220 L 504 284 M 630 662 L 504 284 L 472 158 L 472 63 L 536 0 L 567 0 L 630 31.5 L 693 94.5 M 662 662 L 536 284 L 504 158 L 504 63 L 536 0 M 378 1039.5 L 536 850 M 378 1039.5 L 346 1008.0 L 536 850" />
<glyph unicode="&#xda;" glyph-name="Uacute" horiz-adv-x="693" d="M 63 536 L 126 630 L 189 662 L 220 662 L 284 598 L 284 504 L 252 410 L 158 158 L 158 63 L 189 0 M 220 662 L 252 598 L 252 504 L 158 252 L 126 158 L 126 63 L 189 0 L 252 0 L 315 31.5 L 410 126 L 472 220 L 504 284 M 630 662 L 504 284 L 472 158 L 472 63 L 536 0 L 567 0 L 630 31.5 L 693 94.5 M 662 662 L 536 284 L 504 158 L 504 63 L 536 0 M 724 1071.0 L 567 882 M 724 1071.0 L 756 1039.5 L 567 882" />
<glyph unicode="&#xdb;" glyph-name="Ucircumflex" horiz-adv-x="693" d="M 63 536 L 126 630 L 189 662 L 220 662 L 284 598 L 284 504 L 252 410 L 158 158 L 158 63 L 189 0 M 220 662 L 252 598 L 252 504 L 158 252 L 126 158 L 126 63 L 189 0 L 252 0 L 315 31.5 L 410 126 L 472 220 L 504 284 M 630 662 L 504 284 L 472 158 L 472 63 L 536 0 L 567 0 L 630 31.5 L 693 94.5 M 662 662 L 536 284 L 504 158 L 504 63 L 536 0 M 302 756 L 454 850 L 605 756 M 302 756 L 454 832 L 605 756" />
<glyph unicode="&#xdc;" glyph-name="Udieresis" horiz-adv-x="693" d="M 63 536 L 126 630 L 189 662 L 220 662 L 284 598 L 284 504 L 252 410 L 158 158 L 158 63 L 189 0 M 220 662 L 252 598 L 252 504 L 158 252 L 126 158 L 126 63 L 189 0 L 252 0 L 315 31.5 L 410 126 L 472 220 L 504 284 M 630 662 L 504 284 L 472 158 L 472 63 L 536 0 L 567 0 L 630 31.5 L 693 94.5 M 662 662 L 536 284 L 504 158 L 504 63 L 536 0 M 412 914 L 380 882 L 412 850 L 443 882 L 412 914 M 659 914 L 628 882 L 659 850 L 691 882 L 659 914" />
<glyph unicode="&#xdd;" glyph-name="Yacute" horiz-adv-x="693" d="M 126 536 L 189 630 L 252 662 L 284 662 L 346 630 L 346 567 L 284 378 L 284 284 L 315 220 M 284 662 L 315 630 L 315 567 L 252 378 L 252 284 L 315 220 L 378 220 L 472 252 L 536 315 L 598 410 L 630 472 M 693 662 L 630 472 L 536 220 L 472 94.5 M 724 662 L 662 472 L 598 315 L 536 189 L 472 94.5 L 410 31.5 L 315 0 L 189 0 L 126 31.5 L 94.5 94.5 L 94.5 126 L 126 158 L 158 126 L 126 94.5 M 724 1071.0 L 567 882 M 724 1071.0 L 756 1039.5 L 567 882" />
<glyph unicode="&#xe0;" glyph-name="agrave" horiz-adv-x="504" d="M 346 189 L 315 252 L 252 284 L 189 284 L 126 252 L 94.5 220 L 63 158 L 63 94.5 L 94.5 31.5 L 158 0 L 220 0 L 284 31.5 L 315 94.5 M 189 284 L 126 220 L 94.5 158 L 94.5 63 L 158 0 M 378 284 L 315 94.5 L 315 31.5 L 378 0 L 441 31.5 L 472 63 L 536 158 M 410 284 L 346 94.5 L 346 31.5 L 378 0 M 158 693 L 315 504 M 158 693 L 126 662 L 315 504" />
<glyph unicode="&#xe1;" glyph-name="aacute" horiz-adv-x="504" d="M 346 189 L 315 252 L 252 284 L 189 284 L 126 252 L 94.5 220 L 63 158 L 63 94.5 L 94.5 31.5 L 158 0 L 220 0 L 284 31.5 L 315 94.5 M 189 284 L 126 220 L 94.5 158 L 94.5 63 L 158 0 M 378 284 L 315 94.5 L 315 31.5 L 378 0 L 441 31.5 L 472 63 L 536 158 M 410 284 L 346 94.5 L 346 31.5 L 378 0 M 441 693 L 284 504 M 441 693 L 472 662 L 284 504" />
<glyph unicode="&#xe2;" glyph-name="acircumflex" horiz-adv-x="504" d="M 346 189 L 315 252 L 252 284 L 189 284 L 126 252 L 94.5 220 L 63 158 L 63 94.5 L 94.5 31.5 L 158 0 L 220 0 L 284 31.5 L 315 94.5 M 189 284 L 126 220 L 94.5 158 L 94.5 63 L 158 0 M 378 284 L 315 94.5 L 315 31.5 L 378 0 L 441 31.5 L 472 63 L 536 158 M 410 284 L 346 94.5 L 346 31.5 L 378 0 M 113 416 L 265 510 L 416 416 M 113 416 L 265 491 L 416 416" />
<glyph unicode="&#xe3;" glyph-name="atilde" horiz-adv-x="504" d="M 346 189 L 315 252 L 252 284 L 189 284 L 126 252 L 94.5 220 L 63 158 L 63 94.5 L 94.5 31.5 L 158 0 L 220 0 L 284 31.5 L 315 94.5 M 189 284 L 126 220 L 94.5 158 L 94.5 63 L 158 0 M 378 284 L 315 94.5 L 315 31.5 L 378 0 L 441 31.5 L 472 63 L 536 158 M 410 284 L 346 94.5 L 346 31.5 L 378 0 M 147 441 L 147 472 L 163 520 L 194 536 L 226 536 L 257 520 L 320 472 L 352 457 L 383 457 L 415 472 L 430 504 M 147 472 L 163 504 L 194 520 L 226 520 L 257 504 L 320 457 L 352 441 L 383 441 L 415 457 L 430 504 L 430 536" />
<glyph unicode="&#xe4;" glyph-name="adieresis" horiz-adv-x="504" d="M 346 189 L 315 252 L 252 284 L 189 284 L 126 252 L 94.5 220 L 63 158 L 63 94.5 L 94.5 31.5 L 158 0 L 220 0 L 284 31.5 L 315 94.5 M 189 284 L 126 220 L 94.5 158 L 94.5 63 L 158 0 M 378 284 L 315 94.5 L 315 31.5 L 378 0 L 441 31.5 L 472 63 L 536 158 M 410 284 L 346 94.5 L 346 31.5 L 378 0 M 191 536 L 159 504 L 191 472 L 222 504 L 191 536 M 439 536 L 408 504 L 439 472 L 471 504 L 439 536" />
<glyph unicode="&#xe5;" glyph-name="aring" horiz-adv-x="630" d="M 567 441 L 567 0 L 598 0 M 567 441 L 598 441 L 598 0 M 567 346 L 504 410 L 441 441 L 346 441 L 284 410 L 220 346 L 189 252 L 189 189 L 220 94.5 L 284 31.5 L 346 0 L 441 0 L 504 31.5 L 567 94.5 M 567 346 L 441 410 L 346 410 L 284 378 L 252 346 L 220 252 L 220 189 L 252 94.5 L 284 63 L 346 31.5 L 441 31.5 L 567 94.5 M 397 860 L 378 850 L 359 832 L 350 813 L 340 784 L 340 737 L 350 709 L 359 690 L 378 671 L 397 662 L 435 662 L 454 671 L 472 690 L 482 709 L 491 737 L 491 784 L 482 813 L 472 832 L 454 850 L 435 860 L 397 860 M 406 850 L 378 841 L 359 813 L 350 784 L 350 737 L 359 709 L 378 680 L 406 671 L 425 671 L 454 680 L 472 709 L 482 737 L 482 784 L 472 813 L 454 841 L 425 850 L 406 850" />
<glyph unicode="&#xe8;" glyph-name="egrave" horiz-adv-x="378" d="M 126 63 L 189 94.5 L 220 126 L 252 189 L 252 252 L 220 284 L 189 284 L 126 252 L 94.5 220 L 63 158 L 63 94.5 L 94.5 31.5 L 158 0 L 252 0 L 346 63 L 410 158 M 189 284 L 126 220 L 94.5 158 L 94.5 63 L 158 0 M 94.5 693 L 252 504 M 94.5 693 L 63 662 L 252 504" />
<glyph unicode="&#xe9;" glyph-name="eacute" horiz-adv-x="378" d="M 126 63 L 189 94.5 L 220 126 L 252 189 L 252 252 L 220 284 L 189 284 L 126 252 L 94.5 220 L 63 158 L 63 94.5 L 94.5 31.5 L 158 0 L 252 0 L 346 63 L 410 158 M 189 284 L 126 220 L 94.5 158 L 94.5 63 L 158 0 M 378 693 L 220 504 M 378 693 L 410 662 L 220 504" />
<glyph unicode="&#xea;" glyph-name="ecircumflex" horiz-adv-x="378" d="M 126 63 L 189 94.5 L 220 126 L 252 189 L 252 252 L 220 284 L 189 284 L 126 252 L 94.5 220 L 63 158 L 63 94.5 L 94.5 31.5 L 158 0 L 252 0 L 346 63 L 410 158 M 189 284 L 126 220 L 94.5 158 L 94.5 63 L 158 0 M 75.6 416 L 227 510 L 378 416 M 75.6 416 L 227 491 L 378 416" />
<glyph unicode="&#xeb;" glyph-name="edieresis" horiz-adv-x="378" d="M 126 63 L 189 94.5 L 220 126 L 252 189 L 252 252 L 220 284 L 189 284 L 126 252 L 94.5 220 L 63 158 L 63 94.5 L 94.5 31.5 L 158 0 L 252 0 L 346 63 L 410 158 M 189 284 L 126 220 L 94.5 158 L 94.5 63 L 158 0 M 123 536 L 91.2 504 L 123 472 L 154 504 L 123 536 M 381 536 L 350 504 L 381 472 L 413 504 L 381 536" />
<glyph unicode="&#xec;" glyph-name="igrave" horiz-adv-x="252" d="M 126 284 L 63 94.5 L 63 31.5 L 126 0 L 189 31.5 L 220 63 L 284 158 M 158 284 L 94.5 94.5 L 94.5 31.5 L 126 0 M 31.5 693 L 189 504 M 31.5 693 L 0 662 L 189 504" />
<glyph unicode="&#xed;" glyph-name="iacute" horiz-adv-x="252" d="M 126 284 L 63 94.5 L 63 31.5 L 126 0 L 189 31.5 L 220 63 L 284 158 M 158 284 L 94.5 94.5 L 94.5 31.5 L 126 0 M 315 693 L 158 504 M 315 693 L 346 662 L 158 504" />
<glyph unicode="&#xee;" glyph-name="icircumflex" horiz-adv-x="252" d="M 126 284 L 63 94.5 L 63 31.5 L 126 0 L 189 31.5 L 220 63 L 284 158 M 158 284 L 94.5 94.5 L 94.5 31.5 L 126 0 M 37.8 416 L 189 510 L 340 416 M 37.8 416 L 189 491 L 340 416" />
<glyph unicode="&#xef;" glyph-name="idieresis" horiz-adv-x="252" d="M 126 284 L 63 94.5 L 63 31.5 L 126 0 L 189 31.5 L 220 63 L 284 158 M 158 284 L 94.5 94.5 L 94.5 31.5 L 126 0 M 62.2 536 L 30.7 504 L 62.2 472 L 93.7 504 L 62.2 536 M 316 536 L 284 504 L 316 472 L 347 504 L 316 536" />
<glyph unicode="&#xf1;" glyph-name="ntilde" horiz-adv-x="567" d="M 31.5 158 L 94.5 252 L 158 284 L 220 252 L 220 189 L 158 0 M 158 284 L 189 252 L 189 189 L 126 0 M 220 189 L 284 252 L 346 284 L 378 284 L 441 252 L 441 189 L 410 94.5 L 410 31.5 L 441 0 M 378 284 L 410 252 L 410 189 L 378 94.5 L 378 31.5 L 441 0 L 504 31.5 L 536 63 L 598 158 M 158 441 L 158 472 L 173 520 L 205 536 L 236 536 L 268 520 L 331 472 L 362 457 L 394 457 L 425 472 L 441 504 M 158 472 L 173 504 L 205 520 L 236 520 L 268 504 L 331 457 L 362 441 L 394 441 L 425 457 L 441 504 L 441 536" />
<glyph unicode="&#xf2;" glyph-name="ograve" horiz-adv-x="441" d="M 252 284 L 189 284 L 126 252 L 94.5 220 L 63 158 L 63 94.5 L 94.5 31.5 L 158 0 L 220 0 L 284 31.5 L 315 63 L 346 126 L 346 189 L 315 252 L 252 284 L 220 252 L 220 189 L 252 126 L 315 94.5 L 378 94.5 L 441 126 L 472 158 M 189 284 L 126 220 L 94.5 158 L 94.5 63 L 158 0 M 126 693 L 284 504 M 126 693 L 94.5 662 L 284 504" />
<glyph unicode="&#xf3;" glyph-name="oacute" horiz-adv-x="441" d="M 252 284 L 189 284 L 126 252 L 94.5 220 L 63 158 L 63 94.5 L 94.5 31.5 L 158 0 L 220 0 L 284 31.5 L 315 63 L 346 126 L 346 189 L 315 252 L 252 284 L 220 252 L 220 189 L 252 126 L 315 94.5 L 378 94.5 L 441 126 L 472 158 M 189 284 L 126 220 L 94.5 158 L 94.5 63 L 158 0 M 410 693 L 252 504 M 410 693 L 441 662 L 252 504" />
<glyph unicode="&#xf4;" glyph-name="ocircumflex" horiz-adv-x="441" d="M 252 284 L 189 284 L 126 252 L 94.5 220 L 63 158 L 63 94.5 L 94.5 31.5 L 158 0 L 220 0 L 284 31.5 L 315 63 L 346 126 L 346 189 L 315 252 L 252 284 L 220 252 L 220 189 L 252 126 L 315 94.5 L 378 94.5 L 441 126 L 472 158 M 189 284 L 126 220 L 94.5 158 L 94.5 63 L 158 0 M 94.5 416 L 246 510 L 397 416 M 94.5 416 L 246 491 L 397 416" />
<glyph unicode="&#xf5;" glyph-name="otilde" horiz-adv-x="441" d="M 252 284 L 189 284 L 126 252 L 94.5 220 L 63 158 L 63 94.5 L 94.5 31.5 L 158 0 L 220 0 L 284 31.5 L 315 63 L 346 126 L 346 189 L 315 252 L 252 284 L 220 252 L 220 189 L 252 126 L 315 94.5 L 378 94.5 L 441 126 L 472 158 M 189 284 L 126 220 L 94.5 158 L 94.5 63 L 158 0 M 137 441 L 137 472 L 152 520 L 184 536 L 215 536 L 247 520 L 310 472 L 341 457 L 373 457 L 404 472 L 420 504 M 137 472 L 152 504 L 184 520 L 215 520 L 247 504 L 310 457 L 341 441 L 373 441 L 404 457 L 420 504 L 420 536" />
<glyph unicode="&#xf6;" glyph-name="odieresis" horiz-adv-x="441" d="M 252 284 L 189 284 L 126 252 L 94.5 220 L 63 158 L 63 94.5 L 94.5 31.5 L 158 0 L 220 0 L 284 31.5 L 315 63 L 346 126 L 346 189 L 315 252 L 252 284 L 220 252 L 220 189 L 252 126 L 315 94.5 L 378 94.5 L 441 126 L 472 158 M 189 284 L 126 220 L 94.5 158 L 94.5 63 L 158 0 M 162 536 L 131 504 L 162 472 L 194 504 L 162 536 M 405 536 L 373 504 L 405 472 L 436 504 L 405 536" />
<glyph unicode="&#xf7;" glyph-name="divide" horiz-adv-x="614" d="M 346 536 L 315 504 L 346 472 L 378 504 L 346 536 M 346 252 L 315 220 L 346 189 L 378 220 L 346 252 M 118 378 L 543 378" />
<glyph unicode="&#xf8;" glyph-name="oslash" horiz-adv-x="441" d="M 252 284 L 189 284 L 126 252 L 94.5 220 L 63 158 L 63 94.5 L 94.5 31.5 L 158 0 L 220 0 L 284 31.5 L 315 63 L 346 126 L 346 189 L 315 252 L 252 284 L 220 252 L 220 189 L 252 126 L 315 94.5 L 378 94.5 L 441 126 L 472 158 M 189 284 L 126 220 L 94.5 158 L 94.5 63 L 158 0 M 386 386 L -15.4 -108" />
<glyph unicode="&#xf9;" glyph-name="ugrave" horiz-adv-x="504" d="M 126 284 L 63 94.5 L 63 31.5 L 126 0 L 158 0 L 220 31.5 L 284 94.5 L 346 189 M 158 284 L 94.5 94.5 L 94.5 31.5 L 126 0 M 378 284 L 315 94.5 L 315 31.5 L 378 0 L 441 31.5 L 472 63 L 536 158 M 410 284 L 346 94.5 L 346 31.5 L 378 0 M 158 693 L 315 504 M 158 693 L 126 662 L 315 504" />
<glyph unicode="&#xfa;" glyph-name="uacute" horiz-adv-x="504" d="M 126 284 L 63 94.5 L 63 31.5 L 126 0 L 158 0 L 220 31.5 L 284 94.5 L 346 189 M 158 284 L 94.5 94.5 L 94.5 31.5 L 126 0 M 378 284 L 315 94.5 L 315 31.5 L 378 0 L 441 31.5 L 472 63 L 536 158 M 410 284 L 346 94.5 L 346 31.5 L 378 0 M 441 693 L 284 504 M 441 693 L 472 662 L 284 504" />
<glyph unicode="&#xfb;" glyph-name="ucircumflex" horiz-adv-x="504" d="M 126 284 L 63 94.5 L 63 31.5 L 126 0 L 158 0 L 220 31.5 L 284 94.5 L 346 189 M 158 284 L 94.5 94.5 L 94.5 31.5 L 126 0 M 378 284 L 315 94.5 L 315 31.5 L 378 0 L 441 31.5 L 472 63 L 536 158 M 410 284 L 346 94.5 L 346 31.5 L 378 0 M 113 416 L 265 510 L 416 416 M 113 416 L 265 491 L 416 416" />
<glyph unicode="&#xfc;" glyph-name="udieresis" horiz-adv-x="504" d="M 126 284 L 63 94.5 L 63 31.5 L 126 0 L 158 0 L 220 31.5 L 284 94.5 L 346 189 M 158 284 L 94.5 94.5 L 94.5 31.5 L 126 0 M 378 284 L 315 94.5 L 315 31.5 L 378 0 L 441 31.5 L 472 63 L 536 158 M 410 284 L 346 94.5 L 346 31.5 L 378 0 M 191 536 L 160 504 L 191 472 L 223 504 L 191 536 M 439 536 L 407 504 L 439 472 L 470 504 L 439 536" />
<glyph unicode="&#xfd;" glyph-name="yacute" horiz-adv-x="504" d="M 126 284 L 63 94.5 L 63 31.5 L 126 0 L 158 0 L 220 31.5 L 284 94.5 L 346 189 M 158 284 L 94.5 94.5 L 94.5 31.5 L 126 0 M 378 284 L 189 -284 M 410 284 L 315 0 L 252 -158 L 189 -284 L 158 -346 L 94.5 -378 L 63 -346 L 63 -284 L 94.5 -189 L 158 -126 L 252 -63 L 378 0 L 472 63 L 536 158 M 441 693 L 284 504 M 441 693 L 472 662 L 284 504" />
<glyph unicode="&#xff;" glyph-name="ydieresis" horiz-adv-x="504" d="M 126 284 L 63 94.5 L 63 31.5 L 126 0 L 158 0 L 220 31.5 L 284 94.5 L 346 189 M 158 284 L 94.5 94.5 L 94.5 31.5 L 126 0 M 378 284 L 189 -284 M 410 284 L 315 0 L 252 -158 L 189 -284 L 158 -346 L 94.5 -378 L 63 -346 L 63 -284 L 94.5 -189 L 158 -126 L 252 -63 L 378 0 L 472 63 L 536 158 M 193 536 L 162 504 L 193 472 L 225 504 L 193 536 M 437 536 L 405 504 L 437 472 L 468 504 L 437 536" />
<glyph unicode="&#x2013;" glyph-name="endash" horiz-adv-x="614" d="M 118 284 L 543 284" />
<glyph unicode="&#x2014;" glyph-name="emdash" horiz-adv-x="819" d="M 158 284 L 724 284" />
<glyph unicode="&#x201c;" glyph-name="quotedblleft" horiz-adv-x="378" d="M 227 879 L 198 822 L 184 765 L 184 737 L 198 709 L 213 737 L 198 765 M 369 879 L 340 822 L 326 765 L 326 737 L 340 709 L 354 737 L 340 765" />
<glyph unicode="&#x201d;" glyph-name="quotedblright" horiz-adv-x="378" d="M 184 539 L 170 567 L 184 595 L 198 567 L 198 539 L 184 482 L 156 425 M 326 539 L 312 567 L 326 595 L 340 567 L 340 539 L 326 482 L 298 425" />
<glyph unicode="&#x2039;" glyph-name="guilsinglleft" horiz-adv-x="302" d="M 265 454 L 63 227 L 265 0" />
<glyph unicode="&#x203a;" glyph-name="guilsinglright" horiz-adv-x="302" d="M 63 454 L 265 227 L 63 0" />
<glyph unicode="&#x20ac;" glyph-name="Euro" horiz-adv-x="662" d="M 662 504 L 630 567 L 567 630 L 504 662 L 378 662 L 315 630 L 252 567 L 220 504 L 189 410 L 189 252 L 220 158 L 252 94.5 L 315 31.5 L 378 0 L 504 0 L 567 31.5 L 630 94.5 L 662 158 M 662 504 L 630 504 L 598 567 L 567 598 L 504 630 L 378 630 L 315 598 L 252 504 L 220 410 L 220 252 L 252 158 L 315 63 L 378 31.5 L 504 31.5 L 567 63 L 598 94.5 L 630 158 L 662 158 M 126 236 L 410 236 M 126 331 L 410 331" />
<glyph unicode="&#x0178;" glyph-name="Ydieresis" horiz-adv-x="693" d="M 126 536 L 189 630 L 252 662 L 284 662 L 346 630 L 346 567 L 284 378 L 284 284 L 315 220 M 284 662 L 315 630 L 315 567 L 252 378 L 252 284 L 315 220 L 378 220 L 472 252 L 536 315 L 598 410 L 630 472 M 693 662 L 630 472 L 536 220 L 472 94.5 M 724 662 L 662 472 L 598 315 L 536 189 L 472 94.5 L 410 31.5 L 315 0 L 189 0 L 126 31.5 L 94.5 94.5 L 94.5 126 L 126 158 L 158 126 L 126 94.5 M 405 914 L 373 882 L 405 850 L 436 882 L 405 914 M 666 914 L 635 882 L 666 850 L 698 882 L 666 914" />
<glyph unicode="&#xe7;" glyph-name="ccedilla" horiz-adv-x="378" d="M284.0 252.0L252.0 220.0L284.0 220.0L284.0 252.0L252.0 284.0L189.0 284.0L126.0 252.0L94.5 220.0L63.0 158.0L63.0 94.5L94.5 31.5L158.0 0.0L252.0 0.0L346.0 63.0L410.0 158.0M189.0 284.0L126.0 220.0L94.5 158.0L94.5 63.0L158.0 0.0" />
<glyph unicode="&#xe7;" glyph-name="ccedilla" horiz-adv-x="378" d="M205.50776 0.51077027C211.51265 -70.843594 203.48153 -75.310271 179.74673 -87.0079M205.50776 0.51077027C211.51265 -70.843594 227.65409 -89.813806 179.74673 -87.0079" />
<glyph unicode="&#xdf;" glyph-name="germandbls" horiz-adv-x="756" d="M182.0 -0.0L244.0 31.5L308.0 126.0L339.0 189.0L402.0 378.0L465.0 536.0L496.0 598.0L561.28841 659.82087L622.0 662.0C622.0 662.0 669.34576 646.76066 686.0 630.0C702.65424 613.23934 711.96779 588.62453 717.0 567.0C722.03221 545.37547 722.03221 525.62453 717.0 504.0C711.96779 482.37547 686.0 441.0 686.0 441.0L622.0 410.0L496.0 378.0M496.0 598.0L465.0 504.0L402.0 252.0L370.0 158.0L339.0 94.5L276.0 31.5L182.0 -0.0L118.5 -0.0L87.0 31.5C87.0 31.5 80.140544 77.939809 87.0 94.5C93.859456 111.06019 103.65075999999999 126.0 118.5 126.0C133.34924 126.0 150.0 109.34924000000001 150.0 94.5C150.0 79.650758 118.5 63.0 118.5 63.0M622.0 662.0C622.0 662.0 642.65425 645.63683 654.0 630.0C665.34575 614.36317 680.81324 588.66463 686.0 567.0C691.18676 545.33537 691.18676 525.66463 686.0 504.0C680.81324 482.33537 654.0 441.0 654.0 441.0L622.0 410.0M496.0 378.0L591.0 346.0L622.0 315.0C622.0 315.0 647.61028 278.6893 654.0 252.0C660.38972 225.3107 659.23616 189.94787 654.0 158.0C648.76384 126.05213 634.60648 83.508642 622.0 63.0C609.39352 42.491358000000005 591.0 31.5 591.0 31.5L528.0 -0.0C528.0 -0.0 481.47849 -6.9029635 465.0 -0.0C448.52151 6.9029635 440.77092 14.966816 434.0 31.5C427.22908 48.033184 434.0 94.5 434.0 94.5M496.0 378.0L560.0 346.0L591.0 315.0C591.0 315.0 615.79676 278.65674 622.0 252.0C628.20324 225.34326 627.07637 189.92052999999999 622.0 158.0C616.92363 126.07947 607.47787 89.851385 591.0 63.0C574.52213 36.148615 528.0 -0.0 528.0 -0.0" />
<glyph unicode="&#xb8;" glyph-name="cedilla" horiz-adv-x="662" d="M334.0 -0.0C337.6051 -10.106733 335.09874 -25.160187 341.35397 -33.432766C372.99474 -75.277889 354.02185000000003 -146.08275 270.04014 -110.12407C300.40822000000003 -109.97435 325.66815 -108.46797000000001 333.07396 -81.404363C341.75581 -44.75115400000001 306.73007 -51.97781500000001 307.80777 -0.7622168000000045Z" />
<glyph unicode="&#xc7;" glyph-name="Ccedilla" horiz-adv-x="662" d="M334.0 -0.0C337.6051 -10.106733 335.09874 -25.160187 341.35397 -33.432766C372.99474 -75.277889 354.02185000000003 -146.08275 270.04014 -110.12407C300.40822000000003 -109.97435 325.66815 -108.46797000000001 333.07396 -81.404363C341.75581 -44.75115400000001 306.73007 -51.97781500000001 307.80777 -0.7622168000000045ZM126.0 598.0L94.5 536.0L94.5 472.0L126.0 410.0L220.0 378.0L315.0 378.0L441.0 410.0L504.0 441.0L567.0 504.0L598.0 567.0L598.0 630.0L567.0 662.0L504.0 662.0L410.0 630.0L315.0 536.0L252.0 441.0L189.0 315.0L158.0 189.0L158.0 94.5L189.0 31.5L284.0 -0.0L346.0 -0.0L441.0 31.5L504.0 94.5L536.0 158.0L536.0 220.0L504.0 284.0L441.0 284.0L378.0 252.0L346.0 189.0M504.0 662.0L441.0 630.0L346.0 536.0L284.0 441.0L220.0 315.0L189.0 189.0L189.0 94.5L220.0 31.5L284.0 -0.0" />
</font>
</defs>
</svg>
CATALOG.md
# THE CATALOG — one front-end, three engines behind

This is the skill's single selection surface. **The user picks an IDENTITY from
this table; everything else (engine, compiler, authoring file) is derived by
lookup — never ask the user (or yourself) "Standard or Cinematic or Theme?"**
Those are backend names; a product has one UX even when it has several engines.

Routing procedure (replaces any mode decision):

1. Probe the clip (decision gate + pre-flight probes as in SKILL.md).
2. Shortlist 2–3 identities from this table by **content register + the user's
   own words + scene constraints**.
3. Recommend ONE with a one-line why; the user picks.
4. Author the identity's file (last column), then render with its engine's
   pipeline. Done — no category question ever surfaced.

## Identities

| Identity      | Reading surface                                          | Voice (one line)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | Recommend for                                                                                                                                                                                                  | Scene needs                                                                                                            | Author → engine                 |
| ------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
| `cream`       | scene-embedded column flow                               | warm poetic light, emergence + glow                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | introspective, narrative, emotional monologues                                                                                                                                                                 | dark/mid-tone warm scenes (luma < 150)                                                                                 | cinematic.json → make-cinematic |
| `ink`         | scene-embedded column flow                               | letterpress pressed into bright surfaces                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | the bright scenes everything else washes out on                                                                                                                                                                | BRIGHT scenes (luma > 150)                                                                                             | cinematic.json → make-cinematic |
| `editorial`   | scene-embedded column flow                               | lowercase italic serif, pen-stroke glides                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | fashion / beauty / lyrical / premium brand films                                                                                                                                                               | mid/dark scenes                                                                                                        | cinematic.json → make-cinematic |
| `keynote`     | scene-embedded column flow                               | confident centered slides-in-the-scene                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                | launches, founder updates, tech explainers (mood over verbatim reading)                                                                                                                                        | any mid/dark                                                                                                           | cinematic.json → make-cinematic |
| `documentary` | scene-embedded column flow                               | burn-in stillness, bone on charcoal                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | interviews, journalism, testimony — gravitas IS the style                                                                                                                                                      | any                                                                                                                    | cinematic.json → make-cinematic |
| `loud`        | scene-embedded, body in front                            | percussion type that competes with the face                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           | hype, social reels, sport, music, motivational                                                                                                                                                                 | any (counter-accent handles warm)                                                                                      | cinematic.json → make-cinematic |
| `neon`        | scene-embedded column flow                               | captions as neon signage in the dark                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  | neon-noir / nightlife / tech-noir / music                                                                                                                                                                      | dark scenes                                                                                                            | cinematic.json → make-cinematic |
| `glitch`      | scene-embedded column flow                               | corrupted-feed RGB-split type                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | hacker / AI / dystopia content                                                                                                                                                                                 | mid/dark                                                                                                               | cinematic.json → make-cinematic |
| `chrome`      | scene-embedded column flow                               | liquid-metal hero with sheen sweeps                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | fashion-tech, Y2K, product-flex                                                                                                                                                                                | mid/dark                                                                                                               | cinematic.json → make-cinematic |
| `velocity`    | scene-embedded column flow                               | every word arrives along its motion vector                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | sport, automotive, fitness, kinetic content                                                                                                                                                                    | any mid/dark                                                                                                           | cinematic.json → make-cinematic |
| `anchor`      | verbatim lower-third rail + 1 settled climax             | the quiet professional — fade-up rail that simply reads; the apex settles behind the subject with a drawn accent underline. Discipline IS the style.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  | explainers, tutorials, corporate, news-adjacent — **where every word must be read**; the conservative default                                                                                                  | any (opaque rail; settle carries a soft veil)                                                                          | theme.json → make-theme         |
| `ordnance`    | stamped verbatim rail + detonation apex                  | military marking system; everything has mass; the room reacts                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | punchy announcements, drops, "炸一点 / 像 AE 做的" asks                                                                                                                                                        | any (charge-dim makes its own contrast)                                                                                | theme.json → make-theme         |
| `terminal`    | typed console panel + glyph-decode apex                  | the transcript is a signal log; apex decodes from noise                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               | tech / data / AI / investigative, "终端/控制台/解码" asks                                                                                                                                                      | cool/dusk preferred                                                                                                    | theme.json → make-theme         |
| `neonsign`    | warm tube rail + hand-written neon apex                  | a sign district at dusk; the apex is WRITTEN stroke by stroke                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | lifestyle / music / mood, "霓虹/手写" asks                                                                                                                                                                     | dusk/dark backdrop; needs `python3` (stroke-path gen)                                                                  | theme.json → make-theme         |
| `stardust`    | accumulating poem, condensing letters                    | words condense from dust and return to it                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | poetic / memory / science-wonder monologues                                                                                                                                                                    | one clear column side (probe fails → fall back to `cream`)                                                             | theme.json → make-theme         |
| `stomp`       | full-frame takeover cards                                | hard-cut typographic takeover with a void before the drop                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | hooks, manifestos, ad openers — the words ARE the show                                                                                                                                                         | any (plate runs dimmed)                                                                                                | theme.json → make-theme         |
| `lastpage`    | manuscript rail + a blurred future-field                 | the future hangs unreadable in the room; one rack-focus reveals every faint line was the apex word                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    | visionary thesis, prophecy-toned talks, trailers, literary content                                                                                                                                             | mid/dark (the field needs air)                                                                                         | theme.json → make-theme         |
| `scoreboard`  | split-flap chyron rail + departures-board apex           | stadium split-flap mechanics — body words flip-cycle wrong words on tiles before clacking onto the real one; ROW counter + odometer chip furniture; the apex is a giant departures board behind the subject that locks the word letter-by-letter under floodlights and a crowd-flash, status ticker types on                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          | sports, transit/travel, scores, countdowns, reveals, "翻牌/记分牌/机场大屏" asks                                                                                                                               | any (board + chyron bring their own dark metal contrast)                                                               | theme.json → make-theme         |
| `transit`     | LED departure-strip rail + illumination-wipe apex        | amber dot-matrix station PA — body words light column-by-column (L→R LED wipe + arrival flicker) on a docked departure strip whose lines page by scroll-snap; platform tag, ticking clock, ⚠ DELAYED → ● ON TIME gag; the apex panel expands behind the subject inside a chasing LED-marquee border, the word illuminates column-by-column under a PA flash + ding pulses, then pages up and collapses                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                | travel/transit, announcements, schedules, countdowns, retro-terminal, "LED屏/车站到站/点阵屏" asks                                                                                                             | any (LED strip + panel bring their own dark contrast)                                                                  | theme.json → make-theme         |
| `vhs`         | camcorder OSD rail + REC-FREEZE tracking-slam apex       | tape playback — permanent lens vignette + OSD furniture (▶ PLAY, ● REC, blinking green timestamp); body words carry a permanent red/cyan misregistration fringe, arrive by tracking glitch and REWIND off with flat-white echoes + scrub lines (the deck flips PLAY→REW in dead air); the apex is REC FREEZE: 1-frame white tear + head-switching noise bar, the word slams in behind the subject as three sheared tracking bands that SNAP into register, misregistration breathes through the hold under tape weave + auto-iris, then rewinds off                                                                                                                                                                                                                                                                                                                                                                                                                   | retro / nostalgia / found-footage / home-video, '90s tech, music & lifestyle edits, "录像带/VHS/复古/DV" asks                                                                                                  | any (backlight-comp scrim makes its own contrast on bright skies)                                                      | theme.json → make-theme         |
| `arcade`      | arcade HUD rail + pixel-block boss-intro apex            | 8-bit cabinet attract mode — the frame plays through CRT scanlines + a corner vignette; body words coin-pop onto a bordered HUD bar (blinking 1UP, CREDIT 01) with a 1-frame white blip each, emphasis words run a cyan→magenta palette swap, lines dissolve checkerboard; the apex is a BOSS INTRO behind the subject: the room dims, WARNING blinks twice, ~64 seeded pixel blocks fall quantized into a matrix grid over the word rect, the word flashes ON in Press Start 2P with chromatic ghost layers + a 3-frame palette strobe while letterbox bars slam in and a HI-SCORE odometer rolls top-right; everything dissolves checkerboard and the blocks drop away                                                                                                                                                                                                                                                                                              | gaming, retro-tech, playful hype, launches, achievement/level-up beats, "像素/街机/8-bit/打游戏" asks                                                                                                          | any (boss dim + dark HUD bar bring their own contrast)                                                                 | theme.json → make-theme         |
| `dossier`     | manila case-file strip + rubber-stamp apex               | cold-war case file — carbon Special Elite HAMMERS onto a manila transcript strip char by char (seeded baselines + ribbon ink, every strike kicks the paper; typed case header, CONFIDENTIAL watermark, red ink circles around the evidence; pages are X-row over-struck and carriage-fed away); the apex is a RUBBER STAMP behind the subject: the footage goes sepia-archival, the stamp crushes in at −8° with a double-strike ghost + thin-ink patches, then holds DEAD STILL — sold only by a breathing paper indent — until the page is filed away with a mechanical yank                                                                                                                                                                                                                                                                                                                                                                                        | investigations, exposés, true-crime, document-driven storytelling, declassified/archival framing, "档案/卷宗/解密/盖章" asks                                                                                   | any (the manila strip is self-lit; the stamp brings its own sepia charge)                                              | theme.json → make-theme         |
| `laser`       | beam-ignited rail + 6-beam laser-cage apex               | concert laser rig in a blackout — every body word is IGNITED where a green and a magenta beam converge on it two frames early (flare pop, snap-on, single strobe on emphasis; a sweeper beam carries each line away); the apex is the drop: a 6-beam array sweeps to one hot point, the word ignites behind the subject in an additive glow stack with two controlled strobe pops, a criss-cross beam cage draws around it, the array fans out wide through the hold, then the beams sweep everything off L→R                                                                                                                                                                                                                                                                                                                                                                                                                                                         | music, nightlife, festivals, drops, hype reveals, keynote 'one more thing' beats, "激光/灯光秀/蹦迪字幕" asks                                                                                                  | any (the concert blackout makes its own contrast, bright skies included)                                               | theme.json → make-theme         |
| `thunder`     | rain-washed rail + branched-bolt strike apex             | the storm is the editor — slate storm mood on the room, ambient slanted rain falling in front of the subject, and the sky "kisses" (1-frame brighten) on every body word; rail words arrive with a lit-bright lightning pop (emphasis = sheet-lightning double flash mirrored by a double sky-kiss); the apex is THE STRIKE: a hand-authored branched bolt flashes two frames before the word, the word slams in lit HARD with its shadow flipped toward the bolt, ozone under-glow breathes through the hold while the rain doubles, then everything washes downward with the rain                                                                                                                                                                                                                                                                                                                                                                                   | dramatic reveals, storm/weather content, epic hype beats, trailer-toned monologues, "雷电/闪电/暴风雨字幕" asks                                                                                                | any (charge-dim + radial word scrim make their own contrast, bright skies included)                                    | theme.json → make-theme         |
| `hologram`    | volumetric projection plate + hologram-boot apex         | every caption is LIGHT cast from an emitter — body words boot onto a scanlined projection plate with a rising sweep, a 1-frame interference glitch and a permanent cyan/magenta fringe (emphasis words drop out for 2 frames and restore brighter; lines drift in rotationY and exit by projector cut); the apex is the full volumetric boot: a desk emitter dot pops, the cone snaps wide, a seed beam fires, and the word materializes behind the subject as a 7-layer hologram with climbing interference bands — a hand passes through it before the projector cuts it back to the dot                                                                                                                                                                                                                                                                                                                                                                            | tech / sci-fi / AI / space / imaging explainers, futuristic product reveals, "全息/投影/科幻字幕" asks                                                                                                         | any (the apex scrim + dim make their own contrast; wants a lower frame edge for the emitter dots)                      | theme.json → make-theme         |
| `biolume`     | jellyfish glow-on rail + bioluminescent-bloom apex       | deep-sea bioluminescence — the clip sinks into an abyss grade with a breathing vignette and every caption is a living light: body words GLOW ON like jellyfish (radial bloom from center while a cyan glow breathes in; emphasis words pulse a slow propulsion double-beat) across two coexisting floating rows and SINK away dimming; seeded plankton motes drift the open water and the ones ringing the apex are pulled into the bloom; the apex is the BLOOM behind the subject: a creature pre-glow double-beats and crushes to a point, the word condenses in, six luminous tendrils draw outward like filaments while caustics shimmer, cyan light spills onto the scene and spores burst past — then all the light sinks back into the deep                                                                                                                                                                                                                   | ocean / nature / science-wonder docs, dreamy or meditative monologues, slow night-time reveals, "深海/发光水母/水下字幕" asks                                                                                  | any (the abyss grade + pocket scrim make their own contrast on bright scenes)                                          | theme.json → make-theme         |
| `aurora`      | iridescent sheen rail + silk-ribbon draw-on apex         | polar-night elegance — two huge aurora blobs drift the sky all clip; body words are silk serif ink whose rose→teal→violet sheen drifts continuously through the letters (entrance = 2-frame flow-on + y8 settle; emphasis = one bright highlight sweep marching through the word), lines share two coexisting rows like a woven couplet and dissolve UPWARD into drifting light streaks; the apex is a SILK RIBBON: the hero word is hand-WRITTEN into the sky behind the subject stroke by stroke (script single-line font, blurred halo + bright core, a light mote riding the pen) and the aurora gradient keeps FLOWING through the drawn ribbon during the hold while a soft spill tints the sky and light motes rise past the subject — gentle, no shake                                                                                                                                                                                                        | dreamy / romantic / wonder content, night-sky and travel pieces, poetry, weddings, elegant brand films, "极光/丝带/优雅手写字幕" asks                                                                          | any (apex scrim + dark understroke carry contrast on bright skies; loves dusk/night)                                   | theme.json → make-theme         |
| `spectrum`    | oscilloscope scope-band rail + phosphor trace-write apex | an oscilloscope is listening — a dark scope band hosts an ALWAYS-ALIVE waveform (seeded pseudo-RMS envelope: speech grows it, silence flattens it, every word arrival kicks a local burst) under VT323 phosphor lines that wipe in L→R over reserved slots with a 2-frame just-traced flash (emphasis words get a ticking frequency-readout chip, the trig LED blinks on every word); the apex is the trace itself LEAPING off the scope (surge column) to hand-WRITE the hero word in a thin phosphor script stroke, fill flicker-on, hum + breathe, then COLLAPSE to a flatline before the next spoken word — and on the FINAL word the trace explodes to max-amp chaos, flatlines to a single dot and stamps SIGNAL LOST                                                                                                                                                                                                                                           | science / signal / audio / measurement / astronomy explainers, data-nerd content, retro-lab or synth aesthetics, "示波器/波形/信号字幕" asks                                                                   | any (the scope band brings its own dark glass; the apex scrim handles bright skies); needs `python3` (stroke-path gen) | theme.json → make-theme         |
| `papercut`    | paper-chip craft rail + stop-motion papermat apex        | stop-motion paper craft — the frame becomes an animator's table: body words are cream paper chips PLACED one by one (steps(2) drop, seeded crooked, ~5fps wobble) on a kraft strip with torn top edge, washi-taped corners and a handwritten cut&paste tag; emphasis words get a washi-tape slap, the final word is an accent-red sign-off chip, and a red paper star stands in for the hero word; the apex is a torn kraft mat slapped down behind the subject where torn-edge letter chips DROP with stepped gravity + anticipation shadows, land crooked, settle in 12fps poses, wobble at 8fps while a warm vignette charges — then the FINAL word bumps the table and everything scatters off down-right                                                                                                                                                                                                                                                         | craft/DIY, kids & family, indie product demos, behind-the-scenes, maker channels, "手工/剪纸/定格动画字幕" asks                                                                                                | any (kraft strip + cream chips bring their own warm contrast)                                                          | theme.json → make-theme         |
| `popup`       | pop-up book page rail + centerfold starburst apex        | a pop-up book opens inside the frame — body words POP UP on a baseline hinge (rotationX back.out, paper overshoot, a growing back-shadow) along a cream page strip that unfolds from the bottom edge (center foldline, two alternating reading rows); emphasis words land bigger with a contact squash the page FEELS (1px strip dip), then flick-wobble with a blue paper strut leaning in behind; line exits fold flat in an L→R wave (the page closing), the whole page TILTS on the final word and the strip folds shut on the last frame; the apex is THE CENTERFOLD: blue + cream 12-point paper starbursts unfold behind the subject, the hero word pops up in three hinged clip-path segments, kraft struts lean in, backdrop + word wobble-settle TOGETHER and breathe through the hold — then the whole spread folds flat before the next sentence; a blue paper star holds the hero slot on the rail and paper confetti flicks past the subject at the pop | kids and family content, storytelling, education, greeting-card / wholesome brand spots, picture-book and craft channels, "立体书/弹出书/纸艺字幕" asks                                                        | any (the cream page strip is self-lit; the apex dusk scrim guards bright skies)                                        | theme.json → make-theme         |
| `chalkboard`  | chalk-band rail + hand-written chalkwrite apex           | night lecture — a worn deep-green chalk band where every word is SCRIBBLED in fast chalk (2-frame pop + steps(4) scribble reveal + dust puff at the base, seeded crooked); emphasis gets double underline dashes, the final word lands in yellow chalk, and between sentences an ERASER SWIPE smears the old words off (blurred light band, ghost smudges stay — chalk never fully erases); the apex: the band GROWS into a full blackboard behind the subject and the hero word is HAND-WRITTEN 130px tall stroke by stroke (jittered chalk nib, dust falling from the pen, blurred halo under a tight core), finished with a hand-slap dust CLAP in front of the subject + a 1-frame board shake and two violent accelerating underlines, then the halo breathes through the hold                                                                                                                                                                                   | education / explainers, science and lecture content, teacher channels, documentary asides, "黑板/粉笔/板书/老师讲课字幕" asks                                                                                  | any (the deep-green board brings its own contrast); needs `python3` (stroke-path gen)                                  | theme.json → make-theme         |
| `graffiti`    | marker rail + spray-tagged handstyle apex                | street bombing run — marker words FLICK onto the rail crooked (1-frame squeak skew, seeded rotation, back.out settle); emphasis takes alternating yellow/magenta paint + a spray-underline swoosh with overspray dots; spent lines are ROLLER-BUFFED away (gray roller sweep, wet smear lingers) and the final word lands BIG in yellow with the punch; the apex: the dusk wall dims, the can RATTLES, and the hero word is spray-TAGGED stroke by stroke behind the subject in fat yellow paint (dark under-stroke armor + blurred overspray halo + glowing nib + seeded overspray dots), paint DRIPS grow from the low points through the hold, a magenta paint splat pops at the pen-finish and the plate punches on it                                                                                                                                                                                                                                            | street culture, hip-hop / rap, sneaker & fashion drops, urban sports, skate/BMX, rebellious brand spots, "涂鸦/喷漆/街头字幕" asks                                                                             | any (the dark under-stroke armors the tag against bright skies); needs `python3` (stroke-path gen)                     | theme.json → make-theme         |
| `brush`       | washi paper-band rail + sumi-e brush-gesture apex        | sumi-e study — a deckle-edged washi paper band swipes in behind a traveling brush-head shadow; body words are brush-WIPED on in a Mincho serif (clip reveal behind a per-word brush head, seeded ±1° tilt), emphasis gets a double curved ink underline, and spent lines are wiped OFF right-to-left like a wet cloth (erase brush head riding the wipe); the apex: a warm paper wash charges behind the subject and the hero word is written in ONE violent calligraphic gesture — pressure-tapered wet brush (four stacked stroke widths, blurred bleed underneath), start-SPLAT at the first pen contact with ink flecks kicking past the subject, spatter droplets ejected at the sharpest curves — then the ink BLEEDS into the paper through the hold (the work breathes) and a vermilion SEAL chop (観) stamps beside the ink with the plate punch                                                                                                             | calm-authority explainers, craft / design / philosophy / Japan-adjacent content, tea / meditation / writing channels, elegant brand films, "水墨/毛笔/书法字幕" asks                                           | mid/bright scene band (dark ink + its paper wash carry the contrast); needs `python3` (stroke-path gen)                | theme.json → make-theme         |
| `inkwater`    | three-row ink rail + ink-drop bloom apex                 | sumi ink dropped in still water — paper-white radial washes back the text zones, Mincho-serif ink words BLEED into three fixed reading rows (blur+scale bleed over a paper-glow halo, dark blurred halo overshoots then tightens, an ink-settling hairline under each line, emphasis sheds a curling ink wisp) and spent lines re-dissolve upward like dispersing ink; the apex: a dark drop falls behind the subject, hits an invisible water surface at the word point and BLOOMS (three stacked blurred ink ellipses at counter-rotating rates + two thin ripple rings) while the world stills under a gentle vignette — the hero words resolve out of the blob on their own spoken times, breathe at the glyph edges through the hold, then re-dissolve before the next thought needs the reader                                                                                                                                                                  | contemplative / literary / scientific-wonder narration, astronomy / nature / memory / essay films, poetry and slow-craft channels — one liquid meditative event instead of an impact, "水墨滴落/墨水字幕" asks | mid/bright scene band (dark ink + its paper washes carry the contrast)                                                 | theme.json → make-theme         |
| `ransom`      | cutout-chip collage rail + ransom-note slam apex         | kidnapper's collage — every word is a chip clipped from a different page (cream Anton / black Inter / newsprint typewriter / accent-yellow marker, torn edges, hard offset shadows) PASTED crooked on the rail with misaligned baselines (the apex slam at low amplitude); emphasis words get a 1-frame RE-PASTE lift (shadow grows, rotation flips sign for good) and spent chips are RIPPED off one by one under 1-frame paper-tear flashes; the apex: a collage scrim darkens the sky and the hero word slams in letter by letter as a ransom note BEHIND the subject — each letter a different cutout chip, flight shadow popping tight on contact — the whole word JOLTS as the last letter lands (the plate punches with it), looms crooked through the hold, then is torn off chip by chip while the closing line speaks                                                                                                                                       | true crime, mystery / thriller, heist energy, punk / DIY zine aesthetics, edgy drops, unhinged-comedy hooks, "勒索信/剪贴/拼贴字幕" asks                                                                       | any (solid chip backgrounds carry their own contrast; the apex scrim guards bright skies)                              | theme.json → make-theme         |

## Shortlisting heuristics (identity-level, not category-level)

- **"每个词都要被读到 / accessibility / 教程·新闻"** → rail or panel surfaces:
  `anchor`, `ordnance`, `terminal` (all verbatim by construction).
- **"嵌进场景 / 电影感 / 高级"** → the column-flow ten; pick by register + scene luma
  (bright → `ink`; warm dark → `cream`; fashion → `editorial`; …). Bright + must-read → `anchor`.
- **"炸 / 特效 / 酷炫 / 像 AE"** → `ordnance`, `stomp`, `terminal`, `loud` — pick by
  what should explode: a word (`ordnance`), the frame (`stomp`), a decode (`terminal`),
  every beat (`loud`).
- **Named worlds** → direct hits: 控制台/终端 → `terminal`; 霓虹/招牌 → `neonsign`;
  诗/星尘/记忆 → `stardust`; 军事/重击 → `ordnance`; 预言/未来/悬念揭示 → `lastpage`;
- **Scene constraints veto last**: luma > 180 kills cream/screen column looks
  (→ `ink` or a rail/panel surface); dusk/dark unlocks `neon`/`neonsign`.
- **Unsure** → `anchor` (the conservative default: words read, scene safe, zero spectacle).
  EXCEPTION: if the ask literally says "嵌进场景 / embed into the scene / 电影感", that signal
  OUTRANKS the conservative default — go column-flow (pick by register + luma), not a rail.

Adjacencies worth knowing (these pairs are why the old mode question was hard —
recommend by the difference, don't agonize over category): `loud` vs `ordnance`
(percussive type vs a themed world with plate reaction) · `neon` vs `neonsign`
(neon-styled captions vs a sign being written) · `cream` vs `stardust` (warm
embedded flow vs an accumulating poem with semantic dispersal) · `terminal` vs
`glitch` (a console WORLD with typed log + decode vs corrupted-feed styling on
normal captions) · `stomp` vs `loud` (every beat takes over the dead-center
frame vs energetic type that still shares the frame with the subject).

## Rules

- Identities are **engine-locked** — no cross combos. Opening a new combo (e.g.
  another DNA under rail) is a validation event: render → user review → flip the
  DNA's `deliveries.rail` flag (see dna/README.md).
- New identities join via their family's registry rules (dna/README.md for
  classic, themes/README.md for themed — "any input, zero hand-fixing" bar).
- The engines' internals (Standard/Cinematic/Theme compilers, gates, layers)
  are documented in their own files; this catalog is the only routing surface.
dna/chrome.json
{
  "name": "chrome",
  "title": "Chrome — Y2K liquid metal",
  "register": "loud-luxe",
  "when": "fashion-tech, music, Y2K-revival, product-flex content. The hero is cast in liquid metal — a gradient sheen sweeps across it once during the hold. Supporting lines stay cool steel.",
  "font": {
    "family": "Audiowide",
    "weights": [400]
  },
  "palette": {
    "scheme": "light-on-dark",
    "cap_color": "#e8edf2",
    "blend": "normal",
    "accent": "#9fb6cc",
    "accent_fallback": "#9fb6cc"
  },
  "optics": {
    "depthMatch": true,
    "contactShadow": true,
    "warmBloom": false
  },
  "filter": "none",
  "motion": {
    "soft": {
      "y": 6,
      "scale": 1.04,
      "dur": 0.3,
      "ease": "power3.out"
    },
    "present": {
      "y": 6,
      "scale": 1.06,
      "dur": 0.26,
      "ease": "power3.out"
    },
    "impact": {
      "y": 8,
      "scale": 1.1,
      "dur": 0.22,
      "ease": "power4.out"
    },
    "exit": {
      "dur": 0.25,
      "ease": "power2.in",
      "stagger": 0
    }
  },
  "hero": {
    "entrance": "settle",
    "perLetter": false,
    "letterStagger": 0,
    "glow": 0.15,
    "dimOthers": 0.4,
    "breathe": 0.008,
    "sheen": true,
    "case": "uppercase",
    "tracking": "0.02em",
    "sizeRange": [0.18, 0.3],
    "css": "",
    "wordCss": "background: linear-gradient(105deg,#3a4350 0%,#f6fbff 20%,#8fa0b2 38%,#ffffff 50%,#2e3742 64%,#eef6fd 82%,#6b7888 100%); background-size: 220% 100%; -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; text-shadow: none;"
  },
  "css": "",
  "deliveries": {
    "home": "column",
    "rail": "unvalidated",
    "note": "signature = liquid-metal wordCss + sheen sweep (engine fx) — std ignores wordCss/fx"
  }
}
dna/cream.json
{
  "name": "cream",
  "title": "Warm cream — the poetic default",
  "register": "premium-warm",
  "when": "dark / mid-tone warm scenes (band luma < 150): studios, evening interiors, moody interviews. Introspective, narrative, emotional content. The successor of cinematic-cream.",
  "font": {
    "family": "Inter",
    "weights": [500, 600, 700, 900]
  },
  "palette": {
    "scheme": "light-on-dark",
    "cap_color": "#fff5df",
    "blend": "screen",
    "accent": "scene",
    "accent_fallback": "#e3c06a"
  },
  "optics": {
    "depthMatch": true,
    "contactShadow": true,
    "warmBloom": true
  },
  "filter": "contrast(1.08)",
  "motion": {
    "soft": {
      "y": 8,
      "blur": 2.5,
      "dur": 0.5,
      "ease": "power2.out"
    },
    "present": {
      "y": 6,
      "blur": 1.5,
      "scale": 1.03,
      "dur": 0.3,
      "ease": "power3.out"
    },
    "impact": {
      "y": 12,
      "blur": 1.5,
      "scale": 1.08,
      "dur": 0.24,
      "ease": "power4.out"
    },
    "exit": {
      "dur": 0.45,
      "y": -8,
      "ease": "sine.in",
      "stagger": 0
    }
  },
  "hero": {
    "entrance": "emergence",
    "perLetter": true,
    "letterStagger": 0.016,
    "glow": 0.3,
    "dimOthers": 0.45,
    "breathe": 0.012,
    "case": "uppercase",
    "tracking": "-0.03em",
    "sizeRange": [0.2, 0.34],
    "letterBlur": 2.5
  },
  "css": "",
  "deliveries": {
    "home": "column",
    "rail": "validated",
    "note": "standard-mode lineage IS cinematic-cream; validated std renders (jimeng)"
  }
}
dna/documentary.json
{
  "name": "documentary",
  "title": "Documentary — Errol Morris dignity",
  "register": "formal",
  "when": "interviews, serious subject matter, journalism, testimonial. Burn-in reveals (no animation), bone on charcoal, a deliberately modest hero. Gravitas IS the style — stillness, no accent color, no glow.",
  "font": {
    "family": "Inter",
    "weights": [500, 700]
  },
  "palette": {
    "scheme": "light-on-dark",
    "cap_color": "#F5EFE6",
    "blend": "normal",
    "accent": "#F5EFE6",
    "accent_fallback": "#F5EFE6"
  },
  "optics": {
    "depthMatch": true,
    "contactShadow": true,
    "warmBloom": false
  },
  "filter": "none",
  "motion": {
    "soft": {
      "reveal": "burn"
    },
    "present": {
      "y": 0,
      "dur": 0.18,
      "ease": "power1.out"
    },
    "impact": {
      "y": 6,
      "scale": 1.03,
      "dur": 0.2,
      "ease": "power2.out"
    },
    "exit": {
      "dur": 0.15,
      "ease": "power1.in",
      "stagger": 0
    }
  },
  "hero": {
    "entrance": "rise",
    "perLetter": false,
    "letterStagger": 0,
    "glow": 0,
    "dimOthers": 0.3,
    "breathe": 0,
    "case": "none",
    "tracking": "-0.015em",
    "sizeRange": [0.09, 0.15],
    "css": "font-weight: 700;"
  },
  "css": "",
  "deliveries": {
    "home": "column",
    "rail": "unvalidated",
    "note": "the lower-third IS documentary's home register (Errol Morris); column stays deliberately modest"
  }
}
dna/editorial.json
{
  "name": "editorial",
  "title": "Editorial — serif voice, magazine cadence",
  "register": "editorial-luxe",
  "when": "introspective monologue, fashion / beauty, poetic or lyrical content, premium brand films on mid/dark scenes. The serif italic carries intimacy that a grotesque can't. The hero stays lowercase italic — elegance over shout.",
  "font": {
    "family": "Bodoni Moda",
    "weights": [400, 700]
  },
  "palette": {
    "scheme": "light-on-dark",
    "cap_color": "#f2ead8",
    "blend": "screen",
    "accent": "scene",
    "accent_fallback": "#c9a36a"
  },
  "optics": {
    "depthMatch": true,
    "contactShadow": true,
    "warmBloom": true
  },
  "filter": "contrast(1.06)",
  "motion": {
    "soft": {
      "x": -10,
      "y": 0,
      "dur": 0.55,
      "ease": "power2.out"
    },
    "present": {
      "x": -8,
      "y": 0,
      "scale": 1.02,
      "dur": 0.34,
      "ease": "power2.out"
    },
    "impact": {
      "x": -10,
      "y": 0,
      "scale": 1.06,
      "dur": 0.28,
      "ease": "power3.out"
    },
    "exit": {
      "dur": 0.4,
      "x": 8,
      "ease": "sine.in",
      "stagger": 0.07
    }
  },
  "hero": {
    "entrance": "emergence",
    "perLetter": true,
    "letterStagger": 0.022,
    "glow": 0.18,
    "dimOthers": 0.5,
    "breathe": 0.01,
    "case": "none",
    "tracking": "-0.01em",
    "sizeRange": [0.18, 0.3],
    "css": "font-style: italic; font-weight: 700;",
    "letterBlur": 1.5
  },
  "css": "",
  "deliveries": {
    "home": "column",
    "rail": "unvalidated",
    "note": "italic serif rail reads fine; the signature x-glide + line cascade-out exits don't exist in the std enum"
  }
}
dna/glitch.json
{
  "name": "glitch",
  "title": "Glitch — signal corruption",
  "register": "loud-cyber",
  "when": "digital / hacker / AI / dystopia content; anything that wants the captions to feel like a corrupted feed. RGB-split echoes snap together as each beat lands; timing is machine-percussive; nothing eases politely.",
  "font": {
    "family": "Space Grotesk",
    "weights": [400, 700]
  },
  "palette": {
    "scheme": "light-on-dark",
    "cap_color": "#eaffff",
    "blend": "screen",
    "accent": "#ff3df0",
    "accent_fallback": "#ff3df0"
  },
  "optics": {
    "depthMatch": false,
    "contactShadow": false,
    "warmBloom": false
  },
  "filter": "none",
  "motion": {
    "soft": {
      "x": -6,
      "stretchX": 1.12,
      "dur": 0.12,
      "ease": "power4.out"
    },
    "present": {
      "x": -8,
      "stretchX": 1.18,
      "dur": 0.11,
      "ease": "power4.out"
    },
    "impact": {
      "x": -10,
      "stretchX": 1.25,
      "dur": 0.1,
      "ease": "power4.out"
    },
    "exit": {
      "dur": 0.1,
      "scale": 1.02,
      "ease": "power4.in",
      "stagger": 0
    }
  },
  "hero": {
    "entrance": "settle",
    "perLetter": false,
    "letterStagger": 0,
    "glow": 0.18,
    "dimOthers": 0.45,
    "breathe": 0,
    "ripple": 2,
    "case": "uppercase",
    "tracking": "0.01em",
    "sizeRange": [0.2, 0.32],
    "css": "font-weight: 700;",
    "echoes": [
      {
        "dx": -5,
        "dy": 0,
        "color": "#22e6ff",
        "opacity": 0.75
      },
      {
        "dx": 5,
        "dy": 1,
        "color": "#ff3df0",
        "opacity": 0.75
      }
    ]
  },
  "css": "",
  "deliveries": {
    "home": "column",
    "rail": "unvalidated",
    "note": "signature = RGB-split echoes converge (engine fx) — rail keeps only a tinted skin"
  }
}
dna/ink.json
{
  "name": "ink",
  "title": "Ink — printed on the scene",
  "register": "premium",
  "when": "BRIGHT scenes (band luma > 150): white offices, daylight, pale walls — the scenes where cream/screen washes out. Multiply blend makes the type read as printed ON the wall, picking up its texture. Confident, editorial-tech tone.",
  "font": {
    "family": "Inter",
    "weights": [500, 700, 900]
  },
  "palette": {
    "scheme": "dark-on-light",
    "cap_color": "#1b1714",
    "blend": "multiply",
    "accent": "scene",
    "accent_fallback": "#9a3b2e"
  },
  "optics": {
    "depthMatch": true,
    "contactShadow": true,
    "warmBloom": false
  },
  "filter": "contrast(1.05)",
  "motion": {
    "soft": {
      "y": 0,
      "scale": 1.06,
      "dur": 0.22,
      "ease": "power3.out"
    },
    "present": {
      "y": 0,
      "scale": 1.08,
      "dur": 0.2,
      "ease": "power3.out"
    },
    "impact": {
      "y": 0,
      "scale": 1.12,
      "dur": 0.18,
      "ease": "power4.out"
    },
    "exit": {
      "dur": 0.22,
      "ease": "power2.in",
      "stagger": 0
    }
  },
  "hero": {
    "entrance": "settle",
    "perLetter": false,
    "letterStagger": 0.014,
    "glow": 0,
    "dimOthers": 0.4,
    "breathe": 0,
    "case": "uppercase",
    "tracking": "-0.035em",
    "sizeRange": [0.2, 0.34]
  },
  "css": "",
  "deliveries": {
    "home": "column",
    "rail": "unvalidated",
    "note": "rail works (scale-press→settle) but the letterpress identity wants a scene surface to press INTO"
  }
}
dna/keynote.json
{
  "name": "keynote",
  "title": "Keynote — Apple-grade confidence",
  "register": "tech-premium",
  "when": "product walkthroughs, launches, founder updates, tech explainers. Opaque white type, dead-center planes, few words held long. Confidence comes from mass and stillness, not motion.",
  "font": {
    "family": "Inter",
    "weights": [400, 600, 800]
  },
  "palette": {
    "scheme": "light-on-dark",
    "cap_color": "#ffffff",
    "blend": "normal",
    "accent": "scene",
    "accent_fallback": "#8ab4ff"
  },
  "optics": {
    "depthMatch": true,
    "contactShadow": true,
    "warmBloom": false
  },
  "filter": "none",
  "motion": {
    "soft": {
      "reveal": "burn"
    },
    "present": {
      "reveal": "burn"
    },
    "impact": {
      "reveal": "burn"
    },
    "lineReveal": {
      "type": "wipe-x",
      "dur": 0.4,
      "ease": "expo.out"
    },
    "exit": {
      "dur": 0.3,
      "ease": "power2.in",
      "stagger": 0
    }
  },
  "hero": {
    "entrance": "wipe-up",
    "perLetter": false,
    "letterStagger": 0,
    "glow": 0,
    "dimOthers": 0.35,
    "breathe": 0,
    "case": "uppercase",
    "tracking": "-0.045em",
    "sizeRange": [0.16, 0.3],
    "css": "font-weight: 800;"
  },
  "css": "",
  "deliveries": {
    "home": "column",
    "rail": "validated",
    "note": "wipe-up maps to the std entrance enum; keynote_std validated (Shine)"
  }
}
dna/loud.json
{
  "name": "loud",
  "title": "Loud — the sanctioned blast",
  "register": "loud",
  "when": "hype, social reels, sport, music, motivational shorts — content that WANTS the type to compete with the face (k-pop-lyric lineage; deliberately breaks 'subject wins' at the hero). Machine-gun letter staggers, slam entrances, scene-sampled saturated accent on the hero. Body lines ANNOUNCE in front of the subject (bodyLayer:fg — k-pop lyric depth); only the hero embeds behind.",
  "font": {
    "family": "Anton",
    "weights": [400]
  },
  "palette": {
    "scheme": "light-on-dark",
    "cap_color": "#ffffff",
    "blend": "normal",
    "accent": "scene",
    "accent_fallback": "#ffd600",
    "heroColor": "accent",
    "accentMode": "counter"
  },
  "optics": {
    "depthMatch": false,
    "contactShadow": true,
    "warmBloom": false
  },
  "filter": "none",
  "motion": {
    "soft": {
      "y": 0,
      "scale": 1.3,
      "rot": 1.2,
      "dur": 0.18,
      "ease": "back.out(1.7)"
    },
    "present": {
      "y": 0,
      "scale": 1.35,
      "rot": 1.2,
      "dur": 0.18,
      "ease": "back.out(1.7)"
    },
    "impact": {
      "y": 0,
      "scale": 1.45,
      "rot": 1.5,
      "dur": 0.16,
      "ease": "back.out(1.9)"
    },
    "exit": {
      "dur": 0.14,
      "scale": 0.96,
      "ease": "power3.in",
      "stagger": 0
    }
  },
  "hero": {
    "entrance": "slam",
    "perLetter": false,
    "letterStagger": 0.01,
    "glow": 0.25,
    "dimOthers": 0.55,
    "breathe": 0.016,
    "case": "uppercase",
    "tracking": "-0.005em",
    "sizeRange": [0.26, 0.4],
    "css": "-webkit-text-stroke: 2px rgba(0,0,0,0.35); paint-order: stroke fill;",
    "ripple": 3,
    "loom": 0.03
  },
  "css": "",
  "bodyLayer": "fg",
  "deliveries": {
    "home": "column",
    "rail": "unvalidated",
    "note": "slam maps; bodyLayer:fg already a std-shaped hybrid; validated both"
  }
}
dna/neon.json
{
  "name": "neon",
  "title": "Neon — the sign in the scene",
  "register": "loud-neon",
  "when": "neon-noir / nightlife / tech-noir / music content on dark scenes. The captions ARE neon signage hanging in the room: electric cyan tubes, ignition flicker, a hum of glow. The hero powers ON like a sign being lit.",
  "font": {
    "family": "Orbitron",
    "weights": [400, 700]
  },
  "palette": {
    "scheme": "light-on-dark",
    "cap_color": "#d9f7ff",
    "blend": "screen",
    "accent": "#22e6ff",
    "accent_fallback": "#22e6ff",
    "heroColor": "accent"
  },
  "optics": {
    "depthMatch": false,
    "contactShadow": false,
    "warmBloom": false
  },
  "filter": "none",
  "motion": {
    "soft": {
      "reveal": "flicker"
    },
    "present": {
      "reveal": "flicker"
    },
    "impact": {
      "reveal": "flicker"
    },
    "exit": {
      "dur": 0.18,
      "ease": "power2.in",
      "stagger": 0
    }
  },
  "hero": {
    "entrance": "flicker-on",
    "perLetter": false,
    "letterStagger": 0,
    "glow": 0.5,
    "dimOthers": 0.4,
    "breathe": 0.01,
    "case": "uppercase",
    "tracking": "0.04em",
    "sizeRange": [0.18, 0.3],
    "css": "text-shadow: 0 0 14px rgba(34,230,255,0.65), 0 0 40px rgba(34,230,255,0.35), 0 0 80px rgba(34,230,255,0.18);"
  },
  "css": ".cap { text-shadow: 0 0 10px rgba(34,230,255,0.4), 0 0 26px rgba(34,230,255,0.2); }",
  "deliveries": {
    "home": "column",
    "rail": "unvalidated",
    "note": "signature = flicker-on + glow layer (engine hero fx) — rail keeps only the cyan skin"
  }
}
dna/README.md
# DNA registry — pick a visual language, not a preset

A **DNA** is a complete, art-directed visual language: typeface, palette logic, motion
grammar, and hero orchestration. It **parameterizes per scene** instead of shipping a
fixed look: the accent color is sampled from THIS scene, the contact shadow falls along
THIS scene's light, embed text blur matches THIS scene's depth-of-field, and the hero's
entrance amplitude follows how hard the word was actually spoken (RMS).

This replaces the template grab-bag. Six deep languages × scene adaptation beats 54
shallow presets — every render is already fitted to its footage.

## Category lock (deliveries field, enforced by the compilers)

Every classic DNA's **home is Cinematic (column)** — that is where all ten were
built and validated. (Standard/rail mode was retired 2026-06-12; the verbatim-rail
need is served by the `anchor` theme. The old rail combos are archived outside
this repo and are not distributed with the skill.)

## The ten

| DNA             | Register       | Scene fit                                       | Voice                                                                                                                                                                       |
| --------------- | -------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **cream**       | premium-warm   | dark / mid warm scenes (band luma < 150)        | Inter, warm cream, screen blend, glowing emergence hero. The poetic default.                                                                                                |
| **ink**         | premium        | **bright scenes (band luma > 150)**             | Inter, near-black, multiply blend — type reads as _printed on_ the wall. Fixes the bright-scene hole.                                                                       |
| **editorial**   | editorial-luxe | introspective / fashion / poetic, mid-dark      | Bodoni Moda, bone, _lowercase italic hero_ — magazine elegance over shout.                                                                                                  |
| **keynote**     | tech-premium   | product / launch / founder updates              | Inter 800, opaque white, line-wipe reveals, hero wipes UP. Stillness = confidence.                                                                                          |
| **documentary** | formal         | interviews, serious subject matter              | Inter, bone, **burn-in reveals**, no hero. Gravitas IS the style.                                                                                                           |
| **loud**        | loud           | hype / sport / music / social                   | Anton, scene-sampled accent hero, single-unit slam + caption-layer ripple; **body announces in front** (`bodyLayer: "fg"` — k-pop depth, deliberate).                       |
| **neon**        | loud-neon      | neon-noir / nightlife / tech-noir (dark scenes) | Orbitron + electric cyan; words flicker-ignite like tubes; the hero **powers ON** with a strobe and hums (glow 0.5).                                                        |
| **glitch**      | loud-neon      | digital / hacker / AI / dystopia                | Space Grotesk; **RGB-split echo layers converge** as each beat lands; 0.10–0.12s machine percussion; landing bumps co-visible captions 2px.                                 |
| **chrome**      | loud-luxe      | Y2K / fashion-tech / music                      | Audiowide cast in a **liquid-metal gradient** (background-clip:text on the word spans); one sheen sweep crosses the hero during the hold.                                   |
| **velocity**    | loud-sport     | sport / automotive / fitness                    | Teko italic; every word arrives **along its motion vector** (streak + skew settling upright); the hero passes through with blurred speed-trail echoes and looms while held. |

### fx fields (hero block) the engine understands

`entrance: emergence | settle | slam | rise | wipe-up | flicker-on | streak` ·
`glow` (0–0.5) · `echoes: [{dx,dy,color,opacity,blur}]` (converge-on-land duplicates) ·
`sheen: true` (gradient sweep — pair with `wordCss` background-clip) · `wordCss`
(per-word treatment; required for background-clip:text, which can't clip through
composited children) · `ripple` (px — landing bump, captions only) · `loom` / `breathe`
(hold-life) · `letterBlur` · `bodyLayer` (top-level: default layer for narration lines).

## How to pick (agent)

1. Read `safe-zones.json` → `heroAnchor.bandLuma` + `palette.temperature`.
2. Bright band (>150) → **ink** (never fight a bright scene with cream/screen).
3. Else pick by content register: poetic/warm → **cream** · introspective/luxe →
   **editorial** · product/tech → **keynote** · serious/interview → **documentary** ·
   hype/social → **loud**.
4. State the pick + why in one line; the user decides (SKILL.md Step 0 still applies).

## Authoring

- Cinematic mode: `cinematic.json` → `"dna": "<name>"` (drop the `template` field).
- Locked per DNA: family, palette scheme, blend, motion curves, hero orchestration.
- Open per group (unchanged): size / weight / style / case / spacing + planes.
- `var(--accent)` is available in per-group CSS — it resolves to the scene-sampled accent.

## What the engine generates from the DNA (no authoring needed)

- **Hero three-act**: co-visible captions dim 0.35s before the hero lands (setup) →
  entrance with per-letter stagger, amplitude ∝ spoken loudness (impact) → slow breathe
  - glow until exit (afterglow).
- **Scene optics**: depth-match blur on embed captions, light-direction contact shadow.
- All deterministic — measured from files, no randomness.

## Climax selection — what earns the promotion

Pick the **payoff, not the topic**: the words you would quote when paraphrasing the
beat. "The stars were only limited by the pixel size" — the payoff is **pixel size**
(the surprising claim), not "stars" (the subject). Test: would the word alone, big on
screen, make the listener nod? A topic word makes them wait.

- **Phrases are legal** (`match: "pixel size"` / `"skin food"`): a peak is a semantic
  unit, not necessarily one token. 2–4 words max — beyond that it's a sentence, not a
  peak.
- **Prefer clause-final** — nothing left dangling to place after the lift.
- **Never strand a determiner**: lifting "stars" out of "The stars…" leaves an orphan
  "The" that vanishes mid-air. The compiler auto-absorbs a line-leading article into
  the promoted phrase; mid-line, match the phrase including its article.
- Sizes are TARGETED by the compiler, not just floored — author sizes are hints:
  - **apex = frame event**: under 88% of usable width → raised toward a 93% fill.
    Std height cap = `sizeRange[1] × 1.25` (≤46cqh; formal register ×1.0) — a std apex
    bursts a rail, not a body composition. Cinematic cap = `sizeRange[1]`.
  - **short words fill with TRACKING**: when the height cap binds before the width
    target (SHINE — 5 glyphs), the compiler letterspaces up to +0.32em until the word
    owns ~88% of the width (film-title craft: HER / DUNE). Long words fill by scale,
    short words by air — impact is WIDTH-led either way.
  - **minor = damped beat in the apex family**: `max(3× rail, 0.55× apex-final)`,
    width-capped. A peak that reads as a label is a bug.
  - **lockup context is RATIO-LOCKED**: kicker/tail = `0.26× hero` (clamped
    0.05–0.085·h), recomputed after any width-fit raise. Don't hand-tune context
    sizes — size the hero; the context follows like a poster system.

## Climax placement — the lockup is the default, NOT the only composition

A hero line takes `"placement": "subject" | "column"`:

- **subject** (default) — the **ORBIT lockup** centered ON the subject. NOT a centered
  sandwich: context anchors to the hero's EDGES like a poster kicker/tagline —
  pre-context flush-left off the hero's top-left corner, post-context flush-right off
  its bottom-right — so the small lines land BESIDE the head/shoulder (on the scene),
  never on the face, and therefore EMBED (bg) like everything else. The eye reads a
  diagonal: kicker ↘ HERO ↘ tail. Only the hero crosses the silhouette (the
  matte-occlusion showcase). The context's job is reading continuity — it gets the
  margins; the hero's job is the cinematic event — it gets the subject. Right when the
  peak is THE dramatic moment: cream / loud / glitch / neon / velocity lean here.
- **column** — the apex stays IN its column's reading flow and BURSTS it (oversized,
  full hero motion + dim/glow privileges, but composed within the sentence's home
  zone; spills toward the subject naturally when oversized). Right when the content
  is a continuous read and the column is the narrative's home: keynotes, tutorials,
  explainers, documentary. Registers: keynote / documentary / editorial lean here.

Applying ONE composition to every peak is monoculture — the same failure the motion
review caught. Pick per beat: a 15s explainer might run column peaks throughout and
save the subject lockup for the single apex. (A third pattern — the editorial split,
bg context upper + fg hero lower-third — lives in references/composition-craft.md and
is the next placement candidate.)

## The motion language (five layers, each DNA answers all five)

A DNA's animation is not a parameter tweak on a shared fade — it's a distinct physics:

| Layer                  | What it governs                                                                                                    | Where it lives                                        |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- |
| **word entrance**      | how each word materializes on its beat ({y,x,blur,scale,rot,dur,ease} or burn)                                     | `motion.soft/present/impact`                          |
| **line exit**          | direction + speed of leaving, optional top-to-bottom cascade                                                       | `motion.exit` ({dur,y,x,scale,ease,stagger})          |
| **hold-life**          | what text does while on screen — stillness is a deliberate choice                                                  | `hero.breathe` / `hero.loom` (0 = print-still)        |
| **hero orchestration** | the three-act entrance signature (emergence / settle / slam / rise / **wipe-up**) + glow / letterBlur / **ripple** | `hero.*`                                              |
| **timing physics**     | the duration & easing register everything obeys (percussive 0.15s ↔ atmospheric 0.6s)                              | implied by the values above — keep one family per DNA |

Shipped signatures: cream = _light condenses_ (blur-settle, drift-up exits) · ink =
_letterpress_ (scale-press, no float, dead-still) · editorial = _the pen glides_
(x-glide with the italic, cascade-out page turns) · keynote = _surgical reveal_
(burn words + line wipe + hero wipe-up, exits never move) · documentary = _burn and
hold_ (motion's absence IS the language) · loud = _percussion_ (back-eased punches,
alternating tilt, hero ripple that bumps every caption — never the footage).

Rules inherited from hyperframes motion-principles: exits faster than entrances; vary
ease families BETWEEN DNAs (not within one); `.out` enters, `.in` leaves; never two
transform tweens on one element in the same window (wipe-up is clip-path so the
afterglow scale channel stays free; loom subsumes breathe).

## Adding a DNA

Copy an existing `dna/<name>.json`, change the voice, keep the schema. The engine
(`modes/cinematic/engine.html`) and both compilers consume it as-is. A DNA earns its
place by being a _distinct voice with a reason to exist_ — not a recolor.
dna/velocity.json
{
  "name": "velocity",
  "title": "Velocity — speed lines",
  "register": "loud-sport",
  "when": "sport, automotive, fitness, anything kinetic. Every word arrives ALONG its own motion vector (streak + skew settles upright); the hero passes through with speed trails behind it. Condensed italic forward lean.",
  "font": {
    "family": "Teko",
    "weights": [400, 700]
  },
  "palette": {
    "scheme": "light-on-dark",
    "cap_color": "#ffffff",
    "blend": "normal",
    "accent": "scene",
    "accent_fallback": "#ffd600",
    "heroColor": "accent",
    "accentMode": "counter"
  },
  "optics": {
    "depthMatch": false,
    "contactShadow": true,
    "warmBloom": false
  },
  "filter": "none",
  "motion": {
    "soft": {
      "x": -22,
      "skewX": -8,
      "stretchX": 1.3,
      "dur": 0.22,
      "ease": "expo.out"
    },
    "present": {
      "x": -26,
      "skewX": -10,
      "stretchX": 1.35,
      "dur": 0.2,
      "ease": "expo.out"
    },
    "impact": {
      "x": -32,
      "skewX": -12,
      "stretchX": 1.45,
      "dur": 0.18,
      "ease": "expo.out"
    },
    "exit": {
      "dur": 0.16,
      "x": 14,
      "ease": "power3.in",
      "stagger": 0.04
    }
  },
  "hero": {
    "entrance": "streak",
    "perLetter": false,
    "letterStagger": 0,
    "glow": 0,
    "dimOthers": 0.5,
    "breathe": 0,
    "loom": 0.025,
    "case": "uppercase",
    "tracking": "0.015em",
    "sizeRange": [0.24, 0.38],
    "css": "font-weight: 700; font-style: italic;",
    "echoes": [
      {
        "dx": -10,
        "dy": 0,
        "color": "#ffffff",
        "opacity": 0.22,
        "blur": 1
      },
      {
        "dx": -20,
        "dy": 0,
        "color": "#ffffff",
        "opacity": 0.1,
        "blur": 2
      }
    ]
  },
  "css": "",
  "deliveries": {
    "home": "column",
    "rail": "unvalidated",
    "note": "signature = streak + trail echoes along the speed vector (engine fx) — rail loses all of it"
  }
}
modes/cinematic/_archive/champion/spec.md
# Template: champion

**Look:** podcast / interview, upper-side column with intro→phrase→emph
beats building toward a center-stage CROWN that crosses the subject's body.
"Wimbledon Champion" energy.

**Visual identity (LOCKED):**

- 5 typography slots: intro / phrase / emph / dream / crown
- `mix-blend-mode: screen` over warm bone color (#fff5df)
- Deep text-shadow + brightness boost
- Single-caption-swap layout (caps stack at one point in the column)
- Center-stage crown crosses the subject for cinematic occlusion

## When to apply

✅ **Good fit:**

- Podcast / interview style with a single seated subject
- Cluttered backdrop (bookshelf, props, dark) — screen blend reads through
- Subject roughly centered with clear clean zones on one or both sides
- Speech has a clear payoff line that can become a "crown" (1-3 short words)
- 16:9 landscape, 1920×1080 ideal

❌ **Wrong fit:**

- Bright/sky/window backdrop (screen blend washes out)
- Subject heavily off-center such that there's no clean column zone
- Speech has no clear climax line
- Portrait 9:16 → use `portrait-header` template instead

## Layout decisions agent makes

| Field                         | What                                                                                                                                        | Example (1920×1080)                            |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| `plane.top/left/width/height` | Where the side column sits. Pick the side opposite the subject's gaze (looking room) and hug the cleanest backdrop zone.                    | `{top: 40, left: 40, width: 720, height: 420}` |
| `plane.rotateY`               | Slight perspective into the wall. Positive for left column (recedes right), negative for right column. Keep small (3-7°).                   | `4`                                            |
| `crown_top`                   | Y position of the crown. Should fall on the subject's chest/neck so the body occludes the middle letters.                                   | `420`                                          |
| `font_scale`                  | Multiplier on locked sizes.                                                                                                                 | `1.0` for 1920×1080                            |
| `crown_enabled`               | Whether to render a crown. Skip if the speech has no clean payoff line, or if the subject is too far off-center for a center crown to land. | `true`                                         |

### Crown placement rules

Before enabling the crown, verify all 3 conditions:

1. Subject body center within ±10% of frame horizontal center
2. Clean zones ≥15% of frame width on each side of subject
3. Crown text width (uppercase 140px × ~chars) > subject body width + 400px

If any fails: disable the crown OR use a smaller font + place it in the
larger clean zone instead of center. See `references/layout-heuristics.md`.

## Slot assignment

| Slot   | Use for                                                    |
| ------ | ---------------------------------------------------------- |
| intro  | Filler / discourse markers ("you know,", "for me,", "so…") |
| phrase | Main statement clauses                                     |
| emph   | Key achievement / superlative line                         |
| dream  | Aspirational lines, past-tense reflection                  |
| crown  | The single climax line — center-stage payoff               |

Aim for ≤3 slots per video (excluding crown). Don't use all 4 in a 12s clip.

## Plan.json shape

```json
{
  "mode": "template",
  "template": "champion",
  "duration": 12.08, "fps": 24, "width": 1920, "height": 1080,
  "plane": {
    "top": 40, "left": 40, "width": 720, "height": 420,
    "rotateY": 4
  },
  "crown_top": 420,
  "font_scale": 1.0,
  "groups": [
    { "id": "cg-0", "slot": "intro", "tone": "soft",
      "in": 0.10, "out": 1.45, "words": [...] },
    { "id": "cg-1", "slot": "phrase", "tone": "soft",
      "in": 1.55, "out": 4.20, "words": [...] },
    { "id": "cg-2", "slot": "emph", "tone": "present",
      "in": 6.50, "out": 8.30, "words": [...] }
  ],
  "crown_group": {
    "id": "cg-crown", "tone": "present",
    "in": 10.85, "out": 12.08,
    "words": [
      {"text": "Wimbledon", "start": 10.94, "end": 11.28},
      {"text": "Champion",  "start": 11.38, "end": 11.84}
    ]
  }
}
```

`crown_group` always uses the crown slot — no need to specify `slot`.
Set `crown_group: null` to disable.
modes/cinematic/_archive/champion/template.html
<!doctype html>
<!--
  TEMPLATE: champion
  STYLE LOCKED. LAYOUT OPEN.

  Look: podcast / interview, upper-left column with intro→phrase→emph
  beats, big center-stage CROWN crossing the subject for the payoff.

  Locked:
    - 5 typography slots: intro / phrase / emph / dream / crown
    - mix-blend-mode: screen, color #fff5df, deep text-shadow
    - Single-caption-swap layout (caps absolute-positioned in left-plane)
    - GSAP soft/present per-word reveal

  Open via plan.json:
    - left-plane geometry + perspective
    - crown_plane top
    - --font-scale
    - GROUPS array + slot per group
    - whether crown is enabled (crown_group)
-->
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width={{WIDTH}}, height={{HEIGHT}}" />
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
    <style>
      :root { --font-scale: {{FONT_SCALE}}; }

      * { margin: 0; padding: 0; box-sizing: border-box; }
      html, body {
        margin: 0;
        width: {{WIDTH}}px; height: {{HEIGHT}}px;
        overflow: hidden;
        background: #000;
        font-family: "Inter", system-ui, sans-serif;
      }
      #a-roll { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }

      #stage {
        position: absolute;
        top: 0; left: 0;
        width: {{WIDTH}}px; height: {{HEIGHT}}px;
        perspective: 2400px;
        perspective-origin: 25% 50%;
        pointer-events: none;
        z-index: 2;
      }

      /* LAYOUT */
      .left-plane {
        position: absolute;
        top: {{PLANE_TOP}}px;
        left: {{PLANE_LEFT}}px;
        width: {{PLANE_WIDTH}}px;
        height: {{PLANE_HEIGHT}}px;
        transform-style: preserve-3d;
        transform: rotateY({{ROTATE_Y}}deg);
        transform-origin: left center;
        padding: 24px 36px;
      }
      .left-plane .cap {
        position: absolute;
        top: 24px;
        right: 36px;
        max-width: calc({{PLANE_WIDTH}}px - 72px);
      }
      .crown-plane {
        position: absolute;
        top: {{CROWN_TOP}}px;
        left: {{CROWN_LEFT}}px;
        right: {{CROWN_RIGHT}}px;
        text-align: {{CROWN_ALIGN}};
        pointer-events: none;
      }
      .crown-plane .cap-crown {
        font-size: calc(140px * var(--font-scale) * {{CROWN_SCALE}});
      }

      /* STYLE (locked) */
      .cap {
        display: block;
        position: relative;
        line-height: 1.02;
        opacity: 0;
        mix-blend-mode: {{BLEND_MODE}};
        color: {{CAP_COLOR}};
        text-shadow: {{TEXT_SHADOW}};
        filter: {{TEXT_FILTER}};
        will-change: transform, opacity;
      }
      .cap .w { display: inline-block; opacity: 0; will-change: opacity, transform; }

      .cap-intro  { font-size: calc(52px * var(--font-scale));
                    font-weight: 500; font-style: italic;
                    letter-spacing: -0.005em; }
      .cap-phrase { font-size: calc(60px * var(--font-scale));
                    font-weight: 600;
                    letter-spacing: -0.015em; }
      .cap-emph   { font-size: calc(70px * var(--font-scale));
                    font-weight: 800;
                    letter-spacing: -0.025em; }
      .cap-dream  { font-size: calc(64px * var(--font-scale));
                    font-weight: 700; font-style: italic;
                    letter-spacing: -0.015em; }
      .cap-crown  { font-size: calc(140px * var(--font-scale));
                    font-weight: 900;
                    letter-spacing: -0.035em; text-transform: uppercase;
                    line-height: 1.0; }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="{{DURATION}}"
      data-width="{{WIDTH}}"
      data-height="{{HEIGHT}}"
    >
      <video
        id="a-roll"
        src="source.mp4"
        muted
        playsinline
        data-duration="{{DURATION}}"
        data-track-index="0"
        style="z-index: 1"
      ></video>

      <div id="stage">
        <div class="left-plane">{{GROUPS_HTML}}</div>
        <div class="crown-plane">{{CROWN_HTML}}</div>
      </div>

      <audio
        id="a-roll-audio"
        src="source.mp4"
        data-start="0"
        data-duration="{{DURATION}}"
        data-track-index="3"
        data-volume="1"
      ></audio>
    </div>

    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });
      const DUR = {{DURATION}};
      const GROUPS = {{GROUPS_JSON}};
      const CROWN  = {{CROWN_JSON}};

      function animateGroup(g) {
        var sel = "#" + g.id;
        var isSoft = g.tone === "soft";
        tl.set(sel, { opacity: 1, y: 0 }, Math.max(0, g.in - 0.01));

        g.words.forEach(function (w, i) {
          var wsel = sel + " .w[data-i='" + i + "']";
          if (isSoft) {
            tl.fromTo(wsel,
              { opacity: 0, y: 8 },
              { opacity: 1, y: 0, duration: 0.42, ease: "power2.out", overwrite: "auto" },
              w.start);
          } else {
            tl.fromTo(wsel,
              { opacity: 0, y: 6, scale: 1.04 },
              { opacity: 1, y: 0, scale: 1.0, duration: 0.22,
                ease: "power3.out", overwrite: "auto", transformOrigin: "50% 50%" },
              w.start);
          }
        });

        if (g.out < DUR) {
          tl.to(sel, { opacity: 0, duration: 0.45, ease: "power2.in", overwrite: "auto" },
                g.out - 0.45);
        }
        tl.set(sel, { opacity: 0, visibility: "hidden" }, g.out);
      }

      GROUPS.forEach(animateGroup);
      if (CROWN) animateGroup(CROWN);

      tl.seek(0);
      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
modes/cinematic/_archive/memory-wall/spec.md
# Template: memory-wall

**Look:** introspective monologue, italic poem stacking on a back wall,
right-aligned cascade for the climax. Sam Harris / Pico Iyer territory.

**Visual identity (LOCKED — do not change):**

- 4-slot typography arc: italic 600 → italic 500 hanging-indent → upright
  700 pivot → uppercase 900 climax
- `mix-blend-mode: screen` over warm-bone color (#fff4dc)
- soft text-shadow + brightness boost
- Per-word fade with subtle Y-drift (soft tone) or pop+scale (present tone)

## When to apply

✅ **Good fit:**

- Talking-head with a flat back wall (acoustic foam, plain plaster, soft
  bookshelf) on one side
- Single subject, fixed or near-fixed camera
- Voice-over feel: introspective, narrative, "I was thinking…"
- Mid-tone or dark backdrop (luminance < 180)

❌ **Wrong fit:**

- Bright window/sky backdrop → screen blend blows out
- Cluttered backdrop with text/graphics → conflicts with caption text
- Energetic / vlog / TikTok style → wrong vibe
- Multiple speakers / hard cuts → caption arc breaks

## What agent decides per scene

| Field                          | What                                                                                                               | Example                                                            |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ |
| `plane.top/right/width/height` | Where the wall plane sits in frame. Plane should HUG the side of the wall, not float over the subject.             | `{top: 90, right: 30, width: 720, height: 520}` for 1280×720       |
| `plane.rotateY`                | Match the wall's perspective angle. Negative for right-side wall (text recedes left), positive for left-side wall. | `-13` for a right-side wall at slight angle                        |
| `plane.rotateX`                | Tiny vertical tilt if wall is angled up/down. Usually 0-2 deg.                                                     | `1`                                                                |
| `font_scale`                   | Multiplier on the locked cap-N base sizes. Tune for frame height.                                                  | `1.0` for 1280×720, `~1.5` for 1920×1080, `~0.7` for 720p portrait |
| `groups[].slot`                | Which of the 4 typography slots this group uses (1/2/3/4). Drives the arc.                                         | See below                                                          |
| `groups[].tone`                | "soft" or "present" — drives motion curve                                                                          | `"soft"` for opening lines, `"present"` for pivot+climax           |

## Slot assignment rules

Aim for ONE arc across the video: `1 → 2 → 3 → 4`. Don't repeat slots if you
can avoid it; don't skip slots without reason.

| Slot | Use for                                                                                                                                  |
| ---- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| 1    | The opener. The "I was thinking" line. Italic, soft entry.                                                                               |
| 2    | The dreamy continuation / modifier. Smaller italic, hanging-indent — feels like the second half of a sentence with some staggered drift. |
| 3    | The pivot. Upright (no italic), the syntactic turn. ("but suddenly", "and then…")                                                        |
| 4    | The climax. Big uppercase 900. The payoff line. Aim for ≤4 short words so the right-aligned cascade naturally wraps to 2-3 lines.        |

If the script is longer than 4 groups, repeat slot 1 or 2 for additional
soft lines, but reserve slot 4 for the SINGLE climax.

## Plan.json shape

```json
{
  "mode": "template",
  "template": "memory-wall",
  "duration": 8.04,
  "fps": 24,
  "width": 1280,
  "height": 720,
  "plane": {
    "top": 90,
    "right": 30,
    "width": 720,
    "height": 520,
    "rotateY": -13,
    "rotateX": 1
  },
  "font_scale": 1.0,
  "groups": [
    {
      "id": "cg-0",
      "slot": 1,
      "tone": "soft",
      "in": 0.2,
      "out": 4.85,
      "words": [
        { "text": "Some", "start": 0.24, "end": 0.44 },
        { "text": "memories", "start": 0.48, "end": 0.82 },
        { "text": "feel", "start": 0.92, "end": 1.14 },
        { "text": "soft", "start": 1.2, "end": 1.64 }
      ]
    },
    {
      "id": "cg-1",
      "slot": 2,
      "tone": "soft",
      "in": 2.55,
      "out": 4.85,
      "words": [
        { "text": "like", "start": 2.66, "end": 2.78 },
        { "text": "old", "start": 2.86, "end": 3.02 },
        { "text": "film", "start": 3.16, "end": 3.46 }
      ]
    },
    {
      "id": "cg-2",
      "slot": 3,
      "tone": "present",
      "in": 4.9,
      "out": 8.04,
      "words": [
        { "text": "but", "start": 5.04, "end": 5.18 },
        { "text": "suddenly", "start": 5.24, "end": 5.76 }
      ]
    },
    {
      "id": "cg-3",
      "slot": 4,
      "tone": "present",
      "in": 6.7,
      "out": 8.04,
      "words": [
        { "text": "everything", "start": 6.82, "end": 7.14 },
        { "text": "is", "start": 7.18, "end": 7.28 },
        { "text": "sharp", "start": 7.32, "end": 7.52 },
        { "text": "again", "start": 7.58, "end": 7.92 }
      ]
    }
  ]
}
```

## Don't fight the template

If the user wants different typography, different blend, or different motion:
**use Standard mode** (`../../standard/`). Don't try to override the locked CSS — the
template's visual consistency is the whole point.
modes/cinematic/_archive/memory-wall/template.html
<!doctype html>
<!--
  TEMPLATE: memory-wall
  STYLE LOCKED. LAYOUT OPEN.

  Locked (do not edit — overriding breaks visual consistency):
    - cap-1/2/3/4 typography (weights, italic, letter-spacing, padding-right)
    - mix-blend-mode: screen
    - color, text-shadow, filter
    - GSAP motion: container fade-in via tl.set, per-word opacity+y reveal,
      group fade-out 0.45s before g.out

  Open (agent fills via plan.json):
    - {{PLANE_TOP}} {{PLANE_RIGHT}} {{PLANE_WIDTH}} {{PLANE_HEIGHT}}
    - {{ROTATE_Y}} {{ROTATE_X}}  (perspective angle on the wall)
    - --font-scale CSS var (multiplies all cap-N sizes — agent tunes for frame)
    - GROUPS array (transcript groups, slot assignment 1/2/3/4, in/out/words)

  Style typography is keyed to "slots", semantic positions in the arc:
    cap-1: opener (italic, soft entry)
    cap-2: continuation (smaller italic, hanging indent — ragged poem feel)
    cap-3: pivot (upright, no italic, syntactic turn)
    cap-4: climax (UPPERCASE 900, the payoff line)
-->
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width={{WIDTH}}, height={{HEIGHT}}" />
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
    <style>
      :root { --font-scale: {{FONT_SCALE}}; }

      * { margin: 0; padding: 0; box-sizing: border-box; }
      html, body {
        margin: 0;
        width: {{WIDTH}}px; height: {{HEIGHT}}px;
        overflow: hidden;
        background: #000;
        font-family: "Inter", system-ui, sans-serif;
      }

      #a-roll {
        position: absolute; inset: 0;
        width: 100%; height: 100%;
        object-fit: cover;
      }

      #stage {
        position: absolute;
        top: 0; left: 0;
        width: {{WIDTH}}px; height: {{HEIGHT}}px;
        perspective: 1400px;
        perspective-origin: 30% 40%;
        pointer-events: none;
        z-index: 2;
      }

      /* LAYOUT (agent-tunable via plan.json) */
      .wall-plane {
        position: absolute;
        top: {{PLANE_TOP}}px;
        right: {{PLANE_RIGHT}}px;
        width: {{PLANE_WIDTH}}px;
        height: {{PLANE_HEIGHT}}px;
        transform-style: preserve-3d;
        transform: rotateY({{ROTATE_Y}}deg) rotateX({{ROTATE_X}}deg);
        transform-origin: right center;
        padding: 24px 32px;
        display: flex;
        flex-direction: column;
        justify-content: center;
        align-items: flex-end;
        text-align: right;
        gap: 14px;
      }

      /* STYLE (locked) */
      .cap {
        display: block;
        position: relative;
        line-height: 1.05;
        opacity: 0;
        mix-blend-mode: {{BLEND_MODE}};
        color: {{CAP_COLOR}};
        text-shadow: {{TEXT_SHADOW}};
        filter: {{TEXT_FILTER}};
        will-change: transform, opacity;
      }
      .cap .w { display: inline-block; opacity: 0; will-change: opacity, transform; }

      .cap-1 { font-size: calc(78px * var(--font-scale));
               font-weight: 600; font-style: italic;
               letter-spacing: -0.01em; }
      .cap-2 { font-size: calc(66px * var(--font-scale));
               font-weight: 500; font-style: italic;
               letter-spacing: -0.005em;
               padding-right: calc(44px * var(--font-scale)); }
      .cap-3 { font-size: calc(72px * var(--font-scale));
               font-weight: 700;
               letter-spacing: -0.015em; }
      .cap-4 { font-size: calc(90px * var(--font-scale));
               font-weight: 900;
               letter-spacing: -0.03em;
               text-transform: uppercase;
               line-height: 1.0; }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="{{DURATION}}"
      data-width="{{WIDTH}}"
      data-height="{{HEIGHT}}"
    >
      <video
        id="a-roll"
        src="source.mp4"
        muted
        playsinline
        data-duration="{{DURATION}}"
        data-track-index="0"
        style="z-index: 1"
      ></video>

      <div id="stage">
        <div class="wall-plane">{{GROUPS_HTML}}</div>
      </div>

      <audio
        id="a-roll-audio"
        src="source.mp4"
        data-start="0"
        data-duration="{{DURATION}}"
        data-track-index="3"
        data-volume="1"
      ></audio>
    </div>

    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });
      const DUR = {{DURATION}};
      const GROUPS = {{GROUPS_JSON}};

      GROUPS.forEach(function (g) {
        var sel = "#" + g.id;
        var isSoft = g.tone === "soft";
        tl.set(sel, { opacity: 1, y: 0 }, Math.max(0, g.in - 0.01));

        g.words.forEach(function (w, i) {
          var wsel = sel + " .w[data-i='" + i + "']";
          if (isSoft) {
            tl.fromTo(wsel,
              { opacity: 0, y: 8 },
              { opacity: 1, y: 0, duration: 0.45, ease: "power2.out", overwrite: "auto" },
              w.start);
          } else {
            tl.fromTo(wsel,
              { opacity: 0, y: 6, scale: 1.04 },
              { opacity: 1, y: 0, scale: 1.0, duration: 0.22,
                ease: "power3.out", overwrite: "auto", transformOrigin: "100% 50%" },
              w.start);
          }
        });

        if (g.out < DUR) {
          tl.to(sel, { opacity: 0, duration: 0.5, ease: "power2.in", overwrite: "auto" },
                g.out - 0.5);
        }
        tl.set(sel, { opacity: 0, visibility: "hidden" }, g.out);
      });

      tl.seek(0);
      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
modes/cinematic/_archive/portrait-header/spec.md
# Template: portrait-header

**Look:** 9:16 portrait talking-head with captions on a horizontal strip
across the top of the frame, above the subject's head. Optional crown at
the bottom for shots that include the subject's waist or below.

**Visual identity (LOCKED):**

- 5 typography slots: intro / phrase / emph / dream / crown (centered)
- `mix-blend-mode: screen` over warm bone color (#fff5df)
- Single-caption swap (caps stack at one position in the header strip)
- Per-word fade with subtle Y-drift (soft) or pop+scale (present)

## When to apply

✅ **Good fit:**

- 9:16 portrait video (1080×1920 typical)
- Single subject filling the center of the frame
- Clean band visible above the head
- Speech is relatively dense (many short phrases) — crown only for the
  one true climax

❌ **Wrong fit:**

- Subject's head crops to the very top of frame (no header band exists)
- 16:9 landscape → use `champion` or `memory-wall` instead
- Multiple speakers / cuts

## Layout decisions

| Field           | What                                                                                                                    | Example (1080×1920)                  |
| --------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
| `header.top`    | Y of the header strip. Should sit above the subject's head with ~30px breathing room.                                   | `30` if hair top is around y=200     |
| `header.height` | Height of the strip. Bigger for longer captions / 2-line wraps.                                                         | `280`                                |
| `crown_top`     | Y of the crown line (bottom of frame). Skip if no waist visible.                                                        | `1620` for a near-full-body shot     |
| `crown_enabled` | Whether to render a bottom crown. Skip if subject body fills the lower half completely (matte will block crown anyway). | `false` for tight head-and-shoulders |
| `font_scale`    | Scale multiplier on locked sizes.                                                                                       | `1.0` for 1080×1920                  |

## Slot assignment

Same arc as champion: intro / phrase / emph / dream / crown.

For portrait-header specifically, header band is narrow (one strip), so:

- Most groups fit on 1-2 lines
- Don't use crown if it'd be hidden by the body matte (Pausch-style portraits
  often need to skip crown entirely and promote the climax to `emph` in the
  header instead)

## Plan.json shape

```json
{
  "mode": "template",
  "template": "portrait-header",
  "duration": 25.3, "fps": 24, "width": 1080, "height": 1920,
  "header": { "top": 30, "height": 280 },
  "crown_top": 1620,
  "font_scale": 1.0,
  "groups": [
    { "id": "cg-0", "slot": "intro", "tone": "soft",
      "in": 0.10, "out": 3.20, "words": [...] },
    { "id": "cg-1", "slot": "phrase", "tone": "soft",
      "in": 3.55, "out": 5.80, "words": [...] },
    { "id": "cg-2", "slot": "emph", "tone": "present",
      "in": 17.85, "out": 22.10,
      "words": [
        {"text": "Time", "start": 17.94, "end": 18.26},
        {"text": "is",   "start": 18.38, "end": 18.54},
        {"text": "all",  "start": 18.66, "end": 18.80},
        {"text": "we",   "start": 18.92, "end": 19.02},
        {"text": "have", "start": 19.12, "end": 19.38}
      ]}
  ],
  "crown_group": null
}
```

Set `crown_group: null` if you've checked that a crown would be blocked by
the subject body or the shot composition.
modes/cinematic/_archive/portrait-header/template.html
<!doctype html>
<!--
  TEMPLATE: portrait-header
  STYLE LOCKED. LAYOUT OPEN.

  Look: 9:16 portrait talking-head, captions on a horizontal strip across
  the top above the subject. Optional crown at bottom if waist+ visible.

  Locked: 5 typography slots (intro/phrase/emph/dream/crown), screen blend,
          single-caption swap (caps absolute-positioned in header strip).

  Open via plan.json: header strip top/height, crown enable+top, font_scale.
-->
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width={{WIDTH}}, height={{HEIGHT}}" />
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
    <style>
      :root { --font-scale: {{FONT_SCALE}}; }

      * { margin: 0; padding: 0; box-sizing: border-box; }
      html, body {
        margin: 0;
        width: {{WIDTH}}px; height: {{HEIGHT}}px;
        overflow: hidden;
        background: #000;
        font-family: "Inter", system-ui, sans-serif;
      }
      #a-roll { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
      #stage {
        position: absolute;
        top: 0; left: 0;
        width: {{WIDTH}}px; height: {{HEIGHT}}px;
        pointer-events: none;
        z-index: 2;
      }

      /* LAYOUT */
      .header-plane {
        position: absolute;
        top: {{HEADER_TOP}}px;
        left: 0; right: 0;
        height: {{HEADER_HEIGHT}}px;
        padding: 24px 40px;
      }
      .header-plane .cap {
        position: absolute;
        top: 24px;
        left: 40px; right: 40px;
        max-width: calc(100% - 80px);
        text-align: center;
      }
      .crown-plane {
        position: absolute;
        top: {{CROWN_TOP}}px;
        left: 0; right: 0;
        text-align: center;
        pointer-events: none;
      }

      /* STYLE (locked) */
      .cap {
        display: block;
        position: relative;
        line-height: 1.05;
        opacity: 0;
        mix-blend-mode: {{BLEND_MODE}};
        color: {{CAP_COLOR}};
        text-shadow: {{TEXT_SHADOW}};
        filter: {{TEXT_FILTER}};
        will-change: transform, opacity;
      }
      .cap .w { display: inline-block; opacity: 0; will-change: opacity, transform; }

      .cap-intro  { font-size: calc(72px * var(--font-scale));
                    font-weight: 500; font-style: italic;
                    letter-spacing: -0.005em; line-height: 1.1; }
      .cap-phrase { font-size: calc(88px * var(--font-scale));
                    font-weight: 600;
                    letter-spacing: -0.015em; line-height: 1.05; }
      .cap-emph   { font-size: calc(104px * var(--font-scale));
                    font-weight: 800;
                    letter-spacing: -0.025em; line-height: 1.0; }
      .cap-dream  { font-size: calc(92px * var(--font-scale));
                    font-weight: 700; font-style: italic;
                    letter-spacing: -0.015em; line-height: 1.05; }
      .cap-crown  { font-size: calc(130px * var(--font-scale));
                    font-weight: 900;
                    letter-spacing: -0.035em; text-transform: uppercase;
                    line-height: 1.0; }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="{{DURATION}}"
      data-width="{{WIDTH}}"
      data-height="{{HEIGHT}}"
    >
      <video
        id="a-roll"
        src="source.mp4"
        muted
        playsinline
        data-duration="{{DURATION}}"
        data-track-index="0"
        style="z-index: 1"
      ></video>

      <div id="stage">
        <div class="header-plane">{{GROUPS_HTML}}</div>
        <div class="crown-plane">{{CROWN_HTML}}</div>
      </div>

      <audio
        id="a-roll-audio"
        src="source.mp4"
        data-start="0"
        data-duration="{{DURATION}}"
        data-track-index="3"
        data-volume="1"
      ></audio>
    </div>

    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });
      const DUR = {{DURATION}};
      const GROUPS = {{GROUPS_JSON}};
      const CROWN  = {{CROWN_JSON}};

      function animateGroup(g) {
        var sel = "#" + g.id;
        var isSoft = g.tone === "soft";
        tl.set(sel, { opacity: 1, y: 0 }, Math.max(0, g.in - 0.01));

        g.words.forEach(function (w, i) {
          var wsel = sel + " .w[data-i='" + i + "']";
          if (isSoft) {
            tl.fromTo(wsel,
              { opacity: 0, y: 8 },
              { opacity: 1, y: 0, duration: 0.42, ease: "power2.out", overwrite: "auto" },
              w.start);
          } else {
            tl.fromTo(wsel,
              { opacity: 0, y: 6, scale: 1.04 },
              { opacity: 1, y: 0, scale: 1.0, duration: 0.22,
                ease: "power3.out", overwrite: "auto", transformOrigin: "50% 50%" },
              w.start);
          }
        });

        if (g.out < DUR) {
          tl.to(sel, { opacity: 0, duration: 0.45, ease: "power2.in", overwrite: "auto" },
                g.out - 0.45);
        }
        tl.set(sel, { opacity: 0, visibility: "hidden" }, g.out);
      }

      GROUPS.forEach(animateGroup);
      if (CROWN) animateGroup(CROWN);

      tl.seek(0);
      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
modes/cinematic/cinematic-cream/spec.md
# cinematic-cream — spec

The authoritative spec lives in [template.html](template.html)'s header comment (DNA-only:
what's LOCKED vs OPEN, the LAYOUT/planes contract, READING ORDER + hand-off, hero sizing).
Read that header before authoring plan.json. Quick contract:

- LOCKED: Inter, soft/present motion, warm-cream palette, screen blend, shadow logic.
- OPEN per group (css field): size/weight/style/transform/spacing + position WITHIN a plane.
- LAYOUT: run scripts/safe-zones.cjs first. Narration planes HUG the silhouette — use
  zones.hugLeft/hugRight (2-6% gap from the subject's edge); a plane parked in the far
  corner reads as disconnected floating text, not an embed. The ONE hero stays BIG
  (~0.22-0.34·h) centered ON the subject (heroAnchor / heroBands.best), target ~30-55%
  occlusion. fg is the LAST resort: only when heroBands.feasible=false (no band ≤62%).
  hero BIG (~0.22-0.34·h) centered ON the subject (heroAnchor), target ~30-55% occlusion.
- plan.json: groups[] with per-word transcript timings (use scripts/fill-timings.cjs),
  fit via scripts/fit-fonts.cjs, compile via scripts/make-composition.cjs.
modes/cinematic/cinematic-cream/template.html
<!doctype html>
<!--
  TEMPLATE: cinematic-cream
  DNA-ONLY template. No slot structure. No preset typography classes.
  The agent composes each caption group's typography per-scene via the
  plan.json `css` field.

  What's LOCKED (DNA — do not override):
    - font-family: Inter (+ fallback)
    - Motion vocabulary: per-word soft/present reveal curves from the
       validated memory-wall timeline (tone: "soft" | "present")
    - Color palette defaults (cream on dark scenes, deep warm on light)
    - mix-blend-mode defaults, text-shadow / filter auto-picked from
       cap_color luminance (via make-composition.py)
    - Animation: 0.7s container fade-in (at g.in-0.01 via tl.set), per-word
       reveal at w.start, group fade-out 0.5s before g.out

  What's OPEN per group (agent decides per scene via `css` field):
    - font-size, font-weight, font-style (italic or not), text-transform
       (uppercase or not), letter-spacing, line-height
    - Position: top / left / right / bottom / transform / text-align
    - Layer: bg (embed via matte) | fg (announcement on top)
    - Tone: soft | present (motion curve)
    - scale: optional shortcut for size multiplication

  LAYOUT — FIRST run the safe-zone precompute, THEN compose into PLANES:
    - Run `node scripts/safe-zones.cjs <project>` and read `safe-zones.json`. From the
       matte it gives subject coverage%, the clearer side, the largest CLEAN rectangles
       (left/right/top), and a verdict:
         · "fg"   → subject fills the frame; NO room to embed behind it. Use
            `caption_layer:"fg"` (screen-blend on top). Do NOT fight it with bg embeds —
            that's the 9:16 case that costs many failed occlusion rounds.
         · "embed"→ NARRATION planes go INSIDE the reported clean zones (prefer the clearer
            side / top). The HERO is the EXCEPTION — see the next bullet. Don't guess
            narration positions — use the zones safe-zones gives you.
    - NARRATION: define 2-4 named planes in plan.json `planes {}`, each inside a clean zone
       from safe-zones.json. Give each narration group a `plane`; groups STACK within it (a
       plane is a flex column → lines accumulate, aligned + coherent). Narration must be CLEAR.
    - THE HERO (the one promoted peak word) is the OPPOSITE of narration — BIG, and ON the
       subject. It is the scarce, earned PEAK; it must feel like it BURSTS, not a polite label:
         · BIG + dominant — **~0.22–0.34 × frame height**. Give it a WIDE plane (≈ centered,
            spanning the subject), NEVER a narrow margin column. (fit-fonts won't enlarge — if
            the hero's box is narrow it stays small; widen the box.)
         · CENTERED on the subject (use safe-zones `heroAnchor` = the subject's center),
            CROSSING it so the head/torso occludes the MIDDLE — **target ~30–55% occlusion**.
            That occlusion IS the effect. A hero shoved into a clean margin (~10% graze) is
            WRONG — it reads as a small floating word, not an embed behind the subject.
         · Exactly ONE hero per clip (scarcity = impact). Mark it `"hero": true` in the plan.
    - Do NOT hand-position every group with its own scattered absolute `top%` — that
       produces the unbalanced, occluded layouts the gate rejects (many re-tries). Planes
       first; per-group `css` only fine-tunes WITHIN a plane. (Position fields above apply
       per-group only when a group is NOT in a plane — the exception, not the default.)

  READING ORDER — on-screen position follows SPOKEN order; a promoted word appears once:
    - For groups sharing a column/region, vertical (top→bottom) order MUST match spoken
       order. A promoted hero must NOT sit below words that come after it.
       (e.g. "judge us by the [ACTIONS] that we take" → ACTIONS reads BETWEEN "by the" and
       "that we take", NEVER below "that we take".)
    - The promoted word is lifted OUT of the narration (appears once, as the hero — never
       also in a narration line). For the words AFTER it, two options, in order:
         1. PREFERRED — flow them directly BELOW the hero (in reading order) or merge them
            into the hero's group, IF that area is clean (check safe-zones). One continuous
            read, no clearing.
         2. FALLBACK — if below/merge would land on the subject (occluded), do the Standard
            hand-off: the pre-hero narration CLEARS, then the post-hero words appear fresh
            (page-flip); the hero may hold across the flip.
       Prefer promoting a CLAUSE-FINAL word when you can — then nothing follows to place.

  Philosophy:
    - Number of groups = content decides (3? 7? 12? whatever the speech
       warrants)
    - Each group's typography = scene decides (italic for soft moments,
       uppercase for punch, mixed case for narration — per group)
    - No more "cap-1 must be italic 600" rules. Those were slots.
    - The agent reads the scene + transcript and composes. The template
       only guarantees the DNA feels coherent (Inter family, same motion
       library, warm-cream or deep-warm palette, same shadow logic).

  See `references/typographic-moves.md` for RECOMMENDED typography
  combinations (NOT rules) — italic 600 for openers, uppercase 900 for
  climaxes, etc. These are suggestions the agent can pick from or deviate.
-->
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width={{WIDTH}}, height={{HEIGHT}}" />
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
    <style>
      :root { --font-scale: {{FONT_SCALE}}; --h: {{HEIGHT}}px; --w: {{WIDTH}}px; }

      * { margin: 0; padding: 0; box-sizing: border-box; }
      html, body {
        margin: 0;
        width: {{WIDTH}}px; height: {{HEIGHT}}px;
        overflow: hidden;
        background: #000;
        font-family: "Inter", "Helvetica Neue", system-ui, sans-serif;
      }
      #a-roll { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
      #stage {
        position: absolute;
        top: 0; left: 0;
        width: {{WIDTH}}px; height: {{HEIGHT}}px;
        pointer-events: none;
        z-index: 2;
      }

      /* BASE — everything shared across groups */
      .cap {
        display: block;
        position: absolute;
        opacity: 0;
        mix-blend-mode: {{BLEND_MODE}};
        color: {{CAP_COLOR}};
        text-shadow: {{TEXT_SHADOW}};
        filter: {{TEXT_FILTER}};
        line-height: 1.0;
        will-change: transform, opacity;
        /* Free-mode defaults (when cap is a direct child of #stage):
           per-group `top:` positions the element's TOP edge from the
           frame top. Agent writes top/left/right per group.  */
        top: 50%; left: 0; right: 0; text-align: center;
      }
      .cap .w { display: inline-block; opacity: 0; will-change: opacity, transform; }

      /* Plane mode — when .cap is nested inside .plane, switch to flow
         layout so the plane's flex/grid can own the stacking. Agent writes
         typography only per group; positions are delegated to the plane.
         This is the memory-wall / champion architecture: a shared parent
         container handles spatial coherence, caps inside don't fight for
         top% values. */
      .plane { position: absolute; pointer-events: none; }
      .plane > .cap {
        position: relative;
        top: auto; left: auto; right: auto; bottom: auto;
      }

      {{PLANES_CSS}}

      /* Per-group typography (agent-authored). */
      {{CUSTOM_CSS}}
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="{{DURATION}}"
      data-width="{{WIDTH}}"
      data-height="{{HEIGHT}}"
    >
      <video
        id="a-roll"
        src="source.mp4"
        muted
        playsinline
        data-duration="{{DURATION}}"
        data-track-index="0"
        style="z-index: 1"
      ></video>

      <div id="stage">{{GROUPS_HTML}}</div>

      <audio
        id="a-roll-audio"
        src="source.mp4"
        data-start="0"
        data-duration="{{DURATION}}"
        data-track-index="3"
        data-volume="1"
      ></audio>
    </div>

    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });
      const DUR = {{DURATION}};
      const GROUPS = {{GROUPS_JSON}};

      /* Motion vocabulary — LOCKED.
         soft:    gentle fade with 8px y-drift, 0.45s power2.out
         present: snappier pop with 6px y + 1.04 scale, 0.22s power3.out
         Both target per-WORD spans. Container fade-in via tl.set at g.in-0.01. */
      GROUPS.forEach(function (g) {
        var sel = "#" + g.id;
        var isSoft = g.tone === "soft";

        // Enter: reveal opacity at g.in-0.01. The cap has been reserving flex
        // space all along (opacity: 0 but display: block from CSS), so no
        // layout reflow when it fades in — matches memory-wall canonical.
        tl.set(sel, { opacity: 1 }, Math.max(0, g.in - 0.01));

        g.words.forEach(function (w, i) {
          var wsel = sel + " .w[data-i='" + i + "']";
          if (isSoft) {
            tl.fromTo(wsel,
              { opacity: 0, y: 8 },
              { opacity: 1, y: 0, duration: 0.45, ease: "power2.out", overwrite: "auto" },
              w.start);
          } else {
            tl.fromTo(wsel,
              { opacity: 0, y: 6, scale: 1.04 },
              { opacity: 1, y: 0, scale: 1.0, duration: 0.22,
                ease: "power3.out", overwrite: "auto", transformOrigin: "50% 50%" },
              w.start);
          }
        });

        if (g.out < DUR) {
          tl.to(sel, { opacity: 0, duration: 0.5, ease: "power2.in", overwrite: "auto" },
                g.out - 0.5);
        }
        // Exit: display:none releases flex space so the next sentence-block's
        // caps can take the top slot. All caps in ONE block share the same
        // `out` so this fires synchronously for the block — one clean reflow
        // at the boundary, no per-cap jitter. Plan must share `out` within a
        // block; staggered per-cap exits are forbidden in plane mode.
        tl.set(sel, { opacity: 0, display: "none" }, g.out);
      });

      tl.seek(0);
      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
modes/cinematic/engine.html
<!doctype html>
<!--
  ENGINE — the one Cinematic-mode shell, parameterized by a DNA (dna/<name>.json).
  Replaces per-template HTML files: the DNA supplies type / palette / blend / motion
  grammar / hero orchestration; safe-zones v2 supplies scene tokens (accent sampling,
  contact-shadow direction, depth-match blur); the plan supplies layout + per-group
  typography. Compiled by make-composition.cjs.

  Contract (unchanged from cinematic-cream — gates depend on it):
    #root[data-composition-id="main"] · #a-roll · #stage · #a-roll-audio
    .plane > .cap > .w spans · paused seek-safe GSAP on window.__timelines["main"]
    container fade via tl.set at g.in−0.01 · exit fade + display:none at g.out
    no Math.random / Date.now / CSS keyframes / repeat:-1

  New in the engine:
    - MOTION grammar from the DNA: soft / present / impact tones (+ "burn" reveal)
    - HERO three-act orchestration:
        act 1 SETUP   — co-visible groups dim 0.35s before the hero lands
        act 2 IMPACT  — entrance (emergence | settle | slam | rise) with per-letter
                        stagger; amplitude coupled to the spoken word's RMS loudness
        act 3 AFTERGLOW — slow breathe scale + glow layer (DNA-gated) until exit
    - depth-match blur on embed (bg) captions only; fg stays sharp
    - scene-sampled --accent custom property available to per-group CSS
-->
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width={{WIDTH}}, height={{HEIGHT}}" />
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
    <style>
      :root {
        --font-scale: {{FONT_SCALE}};
        --h: {{HEIGHT}}px; --w: {{WIDTH}}px;
        --accent: {{ACCENT}};
      }

      * { margin: 0; padding: 0; box-sizing: border-box; }
      html, body {
        margin: 0;
        width: {{WIDTH}}px; height: {{HEIGHT}}px;
        overflow: hidden;
        background: #000;
        font-family: '{{FONT_FAMILY}}', "Helvetica Neue", system-ui, sans-serif;
      }
      #a-roll { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
      #stage {
        position: absolute;
        top: 0; left: 0;
        width: {{WIDTH}}px; height: {{HEIGHT}}px;
        pointer-events: none;
        z-index: 2;
      }

      /* BASE — DNA tokens. bg (embed) captions get the depth-match filter; fg stays sharp. */
      .cap {
        display: block;
        position: absolute;
        opacity: 0;
        mix-blend-mode: {{BLEND_MODE}};
        color: {{CAP_COLOR}};
        text-shadow: {{TEXT_SHADOW}};
        filter: {{TEXT_FILTER_BG}};
        line-height: 1.12; /* contains ink + internal leading — wrapped rows breathe (was 1.0: rows touched) */
        will-change: transform, opacity;
        top: 50%; left: 0; right: 0; text-align: center;
      }
      .cap[data-layer="fg"] { filter: {{TEXT_FILTER_FG}}; }
      .cap .w { display: inline-block; opacity: 0; will-change: opacity, transform; }
      .cap .w .l { display: inline-block; will-change: opacity, transform; }

      /* hero glow — same glyphs duplicated UNDER the hero, blurred; opacity driven by GSAP.
         em-based blur scales with the hero's font size. Not part of plan.groups → gates skip it.
         MUST be out of flow (absolute) — as a flow sibling inside a plane it displaces the hero. */
      .hero-glow, .plane > .cap.hero-glow { position: absolute; left: 0; right: 0; top: 0; filter: blur(0.06em); }
      /* hero echoes — colored duplicates (RGB fringe / speed trails); position mirrors the
         hero via its #id rule, color comes from each echo's own rule */
      .hero-echo, .plane > .cap.hero-echo { position: absolute; left: 0; right: 0; top: 0; }

      .plane { position: absolute; pointer-events: none; }
      .plane > .cap {
        position: relative;
        top: auto; left: auto; right: auto; bottom: auto;
      }

      {{DNA_CSS}}

      {{PLANES_CSS}}

      /* Per-group typography (agent-authored). */
      {{CUSTOM_CSS}}
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="{{DURATION}}"
      data-width="{{WIDTH}}"
      data-height="{{HEIGHT}}"
    >
      <video
        id="a-roll"
        src="source.mp4"
        muted
        playsinline
        data-duration="{{DURATION}}"
        data-track-index="0"
        style="z-index: 1"
      ></video>

      <div id="stage">{{GROUPS_HTML}}</div>

      <audio
        id="a-roll-audio"
        src="source.mp4"
        data-start="0"
        data-duration="{{DURATION}}"
        data-track-index="3"
        data-volume="1"
      ></audio>
    </div>

    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });
      const DUR = {{DURATION}};
      const GROUPS = {{GROUPS_JSON}};
      const MOTION = {{MOTION_JSON}};
      const HEROES = {{HEROES_JSON}};
      const HERO_IDS = {};
      HEROES.forEach(function (h) { HERO_IDS[h.id] = true; });

      /* ── word entrance: the DNA's motion grammar ─────────────────────────
         Each tone = {y, x, blur, scale, rot, dur, ease} or {reveal:"burn"}.
         rot alternates sign by word index (deterministic percussion); blur is a
         filter tween on the word span only (the container's depth-match filter
         is a separate element — no conflict, no reflow). */
      function wordReveal(wsel, m, at, idx) {
        if (m.reveal === "burn") { tl.set(wsel, { opacity: 1 }, at); return; }
        if (m.reveal === "flicker") {
          /* neon tube igniting — deterministic on/off pattern, phase-shifted by word
             index so a line of words doesn't strobe in unison */
          var o = (idx % 3) * 0.018;
          tl.set(wsel, { opacity: 1 }, at);
          tl.set(wsel, { opacity: 0.25 }, at + 0.042 + o);
          tl.set(wsel, { opacity: 1 }, at + 0.078 + o);
          tl.set(wsel, { opacity: 0.5 }, at + 0.112 + o);
          tl.set(wsel, { opacity: 1 }, at + 0.152 + o);
          return;
        }
        var from = { opacity: 0 }, to = { opacity: 1, duration: m.dur || 0.4,
          ease: m.ease || "power2.out", overwrite: "auto", transformOrigin: "50% 70%" };
        if (m.y) { from.y = m.y; to.y = 0; }
        if (m.x) { from.x = m.x; to.x = 0; }
        if (m.scale && m.scale !== 1) { from.scale = m.scale; to.scale = 1; }
        if (m.stretchX && m.stretchX !== 1) { from.scaleX = m.stretchX; to.scaleX = 1; }
        if (m.skewX) { from.skewX = m.skewX; to.skewX = 0; }
        if (m.rot) { from.rotation = ((idx % 2) ? 1 : -1) * m.rot; to.rotation = 0; }
        if (m.blur) { from.filter = "blur(" + m.blur + "px)"; to.filter = "blur(0px)"; }
        tl.fromTo(wsel, from, to, at);
      }

      var EXIT = MOTION.exit || { dur: 0.5, ease: "power2.in", stagger: 0 };
      var LINE_REVEAL = MOTION.lineReveal || null;

      /* exit cascade: lines of the same plane sharing one page-flip `out` can leave
         top-to-bottom (editorial). Slot layout = absolute positions, so staggered
         exits cause no reflow. Every exit still hard-kills at g.out. */
      var pageMates = {};
      GROUPS.forEach(function (g) {
        if (HERO_IDS[g.id]) return;
        var key = (g.plane || "free") + "@" + g.out;
        (pageMates[key] = pageMates[key] || []).push(g.id);
      });

      GROUPS.forEach(function (g) {
        if (HERO_IDS[g.id]) return; // heroes are orchestrated below
        var sel = "#" + g.id;
        var m = MOTION[g.tone] || MOTION.soft || {};

        tl.set(sel, { opacity: 1 }, Math.max(0, g.in - 0.01));

        /* line container reveal (keynote's surgical wipe) — clip-path, not transform.
           PHRASE mode: the whole line is set visible up front and the WIPE is the
           reveal (slide-title idiom) — wiping an empty container reads as nothing. */
        if (LINE_REVEAL && LINE_REVEAL.type === "wipe-x") {
          tl.set(sel + " .w", { opacity: 1 }, Math.max(0, g.in - 0.01));
          tl.fromTo(sel, { clipPath: "inset(0% 100% 0% 0%)" },
            { clipPath: "inset(0% 0% 0% 0%)", duration: LINE_REVEAL.dur || 0.4,
              ease: LINE_REVEAL.ease || "expo.out", overwrite: "auto" },
            Math.max(0, g.in - 0.01));
        } else {
          g.words.forEach(function (w, i) {
            wordReveal(sel + " .w[data-i='" + i + "']", m, w.start, i);
          });
        }

        /* exit: DNA grammar — direction + speed + optional per-line cascade */
        var mates = pageMates[(g.plane || "free") + "@" + g.out];
        var rank = mates.indexOf(g.id);
        var lead = (EXIT.stagger || 0) * (mates.length - 1 - rank);
        var exitAt = Math.max(g.in + 0.1, g.out - (EXIT.dur || 0.5) - lead);
        var to = { opacity: 0, duration: EXIT.dur || 0.5, ease: EXIT.ease || "power2.in", overwrite: "auto" };
        if (EXIT.y) to.y = EXIT.y;
        if (EXIT.x) to.x = EXIT.x;
        if (EXIT.scale && EXIT.scale !== 1) to.scale = EXIT.scale;
        if (g.out < DUR) tl.to(sel, to, exitAt);
        tl.set(sel, { opacity: 0, display: "none" }, g.out);
      });

      /* ── HEROES: three-act orchestration, one per promoted beat ──────────── */
      HEROES.forEach(function (HERO) {
        var hsel = "#" + HERO.id;
        var gsel = "#" + HERO.id + "-glow";
        var hg = GROUPS.filter(function (g) { return g.id === HERO.id; })[0];
        var amp = HERO.amp || 1;

        /* act 1 — SETUP: co-visible groups yield (dim) just before the hero lands.
           Minor heroes are emphasis-plus: no dim, no ripple, no breathe — the apex
           keeps those privileges (that's what makes it the apex). */
        if (!HERO.minor && HERO.dimOthers < 1) {
          GROUPS.forEach(function (g) {
            if (g.id === HERO.id) return;
            if (g.in < HERO.in && g.out > HERO.in) {
              tl.to("#" + g.id, { opacity: HERO.dimOthers, duration: 0.3, ease: "power2.out", overwrite: "auto" },
                    Math.max(0, HERO.in - 0.35));
            }
          });
        }

        tl.set(hsel, { opacity: 1 }, Math.max(0, HERO.in - 0.01));

        /* act 2 — IMPACT: container entrance (amplitude ∝ spoken loudness) */
        if (HERO.entrance === "settle") {
          tl.fromTo(hsel, { scale: 1 + 0.10 * amp },
            { scale: 1, duration: 0.50, ease: "expo.out", transformOrigin: "50% 60%", overwrite: "auto" }, HERO.in);
        } else if (HERO.entrance === "slam") {
          /* percussion: ONE unit, violent snap — geometry locks in 0.16s; heat comes after */
          tl.fromTo(hsel, { scale: 1 + 0.22 * amp },
            { scale: 1, duration: 0.16, ease: "back.out(2.2)", transformOrigin: "50% 55%", overwrite: "auto" }, HERO.in);
        } else if (HERO.entrance === "rise") {
          tl.fromTo(hsel, { y: 22 * amp },
            { y: 0, duration: 0.60, ease: "power2.out", overwrite: "auto" }, HERO.in);
        } else if (HERO.entrance === "wipe-up") {
          /* surgical slide-title reveal — clip-path only (not a transform: can't
             collide with the afterglow scale channel), bottom-up behind the subject */
          tl.fromTo(hsel, { clipPath: "inset(100% 0% 0% 0%)" },
            { clipPath: "inset(0% 0% 0% 0%)", duration: 0.45, ease: "expo.out", overwrite: "auto" }, HERO.in);
        } else if (HERO.entrance === "flicker-on") {
          /* neon sign powering up — deterministic strobe, then steady */
          tl.set(hsel, { opacity: 1 }, HERO.in);
          tl.set(hsel, { opacity: 0.15 }, HERO.in + 0.05);
          tl.set(hsel, { opacity: 1 }, HERO.in + 0.095);
          tl.set(hsel, { opacity: 0.4 }, HERO.in + 0.14);
          tl.set(hsel, { opacity: 1 }, HERO.in + 0.19);
          tl.fromTo(hsel, { scale: 1.03 }, { scale: 1, duration: 0.4, ease: "power2.out", overwrite: "auto", transformOrigin: "50% 55%" }, HERO.in + 0.19);
        } else if (HERO.entrance === "streak") {
          /* speed pass — slides in along its own motion blur (skew rides the velocity) */
          tl.fromTo(hsel, { x: -90 * amp, skewX: -12, opacity: 0 },
            { x: 0, skewX: 0, opacity: 1, duration: 0.28, ease: "expo.out", overwrite: "auto", transformOrigin: "50% 80%" }, HERO.in);
        } else { /* emergence — grows out from behind the subject */
          tl.fromTo(hsel, { scale: Math.max(0.74, 1 - 0.18 * amp) },
            { scale: 1, duration: 0.55, ease: "expo.out", transformOrigin: "50% 62%", overwrite: "auto" }, HERO.in);
        }

        /* impact ripple (loud): the hero's landing bumps every co-visible caption —
           captions only, the footage itself NEVER moves */
        if (!HERO.minor && HERO.ripple > 0) {
          GROUPS.forEach(function (g) {
            if (g.id === HERO.id) return;
            if (g.in < HERO.in && g.out > HERO.in + 0.3) {
              tl.to("#" + g.id, { y: HERO.ripple, duration: 0.07, ease: "power2.out", overwrite: "auto" }, HERO.in + 0.04);
              tl.to("#" + g.id, { y: 0, duration: 0.20, ease: "power2.out", overwrite: "auto" }, HERO.in + 0.11);
            }
          });
        }

        /* words / letters */
        (hg ? hg.words : []).forEach(function (w, i) {
          var wsel = hsel + " .w[data-i='" + i + "']";
          if (HERO.perLetter) {
            tl.set(wsel, { opacity: 1 }, w.start);
            var letters = document.querySelectorAll(wsel + " .l");
            for (var li = 0; li < letters.length; li++) {
              var lf = { opacity: 0, y: "0.16em", scale: 1.05 };
              var lt = { opacity: 1, y: 0, scale: 1, duration: 0.30, ease: "power4.out", overwrite: "auto" };
              if (HERO.letterBlur) { lf.filter = "blur(" + HERO.letterBlur + "px)"; lt.filter = "blur(0px)"; }
              tl.fromTo(letters[li], lf, lt, w.start + li * (HERO.letterStagger || 0.014));
            }
          } else {
            var m = MOTION.impact || MOTION.present || {};
            wordReveal(wsel, m, w.start, i);
          }
        });

        /* glow layer (DNA-gated). Slam = impact THEN heat: glow waits for geometry lock. */
        if (HERO.glow > 0) {
          var glowAt = HERO.in + (HERO.entrance === "slam" ? 0.17 : 0.08);
          var glowPeak = Math.min(0.5, HERO.glow * amp) * (HERO.minor ? 0.5 : 1);
          tl.set(gsel + " .w", { opacity: 1 }, Math.max(0, HERO.in - 0.01));
          tl.fromTo(gsel, { opacity: 0 },
            { opacity: glowPeak, duration: HERO.entrance === "slam" ? 0.3 : 0.5, ease: "power2.out", overwrite: "auto" },
            glowAt);
          tl.to(gsel, { opacity: 0, duration: 0.5, ease: "power2.in", overwrite: "auto" }, HERO.out - 0.5);
          tl.set(gsel, { opacity: 0, display: "none" }, HERO.out);
        }

        /* fx: ECHO layers (RGB-split fringe / speed trails) — colored duplicates that
           CONVERGE as the word lands, then rest at a small offset until exit */
        if (HERO.echoes && HERO.echoes.length) {
          HERO.echoes.forEach(function (ec, k) {
            var esel = "#" + HERO.id + "-echo-" + k;
            tl.set(esel + " .w", { opacity: 1 }, Math.max(0, HERO.in - 0.01));
            tl.fromTo(esel,
              { x: (ec.dx || 0) * 4, y: (ec.dy || 0) * 4, opacity: 0 },
              { x: ec.dx || 0, y: ec.dy || 0, opacity: ec.opacity != null ? ec.opacity : 0.5,
                duration: 0.22, ease: "power4.out", overwrite: "auto" }, HERO.in);
            tl.to(esel, { opacity: 0, duration: 0.4, ease: "power2.in", overwrite: "auto" }, HERO.out - 0.45);
            tl.set(esel, { opacity: 0, display: "none" }, HERO.out);
          });
        }

        /* fx: SHEEN — one gradient sweep across the hero during the hold (chrome).
           Targets the .w spans (the gradient lives there — see wordCss). Starts as the
           entrance settles so the specular hit is visible early in the hold. */
        if (HERO.sheen && HERO.out - HERO.in > 0.9) {
          tl.fromTo(hsel + " .w", { backgroundPosition: "0% 50%" },
            { backgroundPosition: "200% 50%", duration: Math.min(1.2, HERO.out - HERO.in - 0.5),
              ease: "power1.inOut", overwrite: "auto" }, HERO.in + 0.22);
        }

        /* act 3 — AFTERGLOW: ONE slow scale tween (no loops — deterministic).
           loom (slow growth, loud) subsumes breathe — never two transform tweens
           on the same element in the same window. wipe-up entrances are clip-path,
           so the afterglow scale channel is always free by now. */
        var after = HERO.loom > 0 ? HERO.loom : HERO.breathe;
        if (!HERO.minor && after > 0 && HERO.out - HERO.in > 1.4) {
          tl.fromTo(hsel, { scale: 1 },
            { scale: 1 + after, duration: HERO.out - HERO.in - 1.2, ease: HERO.loom > 0 ? "power1.inOut" : "sine.inOut", overwrite: "auto" },
            HERO.in + 0.7);
        }

        /* exit */
        if (HERO.out < DUR) {
          tl.to(hsel, { opacity: 0, duration: 0.5, ease: "power2.in", overwrite: "auto" }, HERO.out - 0.5);
        }
        tl.set(hsel, { opacity: 0, display: "none" }, HERO.out);
      });

      tl.seek(0);
      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
modes/cinematic/README.md
# Cinematic mode (pure embed) — one engine, six DNAs

> Cinematic mode compiles **[../../dna/](../../dna/README.md)** through
> **[engine.html](engine.html)** (`make-composition.cjs`). The old per-template HTML
> shells are retired — `cinematic-cream` maps to `dna: "cream"` automatically; the other
> archived templates (memory-wall / champion / portrait-header, in [\_archive/](_archive/))
> remain as design references only.

Use this mode for pure-embed asks (no rail): brand film, hype, social reel, showcase.
The **DNA** locks the visual language (type, palette scheme, blend, motion grammar, hero
three-act); **safe-zones v2** parameterizes it to the scene (sampled accent, light-
direction contact shadow, depth-match blur); **the agent decides layout only** (planes,
blocks, per-line typography within the DNA).

## Workflow

1. `bash scripts/prepare.sh <project>` → matte ∥ transcript ∥ envelope → safe-zones v2
2. Pick a DNA ([../../dna/README.md](../../dna/README.md)): bright hero band → `ink`,
   else by register (cream / editorial / keynote / documentary / loud). Recommend, let
   the user pick.
3. Author `<project>/cinematic.json` — `"dna": "<name>"` + thought-blocks (schema:
   `scripts/make-cinematic.cjs` header)
4. `node scripts/make-cinematic.cjs <project>` → plan.json → engine-compiled index.html
5. `node scripts/preview-frames.cjs <project>` → § Visual QA (failure checks + the 5
   positive checks in [../../references/reference-bar.md](../../references/reference-bar.md))
6. `bash scripts/render-and-composite.sh <project>` → gates → final.mp4

## What the engine generates (never author these)

- word timings from the transcript; accumulate-within-block / page-flip-between-blocks
- the hero hand-off + **three-act orchestration** (dim → RMS-coupled per-letter entrance
  → breathe + glow), per the DNA's `hero` block
- scene tokens: `--accent` (sampled), contact shadow, depth blur
- reading order, re-slot from measured heights, hero size/collision post-pass

## What you DON'T do

- Override `.cap` color / blend / shadow / filter / motion curves — that's the DNA.
  Scene fights the look → pick a different DNA (bright → `ink`), never recolor.
- Hand-position the hero into a clean margin (it belongs ON the subject, ~30–55%
  occluded — safe-zones `heroBands.best`).
- Add full-frame grades/textures over the footage (hard rule: the video ships untouched).

## Adding a DNA

`dna/<name>.json` — copy one, change the voice (see [../../dna/README.md](../../dna/README.md)
§ Adding). The engine consumes it with no code change. A DNA must be a distinct voice
with a reason to exist, not a recolor.
modes/standard/_anatomy.md
---
name: caption-template-anatomy
description: The shared, reproducible scene engine every caption template is built on — a matted talking-head with a flowing verbatim foreground caption and a single climax word, driven by one paused GSAP timeline. Read it ONCE per session (it is identical for all 54 templates); each per-template file in templates/ only overrides the style tokens + the named climax entrance/exit.
metadata:
  tags: caption, talking-head, matte, occlusion, verbatim, climax, gsap, hyperframes
---

# Caption Template — Anatomy (the shared engine)

A **caption template** = one complete, reproducible HyperFrames scene:

> **the person (always in frame)** + a **flowing foreground caption** (verbatim, word-by-word, with appear **and** disappear) + a **climax word** (big, behind the speaker, with a designed entrance **and** exit) + a coherent **font / colour / motion** design.

Every file in `templates/` is the SAME engine described here with three things swapped: (1) the **style tokens** (font, fills, accent, optional gradient/stroke), (2) the named **climax entrance** + **exit** (see `_motion.md`), (3) the **copy** (flow lines + climax word) and **scene/person**. So read this once; each template file is short.

HyperFrames-native, so anyone can reproduce it:

- **One paused GSAP timeline per composition**, registered to `window.__timelines[data-composition-id]`.
- All timing in **seconds**; `data-start` / `data-duration` carry the scene window.
- **Deterministic + seek-safe** only — no `Math.random()`, no `Date.now()`, no infinite repeats, no un-seekable CSS animations. Every state is reachable by seeking the timeline to a time `t`.
- GSAP transform aliases (`x`, `y`, `scale`, `rotation`); animate `opacity`, `filter`, `clipPath`, `textShadow`, `backgroundPosition`, `letterSpacing` — never layout props (width/top/left/margin).

For the composition contract see `hyperframes-core`; eases + the animated-property allowlist see `hyperframes-gsap`; caption grouping/positioning/exit guarantees see `hyperframes-captions`.

## 1 · Asset prep (two CLI calls)

```bash
# 1) The person — transparent talking-head cutout over the scene (VP9 + alpha)
bash scripts/prepare.sh   <project>      # matte ∥ transcribe ∥ safe-zones (THIS skill — not remove-background)
#    (a still works too:  remove-background portrait.jpg -o person.png)

# 2) The verbatim word timings that drive the flowing caption
npx hyperframes transcribe subject.mp4 --model small            # → transcript.json
#    shape: [{ "id":"w0","text":"Hello","start":0.0,"end":0.5 }, …]
```

The flow caption consumes that `transcript.json` directly (word `start`/`end` → reveal + active-word emphasis). The climax word is authored by hand (it is the headline beat, not part of the spoken transcript). In production the avatar/matte pipeline yields a pixel-perfect alpha for free — `remove-background` is the fallback for arbitrary footage.

## 2 · The matte sandwich (HTML)

Six layers, back-to-front. The person is layered ON TOP of the climax so they physically **occlude** it — this is what sells "behind the speaker" (blur/opacity alone reads as a flat overlay).

```html
<div
  class="stage {STYLE} {PERSON}"
  id="cap-{id}"
  data-composition-id="cap-{id}"
  data-start="0"
  data-duration="{SCENE_DUR}"
  data-track-index="0"
>
  <!-- z0  background plate (the original frame, full) -->
  <!--     supplied by .{PERSON} as background-image, or a <video> at z-index:0 -->

  <!-- z1  CLIMAX — big, BEHIND the person, occluded -->
  <div class="climax"><span>{CLIMAX_WORD}</span></div>

  <!-- z4  the person cutout (transparent webm/png), aligned to the plate -->
  <video class="cut" src="person.webm" muted playsinline></video>
  <!-- or:  <img class="cut" src="person.png"> -->

  <!-- z5  grade / vignette for depth + legibility -->
  <div class="grade"></div>

  <!-- z6  FLOW — the verbatim caption, IN FRONT, lower third -->
  <div class="flow"></div>
  <!-- words injected from transcript.json -->
</div>
```

## 3 · Base CSS (layout · z-order · sizing)

Caption size is in **`cqh`** (% of frame height) via a size container, so it honours broadcast spec at any resolution (~8% cap-height for a 16:9 word). The per-template file only sets the `--ff` / `--cfill` / `--cacc` tokens and any `.climax span` fill (gradient/stroke).

```css
.stage {
  position: relative;
  aspect-ratio: 16/9;
  overflow: hidden;
  container-type: size;
  background-size: cover;
  background-position: center 12%;
  background-color: #0a0a0e;
  font-family: var(--ff);
}
.stage > * {
  position: absolute;
}
.cut {
  z-index: 4;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center 12%;
  pointer-events: none;
}
.grade {
  z-index: 5;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(130% 100% at 50% 26%, transparent 40%, rgba(0, 0, 0, 0.6));
}

/* CLIMAX — big, behind person (z1), occluded. line-height ≥1.15 so clip-reveal
   entrances (inset(0)) never slice glyph tops; pad clips negative for script faces. */
.climax {
  z-index: 1;
  left: 50%;
  top: 37%;
  transform: translate(-50%, -50%);
  white-space: nowrap;
  text-align: center;
  line-height: 1.18;
  font-family: var(--ff);
  color: var(--cfill);
  font-weight: 900;
  font-size: 44cqh;
  text-transform: uppercase;
  text-shadow:
    0 2px 13px rgba(0, 0, 0, 0.6),
    0 0 48px rgba(0, 0, 0, 0.42);
}
.climax span {
  display: inline-block;
  opacity: 0;
} /* GSAP reveals it */

/* FLOW — verbatim caption, in front (z6), lower third */
.flow {
  z-index: 6;
  left: 50%;
  bottom: 9%;
  transform: translateX(-50%);
  width: 90%;
  text-align: center;
  line-height: 1.15;
  font-family: var(--ff);
  font-weight: 700;
  font-size: 7.5cqh;
  color: var(--cfill);
}
.flow .w {
  display: inline-block;
  opacity: 0;
  margin: 0 0.1em;
  color: var(--cfill);
}
.flow .w.act {
  color: var(--cacc);
} /* the currently-spoken word */

/* tokens every template overrides: */
.stage {
  --ff: "Inter";
  --cfill: #fff;
  --cacc: #10a37f;
}
```

**Legibility on busy/bright scenes:** a behind-the-person climax needs separation from the footage, not just a fill colour. For dark or gradient fills on lit scenes give the climax an outline — `-webkit-text-stroke:1px rgba(0,0,0,.5);paint-order:stroke fill` — a dark drop-shadow alone fails against highlights (e.g. a lamp). Gradient/clip fills must live on `.climax span` (the text node), never on the transformed `.climax` container, or the clip detaches and only the shadow shows.

## 4 · One paused GSAP timeline (the loop, made seek-safe)

The gallery used a `setInterval` loop; HyperFrames needs the same beats as **absolute-time tweens on one paused timeline**. The cycle is **FLOW line → (FLOW line) → CLIMAX in → hold → out**. Restraint is the rule: flow stays clean; the one big mood move happens only at the climax.

```html
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
<script>
  window.__timelines = window.__timelines || {};
  const stage = document.getElementById("cap-{id}");
  const flow = stage.querySelector(".flow");
  const climax = stage.querySelector(".climax span");
  const tl = gsap.timeline({ paused: true });

  // --- FLOW: render words from transcript.json, reveal each at its start time ---
  // WORDS = grouped transcript lines: [{ words:[{text,start,end}], end }] in scene-local seconds.
  function renderLine(line) {
    flow.innerHTML = line.words
      .map((w, i) => `<span class="w" data-i="${i}">${w.text}</span>`)
      .join(" ");
    return [...flow.querySelectorAll(".w")];
  }
  WORDS.forEach((line) => {
    const spans = renderLine(line); // (one renderLine per active line; see captions skill for multi-line groups)
    spans.forEach((el, i) => {
      const w = line.words[i];
      tl.add(FLOW_IN(el), w.start); // FLOW_IN  = the template's flow entrance (see _motion.md)
      tl.set(spans, { className: "w" }, w.start); // active-word sweep: only the spoken word gets .act
      tl.set(el, { className: "w act" }, w.start);
    });
    tl.add(FLOW_OUT(spans), line.end); // FLOW_OUT = flow exit
    tl.set(flow, { autoAlpha: 0 }, line.end + FOUT); // hard-hide the group so old text can't linger
    tl.set(flow, { autoAlpha: 1 }, line.end + FOUT + 0.001);
  });

  // --- CLIMAX: entrance at the beat → hold ≥1s → exit (named recipes from _motion.md) ---
  const T = CLIMAX_AT; // beat time (after the flow lines)
  tl.add(CLIMAX_IN(climax), T); // e.g. SLAM / DEBLUR / INK-LOOM / CHROME-SWEEP …
  tl.add(CLIMAX_OUT(climax), T + CLIMAX_HOLD); // hold ≥1s, then exit. CLIMAX_OUT ends opacity:0 (hard exit)

  window.__timelines["cap-{id}"] = tl;
</script>
```

`FLOW_IN` / `FLOW_OUT` / `CLIMAX_IN` / `CLIMAX_OUT` are the named recipes in **`_motion.md`** — each returns a GSAP tween/timeline so the per-template file just picks four names. A simpler equivalent for the flow active-word _glow_ (rather than discrete reveal) is the single-driver envelope in `hyperframes-animation/rules/asr-keyword-glow.md`.

## 5 · How to choose values

- **SCENE_DUR** — must equal `data-duration`. Typical 6–10 s for a looping demo card.
- **WORDS grouping** — 2–4 words / line, ~380–520 ms per word (premium pacing is slower than Hormozi). Group from `transcript.json`; keep `end < next.start` (monotonic).
- **CLIMAX_AT** — place the climax _after_ the flow lines clear, on the narration's emphasis beat.
- **CLIMAX_HOLD** — **≥1 s** of settled dwell _after_ the entrance finishes (the climax is the headline beat). Entrances run 0.6–1.6 s, so e.g. hold = entranceDur + 1.0–1.6.
- **FOUT** — flow exit ≈ 0.5 s. **Exit ≈ 75 % of entry** for every element (arrival deliberate, departure swift; see `_motion.md`).
- **Climax size** — base 44 cqh; long words bleed off-frame (intended cinematic); 3-char words behind a centred subject need size + an outline so they peek.

## Critical constraints (HyperFrames)

- Timeline **paused**; registry key = `data-composition-id`.
- **No CSS keyframe animation** on caption elements — all motion is GSAP tweens at absolute times (seek-safe).
- No `Math.random` / `Date.now` / infinite repeats.
- `display:inline-block` on every `.w` and the climax `span`.
- **Hard-hide** each flow group at its end time; **CLIMAX_OUT ends at `opacity:0`** (or fully-clipped) so nothing lingers.
- Gradient / `background-clip:text` / stroke fills go on `.climax span`, not the transformed `.climax`.
- `.climax` `line-height ≥ 1.15`; pad clip-reveal entrances with negative insets for script/decorative fonts.

## Pairs with HF skills

- `media-use` — `remove-background` (the matte) + `transcribe` (word timings).
- `hyperframes-captions` — transcript consumption, grouping, positioning, exit guarantees, `fitTextFontSize`.
- `hyperframes-animation/rules/asr-keyword-glow.md` — the verbatim active-word envelope.
- `hyperframes-gsap` — single paused timeline, transform aliases, ease palette.
- `_motion.md` (this folder) — the named flow/climax entrance + exit recipes.
modes/standard/_motion.md
---
name: caption-template-motion
description: The named flow + climax entrance/exit recipes (GSAP, seek-safe) that the per-template files reference, plus the motion numerics (ease palette, exit=75% entry, climax dwell, restraint, mood→motion). Every template picks a FLOW_IN/OUT and a CLIMAX_IN/OUT from here — look up only the 2-3 recipes a template names (grep the `### name`), this is a catalog, not a read-through.
metadata:
  tags: gsap, easing, entrance, exit, timing, restraint, mood-matching, captions
---

# Caption Template — Motion Language

`_anatomy.md` wires four hooks onto one paused timeline: `FLOW_IN`, `FLOW_OUT`, `CLIMAX_IN`, `CLIMAX_OUT`. This file is the catalog each template picks from. Each recipe is a GSAP tween on the climax `span` (or a flow `.w`); placed at an absolute time it is fully **seek-safe**. Multi-stage looks use GSAP `keyframes:{}` (still seek-safe) — never CSS keyframes.

## Numerics (shared)

**Ease palette** (sanctioned — reads clean on video, stays distinct):

| Curve           | GSAP                                    | Use                                                 |
| --------------- | --------------------------------------- | --------------------------------------------------- |
| overshoot entry | `back.out(1.4–1.7)`                     | confident arrivals (premium/epic/creator)           |
| heavy/refined   | `power3.out`                            | hero/secondary entrances, slides                    |
| general         | `power2.out`                            | fades, scales                                       |
| exit accel      | `power2.in`                             | the _exit_ side of any move (departure accelerates) |
| snappy          | `expo.out`                              | warp/fly/sonic confident snaps                      |
| breathing       | `sine.inOut`                            | gentle wellness drift                               |
| impact thud     | `back.out(1.5–2)` or a quick `expo.out` | slam/stomp/stamp landings                           |
| linear driver   | `none`                                  | the asr active-word envelope driver only            |
| digital stutter | `steps(n)`                              | glitch/vhs/blink/possess/cut                        |

**Forbidden:** `bounce.out`, `elastic.out` everywhere **except** the Playful cluster (explicit playfulness — KIDS drop-bounce, CANDY jelly). Elsewhere real objects decelerate; they don't bounce. Use low `back.out` for sanctioned overshoot.

**Durations** — flow word enter 0.30–0.50 s · climax entrance 0.6–1.6 s · **exit ≈ 75 % of entry** (arrival deliberate, departure swift) · stagger 50–150 ms / word.

**Restraint (the rule that makes it premium):** the FLOW stays clean — a tasteful reveal + the active-word accent, no effects. The big mood move + any scene effect (flash, shockwave, shake) happens **only at the CLIMAX**, then clears. Never sprinkle effects across the flow.

**Climax dwell ≥ 1 s** after the entrance finishes — the climax is the headline beat.

**Mood → motion** (pick an entrance whose physics match the theme):

| Mood                            | Entrances                                            |
| ------------------------------- | ---------------------------------------------------- |
| premium                         | deblur · rise · expose · breathe · flip              |
| epic                            | slam · monument · fly · grandrise                    |
| cyber                           | glitch · type · scan · boot                          |
| horror                          | loom · possess · glimpse · seep (never bouncy/clean) |
| luxury                          | tracking-expand · hairrise                           |
| retro                           | vhs · stamp · blink · type                           |
| neon                            | ignite · buzz                                        |
| hype                            | stomp · punch · slap                                 |
| playful                         | jelly · dropb · scrawl · popr                        |
| creator                         | boxpop · slideup · flip · shimmer                    |
| ultra (flash in the type)       | volt · hyper · liquid · prism · shatter · extrude    |
| atelier (design)                | editwipe · rise · block · vert · ink · weight        |
| impact (scene effect at climax) | nuke · meteor · sonic · seismic · judge              |

---

## FLOW entrances / exits (per word `.w`)

The flow caption reveals word-by-word from the transcript. The active word gets `.act` (→ `--cacc`). Exit hard-hides the group.

- **fade-up** (premium/atelier default) — `fromTo(w,{opacity:0,y:14},{opacity:1,y:0,duration:.42,ease:'power3.out'})`
- **pop** (epic/hype) — `fromTo(w,{opacity:0,scale:.5},{opacity:1,scale:1,duration:.34,ease:'back.out(1.6)'})`
- **whip** (kinetic) — `fromTo(w,{opacity:0,x:-30,filter:'blur(10px)'},{opacity:1,x:0,filter:'blur(0)',duration:.32,ease:'expo.out'})`
- **glitch** (cyber) — `fromTo(w,{opacity:0,clipPath:'inset(45% 0 45% 0)'},{opacity:1,clipPath:'inset(0)',duration:.4,ease:'steps(6)'})`
- **type** (terminal/retro) — `fromTo(w,{opacity:0},{opacity:1,duration:.2,ease:'steps(3)'})` staggered as the cadence
- **blur-in** (horror) — `fromTo(w,{opacity:0,filter:'blur(10px)',y:'.1em'},{opacity:1,filter:'blur(0)',y:0,duration:.6,ease:'power2.out'})`
- **karaoke** (creator) — words start visible-dim; on `w.start` `set(w,{className:'w act'})` (active → `--cacc`), prior word back to spoken. The signature verbatim mechanic.
- **flow exits** — fade-up-out `to{opacity:0,y:-10}` · horror smear `to{opacity:0,x:6,skewX:10,filter:'blur(5px)'}` · all ≈0.4–0.55 s.

---

## CLIMAX entrances

Each is a `gsap.fromTo(span, {from}, {to, duration, ease})`. Where a `keyframes:{}` is shown, pass it as the 2nd arg's `keyframes`.

### premium

- **deblur** — `{opacity:0,scale:.96,filter:'blur(8px)'}→{opacity:1,scale:1,filter:'blur(0)',duration:.8,ease:'power3.out'}`
- **rise** — `{opacity:0,yPercent:48}→{opacity:1,yPercent:0,duration:.9,ease:'power3.out'}`
- **expose** — `{opacity:0,filter:'brightness(3.2) blur(5px)',scale:1.05}→{opacity:1,filter:'brightness(1) blur(0)',scale:1,duration:1,ease:'power2.out'}`
- **breathe** — `{opacity:0,scale:1.09,filter:'blur(8px)'}→{opacity:1,scale:1,filter:'blur(0)',duration:1.2,ease:'sine.out'}`
- **flip** — `{opacity:0,rotationX:93,transformPerspective:720}→{opacity:1,rotationX:0,duration:.9,ease:'power3.out'}`

### epic

- **slam** — `{opacity:0,scale:1.6,filter:'blur(12px)'}→{opacity:1,scale:1,filter:'blur(0)',duration:.7,ease:'back.out(1.6)'}` (lands with a thud; overshoot ~1.03 mid)
- **monument** — `{opacity:0,scale:1.42,letterSpacing:'.12em'}→{opacity:1,scale:1,letterSpacing:'0',duration:1.3,ease:'power3.out'}`
- **fly** — `{opacity:0,scale:.3,filter:'blur(14px)'}→{opacity:1,scale:1,filter:'blur(0)',duration:.8,ease:'expo.out'}`
- **grandrise** — `{opacity:0,yPercent:66,scale:1.05}→{opacity:1,yPercent:0,scale:1,duration:1.15,ease:'power3.out'}`

### cyber

- **glitch** — `{opacity:0}→{opacity:1,duration:.6,ease:'steps(8)',keyframes:{clipPath:['inset(40% 0 40% 0)','inset(0)','inset(60% 0 10% 0)','inset(0)'],x:[-8,5,-2,0],textShadow:['4px 0 #ff003c,-4px 0 #00ffd1','none']}}`
- **type** — `{opacity:1,clipPath:'inset(0 100% 0 0)'}→{clipPath:'inset(0 0 0 0)',duration:.85,ease:'steps(13)'}` (typewriter L→R)
- **scan** — `{opacity:0,scaleY:.02,filter:'brightness(2.4)'}→{opacity:1,scaleY:1,filter:'brightness(1)',duration:.75,ease:'power3.out'}`
- **boot** — `{opacity:0,scaleY:.1}→{opacity:1,scaleY:1,duration:1,ease:'steps(1)',keyframes:{opacity:[0,.6,.1,.85,.2,1]}}` (hologram power-up flicker)

### horror (never bouncy, never clean)

- **loom** — `{opacity:0,scale:.6,filter:'blur(17px)'}→{opacity:1,scale:1,filter:'blur(0)',duration:1.6,ease:'power2.in',keyframes:{scale:[.6,.93,1.07,1]}}` (slowly approaches out of the dark, then a lurch closer)
- **possess** — `{opacity:0}→{opacity:1,duration:1.15,ease:'steps(1)',keyframes:{x:[0,-5,6,-4,5,-2,0],skewX:[0,9,-10,7,-5,3,0],textShadow:['none','4px 0 #c00,-4px 0 #0aa','-6px 0 #c00,6px 0 #0aa','none']}}` (materialise then violent demonic shudder)
- **glimpse** — `{opacity:0}→{opacity:1,duration:1.35,ease:'steps(1)',keyframes:{opacity:[0,1,0,0,1,0,0,1,.05,1,.15,1],x:[0,0,0,3,0,-2,0]}}` (subliminal failing-light flicker)
- **seep** — `{opacity:0,clipPath:'inset(-15% -8% 100% -8%)',filter:'blur(4px)'}→{opacity:1,clipPath:'inset(-15% -8% -15% -8%)',filter:'blur(0)',duration:1.35,ease:'power1.inOut'}` (blood seeps down into the word; negative insets so script tops aren't clipped)

### luxury

- **tracking** — `{opacity:0,letterSpacing:'-.05em'}→{opacity:1,letterSpacing:'.28em',duration:1.4,ease:'power3.out'}` (the most "expensive" move)
- **hairrise** — `{opacity:0,yPercent:32,letterSpacing:'.07em'}→{opacity:1,yPercent:0,letterSpacing:'0',duration:1.1,ease:'power3.out'}`

### retro

- **vhs** — `{opacity:0}→{opacity:1,duration:.8,ease:'steps(1)',keyframes:{x:[0,-7,6,-3,2,0],textShadow:['none','3px 0 #ff3b5c,-3px 0 #3aa0ff','none']}}` (tracking-lock jitter + chroma)
- **stamp** — `{opacity:0,scale:1.7,rotation:-5}→{opacity:1,scale:1,rotation:0,duration:.7,ease:'back.out(2)'}` (printing-stamp thud)
- **blink** — `{opacity:0}→{opacity:1,duration:.75,ease:'steps(1)',keyframes:{opacity:[0,0,1,0,1,0,1,1]}}` (8-bit blink-in)

### neon

- **ignite** — `{opacity:0}→{opacity:1,duration:1.3,ease:'steps(1)',keyframes:{opacity:[.2,1,.2,1,.3,1,1]}}` (tube strikes + buzzes alight; glow is a style `text-shadow`)
- **buzz** — `{opacity:0}→{opacity:1,duration:1.2,ease:'steps(1)',keyframes:{opacity:[0,.7,.1,.85,.2,1,.5,1]}}` (slower warm-up rhythm)

### hype

- **stomp** — `{opacity:0,scale:1.5,filter:'blur(8px)'}→{opacity:1,scale:1,filter:'blur(0)',duration:.9,ease:'back.out(1.6)'}`
- **punch** — `{opacity:0,xPercent:-55,scale:1.1,filter:'blur(9px)'}→{opacity:1,xPercent:0,scale:1,filter:'blur(0)',duration:.6,ease:'expo.out'}` (whip from the side)
- **slap** — `{opacity:0,rotation:12,scale:1.5,filter:'blur(7px)'}→{opacity:1,rotation:0,scale:1,filter:'blur(0)',duration:.7,ease:'back.out(2)'}` (sticker/tag slap)

### playful (bounce allowed here)

- **jelly** — `{opacity:0,scale:.5}→{opacity:1,scale:1,duration:.8,ease:'back.out(2.2)',keyframes:{scale:[.5,1.12,.92,1.04,1],skewX:[0,0,6,-4,0]}}`
- **dropb** — `{opacity:0,yPercent:-85}→{opacity:1,yPercent:0,duration:.95,ease:'power2.out',keyframes:{yPercent:[-85,0,-22,0,-8,0]}}` (drop + multi-bounce). **❗opacity must reach 1 and stay** — declare it at the end of the keyframes.
- **scrawl** — `{opacity:0,clipPath:'inset(-12% 100% -12% 0)',rotation:-3}→{opacity:1,clipPath:'inset(-12% 0 -12% 0)',rotation:0,duration:.8,ease:'power2.out'}` (handwritten write-on, for script faces)
- **popr** — `{opacity:0,scale:.4,rotation:8}→{opacity:1,scale:1,rotation:0,duration:.6,ease:'back.out(2)'}`

### creator

- **boxpop** — `{opacity:0,scale:.5}→{opacity:1,scale:1,duration:.5,ease:'back.out(1.8)'}` (the active flow word's highlight box is a style)
- **slideup** — `{opacity:0,yPercent:44}→{opacity:1,yPercent:0,duration:.55,ease:'power3.out'}`
- **flip** — see premium
- **shimmer** — `{opacity:0,scale:.7,filter:'brightness(2.2)'}→{opacity:1,scale:1,filter:'brightness(1)',duration:.75,ease:'power2.out'}`

### ultra (the flash lives in the type)

- **volt** — `{opacity:0}→{opacity:1,duration:1.1,ease:'steps(1)',keyframes:{opacity:[0,1,.12,1,.35,1],x:[0,0,0,2,-2,0]}}` then settle to glow `text-shadow:0 0 8px #fff,0 0 22px #43f4ff,0 0 48px #1e90ff` (electric strike)
- **hyper** — `{opacity:0,scale:.05,filter:'blur(3px) brightness(3)'}→{opacity:1,scale:1,filter:'blur(0) brightness(1)',duration:.9,ease:'expo.out'}` (warp from depth)
- **liquid** — `{opacity:0,scale:.9,yPercent:6,filter:'blur(10px)'}→{opacity:1,scale:1,yPercent:0,filter:'blur(0)',duration:1,ease:'power2.out'}` + the `.climax` _container_ carries SVG `filter:url(#liquid)`; in HF drive `feDisplacementMap@scale` from the timeline (don't use SMIL — not seek-safe)
- **prism** — `{opacity:0,textShadow:'-38px 0 #ff003c,38px 0 #00ffd1',filter:'blur(5px)',scale:1.08}→{opacity:1,textShadow:'-3px 0 #ff003c,3px 0 #00ffd1',filter:'blur(0)',scale:1,duration:1,ease:'power3.out'}` (chromatic dispersion converge)
- **shatter** — `{opacity:0,scale:1.9,rotation:-3,filter:'blur(14px)'}→{opacity:1,scale:1,rotation:0,filter:'blur(0)',duration:1,ease:'back.out(1.4)',keyframes:{scale:[1.9,.9,1.06,.98,1]}}` (+ a 1-frame white `text-shadow` flash on landing)
- **extrude** — `{opacity:0,rotationY:42,scale:.9,transformPerspective:700}→{opacity:1,rotationY:-9,scale:1,duration:.9,ease:'power3.out'}` (3D turn; depth via stacked `text-shadow` in the style)

### atelier (design-forward)

- **editwipe** — `{opacity:1,clipPath:'inset(-15% 100% -15% 0)'}→{clipPath:'inset(-15% -4% -15% 0)',duration:.7,ease:'power4.inOut'}` (Swiss precise wipe; negative top/bottom so glyph tops aren't clipped)
- **rise** — see premium (slower, `duration:.9`)
- **block** — `{opacity:0,scaleY:0,transformOrigin:'bottom center'}→{opacity:1,scaleY:1,duration:.7,ease:'power3.out'}` (constructed/Bauhaus)
- **vert** — `{opacity:0,clipPath:'inset(0 0 100% 0)'}→{opacity:1,clipPath:'inset(0 0 0 0)',duration:.9,ease:'power2.out'}` (vertical ink-drop; `writing-mode:vertical-rl` is a style, climax sits at `left:81%`)
- **ink** — `{opacity:0,clipPath:'inset(-28% 100% -28% -4%)',filter:'blur(3px)'}→{opacity:1,clipPath:'inset(-28% -8% -28% -4%)',filter:'blur(0)',duration:.9,ease:'power2.out'}` (brush write-on; generous negative insets for the script face)
- **weight** — `{opacity:0,fontVariationSettings:"'wght' 100",letterSpacing:'.18em'}→{opacity:1,fontVariationSettings:"'wght' 900",letterSpacing:'0',duration:1,ease:'power2.out'}` (variable-font morph; needs a variable face, e.g. Inter `[email protected]`)

### impact (the ONE sanctioned scene effect, at the climax only)

Each pairs the type move with a scene element (`<div class="flash">` / `<div class="ring">`) and/or a stage shake. Keep it to the climax beat.

- **nuke** — type: `{opacity:0,scale:.3,filter:'brightness(8) blur(10px)'}→{opacity:1,scale:1,filter:'brightness(1) blur(0)',duration:1,ease:'expo.out'}` + white `.flash` `to{opacity:0}` over 0.3 s + brief stage `x` shake
- **meteor** — `{opacity:0,yPercent:-45,scale:1.12,filter:'blur(12px) brightness(2.2)'}→{opacity:1,yPercent:0,scale:1,filter:'blur(0)',duration:1,ease:'power4.in'}` (accelerate DOWN) + on-land stage shake
- **sonic** — `{opacity:0,scale:1.4,filter:'blur(8px)'}→{opacity:1,scale:1,filter:'blur(0)',duration:.9,ease:'expo.out'}` + a `.ring` scaling `0→3` opacity `1→0`
- **seismic** — `{opacity:0,scale:1.1}→{opacity:1,scale:1,duration:.9,ease:'power3.out'}` + stage `x`/`y` quake `keyframes:[3,-3,2,-2,0]` decaying
- **judge** — `{opacity:0,yPercent:-45,scale:1.12,filter:'blur(12px) brightness(2.2)'}→{opacity:1,yPercent:0,scale:1,filter:'blur(0)',duration:1,ease:'power3.out'}` (descends from above) + light-shaft `.flash`

---

## CLIMAX exits

Default is **fade** unless a template names another. **Every exit ends at `opacity:0`** (or fully-clipped) so nothing lingers — declare opacity:0 at the end even on transform/clip exits.

- **fade** — `to{opacity:0,scale:1.03,duration:.6,ease:'power2.in'}` (premium/epic/most defaults)
- **rise-off** — `to{opacity:0,yPercent:-42,duration:.6}` · **sink** — `to{opacity:0,yPercent:16,scale:.97}` · **lift** — `to{opacity:0,yPercent:-26,letterSpacing:'.1em'}`
- **expose-off** — `to{opacity:0,filter:'brightness(3.6) blur(4px)'}` · **breathe-off** — `to{opacity:0,scale:1.08,filter:'blur(7px)'}`
- **flip-off** — `to{opacity:0,rotationX:-90}` · **fly-off** — `to{opacity:0,scale:2.5,filter:'blur(13px)'}`
- **untype** — `to{clipPath:'inset(-15% 100% -15% 0)',opacity:0,ease:'steps(11)'}` (backspace) · **scan-collapse** — `to{opacity:0,scaleY:.02}` · **power-off** — `keyframes:{opacity:[1,.3,1,.1,.5,0]}` + scaleY collapse
- **drag** (horror) — `to{opacity:0,xPercent:-40,skewX:-20,filter:'blur(11px)',duration:.55,ease:'power2.in'}` (dragged away)
- **snap** (horror) — `keyframes:{x:[0,7,-6,0],skewX:[0,-13,0],scale:[1,1.1,1.16,1.32],opacity:[1,1,1,0],textShadow:['none','8px 0 #c00,-8px 0 #0aa','none','none']}` (violent jerk then gone)
- **cut** (horror) — `keyframes:{opacity:[1,0,1,0,.8,0]},ease:'steps(1)'` (frame-drop) · **bleed** — `to{opacity:0,yPercent:50,scale:.97,filter:'blur(6px)'}` (drips down)
- **knock** — `to{opacity:0,xPercent:55,filter:'blur(9px)'}` · **peel** — `to{opacity:0,rotation:14,xPercent:42,scale:.9}` · **spin-out** — `to{opacity:0,rotation:180,scale:.3}`
- **hop** — `keyframes:{yPercent:[0,-26,120],opacity:[1,1,0]},scaleY:.7` · **popout** — `to{opacity:0,scale:.3,rotation:-10}` · **slidedown** — `to{opacity:0,yPercent:42}` · **scrawl-off** — `to{opacity:0,yPercent:-14,rotation:2}`
- **vhs-out** — `keyframes:{x:[0,-9,8,0],opacity:[1,1,0]},scaleY:.2` (tracking-lost roll) · **blink-out** — `keyframes:{opacity:[1,0,1,0,1,0,0]},ease:'steps(1)'` · **power-down** — `keyframes:{opacity:[1,.2,.7,.1,0]},ease:'steps(2)'` · **lift2** — `to{opacity:0,scale:1.12,filter:'blur(3px)'}` (stamp lift) · **shim-out** — `to{opacity:0,scale:.8,filter:'brightness(1.9)'}`
- **iris-close** — `to{opacity:0,clipPath:'circle(0% at 50% 46%)'}` (Bond) · **un-shine** — `to{opacity:0,clipPath:'inset(0 0 0 100%)',filter:'brightness(1.7)'}` · **sweep-off** — `to{opacity:1,clipPath:'inset(0 100% 0 0)'}`
- ultra family exits: **glitch-out**, **neon-out**, **hyper-out** `to{opacity:0,scale:2.6,filter:'blur(13px)'}`, **liquid-out** `to{opacity:0,yPercent:40,scale:.96,filter:'blur(9px)'}`, **prism-out** (channels split apart), **shatter-out** `to{opacity:0,scale:1.45,filter:'blur(9px)',textShadow:'7px 0 #ff003c,-7px 0 #00ffd1'}`

## Pairs with HF skills

- `hyperframes-gsap` — single paused timeline, transform aliases, ease list, the animated-property allowlist.
- `_anatomy.md` — where these four hooks attach.
- `hyperframes-animation/rules/asr-keyword-glow.md` — the verbatim active-word envelope for the flow.
modes/standard/fonts/build-fonts-css.cjs
#!/usr/bin/env node
/*
 * build-fonts-css.cjs — bundle the template fonts into ONE self-contained
 * fonts.css with base64 data-URI @font-face (no sub-resources, no CDN, no
 * system-font dependency). This is what makes Standard/Cinematic renders
 * deterministic on ANY machine: hyperframes' renderer only auto-supplies its
 * ~18 CANONICAL_FONTS; every other family falls back to a generic font unless
 * the HTML ships a local @font-face. (hyperframes' own font linter says exactly
 * this: "Use local @font-face declarations with captured .woff2 files.")
 *
 * Source woff2 live in ./files/<slug>-latin-<weight>-normal.woff2 (latin subset
 * from @fontsource via jsdelivr). Re-run after adding/removing a font:
 *   node build-fonts-css.cjs
 */
const fs = require("fs");
const path = require("path");

const FILES = path.join(__dirname, "files");
const OUT = path.join(__dirname, "fonts.css");

// slug → exact CSS family name (must match what templates declare in font-family).
const FAMILY = {
  anton: "Anton",
  audiowide: "Audiowide",
  inter: "Inter",
  "baloo-2": "Baloo 2",
  bangers: "Bangers",
  "bodoni-moda": "Bodoni Moda",
  caveat: "Caveat",
  cinzel: "Cinzel",
  "cormorant-garamond": "Cormorant Garamond",
  creepster: "Creepster",
  fredoka: "Fredoka",
  monoton: "Monoton",
  orbitron: "Orbitron",
  "permanent-marker": "Permanent Marker",
  "press-start-2p": "Press Start 2P",
  "saira-stencil-one": "Saira Stencil One",
  "shippori-mincho": "Shippori Mincho",
  rajdhani: "Rajdhani",
  "chakra-petch": "Chakra Petch",
  sora: "Sora",
  "space-grotesk": "Space Grotesk",
  "special-elite": "Special Elite",
  teko: "Teko",
  vt323: "VT323",
};

const files = fs
  .readdirSync(FILES)
  .filter((f) => f.endsWith(".woff2"))
  .sort();
const faces = [];
let raw = 0;
const seenFamilies = new Set();
for (const f of files) {
  const m = f.match(/^(.*)-latin-(\d+)-(normal|italic)\.woff2$/);
  if (!m) {
    console.error(`[fonts] skip unrecognized filename: ${f}`);
    continue;
  }
  const slug = m[1],
    weight = m[2],
    style = m[3];
  const family = FAMILY[slug];
  if (!family) {
    console.error(`[fonts] no family mapping for slug "${slug}" (${f}) — add it to FAMILY`);
    process.exit(1);
  }
  const buf = fs.readFileSync(path.join(FILES, f));
  raw += buf.length;
  seenFamilies.add(family);
  faces.push(
    `@font-face {\n` +
      `  font-family: '${family}';\n` +
      `  font-style: ${style};\n` +
      `  font-weight: ${weight};\n` +
      `  font-display: block;\n` + // block (not swap): render text only once the real face is ready — measure-layout + capture see the true glyphs, never a fallback flash
      `  src: url(data:font/woff2;base64,${buf.toString("base64")}) format('woff2');\n` +
      `}`,
  );
}

const header =
  `/* AUTO-GENERATED by build-fonts-css.cjs — do not edit by hand.\n` +
  `   ${faces.length} faces / ${seenFamilies.size} families, ${Math.round(raw / 1024)}KB raw woff2 (latin subset, base64-inlined).\n` +
  `   Families: ${[...seenFamilies].sort().join(", ")}.\n` +
  `   These are the template fonts NOT in hyperframes' auto-resolved set; inlining\n` +
  `   them makes every render deterministic regardless of installed system fonts. */\n`;

fs.writeFileSync(OUT, header + "\n" + faces.join("\n\n") + "\n");
const outKb = Math.round(fs.statSync(OUT).size / 1024);
console.log(`[fonts] wrote ${OUT}`);
console.log(
  `[fonts] ${faces.length} faces, ${seenFamilies.size} families, ${outKb}KB css (${Math.round(raw / 1024)}KB raw)`,
);
console.log(`[fonts] families: ${[...seenFamilies].sort().join(", ")}`);
references/aesthetic-principles.md
# Aesthetic Principles for Cinematic Captions

The 18 rules that separate "designed" caption work from "preset-generated" caption work. This is the single most important reference in the skill — every plan.json and every Standard-mode HTML should be checked against these before committing.

The whole competitive thesis: every AI caption tool in 2026 (Veed, Submagic, Opus Clip, Captions.ai, CapCut) is a **preset picker**. They hand the user a box of crayons. This skill is a **director** — it exercises judgment. Beat them on taste, not feature count.

---

## The 18 rules

### 1. The subject always wins

Captions exist to serve the face, not compete with it. If the caption draws the eye before the speaker, the caption has failed. **Before shipping any plan.json, ask: "does the caption or the subject read first?"** If caption wins, shrink, dim, or move it.

### 2. Occlude, don't hover

World-class caption design puts text _into_ the scene. Letters that pass behind a shoulder, mic, or head feel diegetic. Letters that float uniformly above the lower-third feel like PowerPoint. Use the matte pipeline — that's the moat over every competitor.

### 3. Contrast is a hierarchy problem, not a brightness problem

A white box behind text is a failure of taste. Priority order:

1. `mix-blend-mode: overlay` or `screen` picking up scene luminance
2. 2–3px dark stroke + soft drop shadow
3. Narrow semi-opaque gradient bar (NOT a solid box)
4. Dim the background plate by 10–15% locally
5. (LAST RESORT) A hard white pill box — banned on cinematic directions

### 4. Kill the constant lower-third

Fixed-bottom captions are monotone. Caption zone shifts with shot:

- Tight close-up → upper sidebar or crown
- Mid-shot → embedded on back wall / foam / whiteboard
- Wide → classic lower-third offset to non-subject side

### 5. One family, two weights maximum

Hierarchy lives in **weight** (e.g., 500 → 800), not in **font**. Mixing Montserrat + script + serif in one clip is the #1 amateur tell. Ship Inter, SF Pro, Söhne, GT America, Aktiv Grotesk, or Neue Haas Grotesk with ≥5 weights and compose with weight + size.

### 6. Tracking tightens as size grows

Display-size (>40pt) wants negative tracking (-10 to -30 units, or `-0.015em` to `-0.035em`). Body-size (14–20pt equivalent) wants positive tracking (+5 to +15, or `+0.005em` to `+0.015em`). Apple SF Pro's optical-size model is the reference. Submagic defaults do the opposite — they look cheap because of it.

### 7. Cap height ≈ 3.5–5% of frame height for body captions

- 1080p vertical (1920 tall): body 65–95px, emph/hook 130–170px
- 1080p landscape (1080 tall): body 40–55px, emph 70–100px
- Hormozi-size (~7–9% of frame) is **hook** size, not body size. Reserve for punchlines.

### 8. Never italicize for emphasis in video text

Italics are a **print** convention for flow inside a paragraph. On 24fps motion they read as "tilted" not "stressed". Emphasize with weight (extrabold), color (single accent), or size (1.3–1.6×). Italics allowed only for:

- Literal quotation of written material
- Foreign word
- Thought vs spoken dichotomy

### 9. Color discipline: one hue + neutrals

Pick **one** saturated accent per video for keyword highlights. Hormozi's yellow+green+red works for him because his content is already loud. Cinematic = single accent + white/bone/charcoal. Default palette:

- Warm white `#F5EFE6` on dark
- Graphite `#1A1A1A` on light
- Accent chosen from scene sampling

### 10. Animate transform only, never the letter itself

`letter-spacing`, `filter:blur`, `font-weight` animations cause inline-block reflow → line-jumps. Animate `translateY`, `scale`, `opacity`, `clip-path`. This is locked from the embedded-captions debugging.

### 11. Minimum 0.4s per word visible

BBC reading speed is 160–180 wpm (0.33–0.38s/word). Cinematic feel wants more breathing room — 200–220ms stagger per word, hold full phrase 0.4–0.6s before exit. Entries under 150ms read as frantic.

### 12. Stagger is the primary expressive axis

Same phrase, different stagger, totally different feel:

| Stagger | Feel                             |
| ------- | -------------------------------- |
| 40ms    | machine-gun, urgent, TikTok-hook |
| 80ms    | conversational, default          |
| 150ms   | deliberate, documentary          |
| 250ms+  | poetic, ceremonial               |

Pick from content tone, not default to one value.

### 13. Emphasis escalates _within_ a phrase, not _between_ phrases

Every word bolded = no emphasis. Structure:

- 70% plain body
- 20% slight lift (color OR weight, not both)
- 8% full emphasis (bigger, brighter, held longer)
- 2% climax (biggest, held 1.5s, breath before + after)

### 14. Break rhythm once per 30s

Every-word-same-way = eye adapts, stops registering motion. Plant a rhythm-break every ~30s:

- Phrase enters from opposite direction
- A single word at 2× size
- A beat of pure silence with no caption
- A color shift

This is what separates Submagic-preset work from something designed.

### 15. Caption what adds, cut what restates

Transcribe everything. **Display** 70–85%. Remove:

- Filler ("um", "like", "you know", "I mean")
- Self-corrections ("I think... I mean actually...")
- Obvious visual echoes ("as you can see here" while pointing)

Editorial judgment — no existing AI caption tool does this. It's pure upside.

### 16. Segment on breath, not on duration

Chunk at natural pauses ≥ 250ms. A caption spanning a breath-break feels wrong. Whisper word-level timestamps make this trivial.

### 17. Safe zones per platform, always

- 9:16 TikTok/IG/Shorts: caption zone `y ∈ [12%, 78%]` (bottom 22% is UI)
- 16:9 broadcast: title-safe = center 80%
- TV export: 5% margin on all sides

Bake into the layout solver. Never eyeball.

### 18. Letterbox/pillarbox is caption real estate

Black bars on 9:16 from 16:9 source? Those bars are the caption home. 2.35:1 cinematic frame on 16:9 export? Serif quotation in the letterbox reads as documentary.

---

## Rhetorical judgment rules (editorial)

These are the "what to caption" decisions. No existing tool exercises them.

- **Filler suppression** — off/light/strong. Default: light for documentary, strong for vlog.
- **Self-correction folding** — display only the final version.
- **Breath-group segmentation** — split on silences >250ms, never mid-phrase.
- **Semantic emphasis** — LLM-pass per phrase: "which 1–2 words carry the meaning?" Highlight those. Don't default to stressed syllables or loudest words.
- **Silence honor** — if speaker pauses 1.5s+ for rhetorical effect, don't back-fill with lingering prior caption. Let silence breathe.
- **Quote-sensing** — "he said, quote…" or air quotes → italic (one of the allowed italic exceptions).
- **No `[laughs]` / `[sighs]`** — those are accessibility captions. For aesthetic captions, they pollute the frame.

---

## Self-critique checklist (run before rendering)

The agent's own pre-render pass. Flag violations:

- [ ] Does the caption or subject read first? (Rule #1)
- [ ] Any hard white pill boxes? (Rule #3 — banned on cinematic)
- [ ] Still lower-third for a close-up? (Rule #4)
- [ ] More than 2 weights or more than 1 font family? (Rule #5)
- [ ] Italic used for emphasis? (Rule #8 — banned)
- [ ] More than 1 saturated accent color? (Rule #9)
- [ ] Letter-spacing or filter:blur animating? (Rule #10 — banned)
- [ ] Stagger same for documentary and vlog? (Rule #12)
- [ ] Every group emphasized? (Rule #13)
- [ ] 30s+ with no rhythm-break? (Rule #14)
- [ ] Displaying every filler word? (Rule #15)
- [ ] Caption crossing breath-break? (Rule #16)
- [ ] Caption in platform UI zone? (Rule #17)

Any "yes" to a violation question → regenerate the affected segment.
references/anti-patterns.md
# Anti-Patterns

You default to these. Stop.

Each entry: the bad habit, what it produces, what to do instead. Written in the voice of someone who has watched an agent do this 10 times — because we have.

---

## Layout

### You default to center-aligned crown.

`.crown-plane { left: 0; right: 0; text-align: center }` is the template default, and you'll leave it on every video because it worked once. On a subject that sits right-of-center (Jobs in a 16:9 frame), the body eats the middle 60% of the word and "THE BEATLES" becomes "THE \_\_\_ S".

**Before using the default crown**, run the three conditions in [layout-heuristics.md § Crown placement](layout-heuristics.md):

1. Subject centered within 10% of frame center
2. Clean zones ≥ 15% on each side
3. Crown width > subject width + 400px

If any one fails, **move the crown to the larger clean zone** with a narrower container and smaller font. Don't keep centering a word that's about to be 60% occluded.

### You compute text leftmost as `plane_left + padding`.

That's correct for left-aligned. Wrong for the templates you're using, which are **right-aligned** on the main column and **center-aligned** on crowns. The real leftmost depends on the word width.

| Alignment | leftmost_x                                         |
| --------- | -------------------------------------------------- |
| Left      | `plane_left + padding_left`                        |
| Right     | `plane_right − padding_right − longest_line_width` |
| Center    | `plane_center − longest_line_width / 2`            |

Compute it against the **longest wrapped line**, not the whole phrase. "four very talented guys" wraps to 3 lines — the widest is "talented" (~8 chars), not 23.

### You treat `plane_left` as the text's leftmost edge.

It isn't. With `left: 180px` and a right-aligned 468px word, the text starts 104px **before** `plane_left` relative to the plane, but actually lands somewhere inside the plane box. The plane box just sets the coordinate space — the text positions inside it based on alignment. Check the compiled output, not the plane attribute.

### You center text on the frame when the subject is off-center.

Look at the subject's body center, not the frame center. Jobs sits at x=1100 in a 1920 frame. The **scene's** center of gravity is 1100, not 960. Center-aligning text to 960 is center-aligning to the empty left third, not to anything meaningful.

### You copy-paste position values from memory-wall to a new video.

`top: 40, right: 30, width: 720, rotateY: -13` worked for a 1280×720 frame with an acoustic foam wall on the right. It will not work on a 1920×1080 frame with a bookshelf backdrop. Run the 6-item checklist in [scene-types.md](scene-types.md) for the new video before reusing any numbers.

---

## Typography

### You use template default font sizes regardless of column width.

Template defaults (66/78/92/140) are tuned for a ~560px column. When you bump the plane to 700px+ and don't touch the fonts, the captions feel underweight — small text swimming in negative space.

Use the [Font-size × column-width matrix](typography-presets.md). For a 700px column, that's 78/108/128/220 — a 30-40% bump across the board. Re-check pillarbox safety after bumping (bigger right-aligned text extends further left).

### You pick `intro` style for every first caption.

Intro = italic, smaller, contemplative. Fine for filler discourse markers ("You know,", "So,", "Well,"). **Not** for the first line when it's actually the thesis ("I've had this kind of upbringing"). Read the words semantically, not positionally.

### You skip emphasis entirely because "every caption looks clean."

A story without an emph or crown is typographically flat — viewers can't feel the crescendo. Reserve at least ONE emph per video for the line that lands. Skip it only for truly monotone content (policy statements, warnings).

### You give every group its own style.

Style is supposed to signal _hierarchy_. Using `intro`/`phrase`/`emph`/`dream`/`crown` all in one 15-second video means none of them signal anything. Pick 2-3 styles max for a short clip.

### You only use the 5 preset class names.

`intro / phrase / emph / dream / crown` are scaffolding, not a closed set. The canonical `memory-wall.html` uses `cap-1 / cap-2 / cap-3 / cap-4` — **position-indexed** with bespoke typography per position. That's how it achieves the 3-line right-aligned cascade on the climax. You can't express "this cap has a hanging indent at position 2" with `"style": "dream"`.

The `"style"` field in plan.json accepts **any string** — it becomes `class="cap-<string>"`. Define the class in `custom_css`:

```json
"custom_css": ".cap-1 { font-size: 78px; ... } .cap-2 { padding-right: 44px; }",
"groups": [{"id": "cg-0", "style": "1", ...}]
```

When the scene needs per-position bespoke typography, do this. Don't force-fit into `intro / phrase / emph`.

### You regenerate from plan.json when a canonical example already has the answer.

When scene framing matches `references/example-renders/memory-wall.html` or `champion.html`, clone that HTML into `<project>/index.html` and only swap the GROUPS array. Don't re-derive the design from presets — you'll lose the specific per-position typography that took many iterations to validate. See [bespoke-vs-presets.md § The clone-and-tweak workflow](bespoke-vs-presets.md).

---

## Blending

### You default to `mix-blend-mode: overlay`.

Overlay works on **mid-tone** backgrounds. On dark bookshelves (<60 luminance), overlay makes white text render as black. On sunny backgrounds (>180 luminance), overlay blows out. Check the actual luminance of the caption region in a sampled frame. Pick:

- mid-tone surface (60-180) → `overlay`
- dark surface (<60) → `screen`
- bright surface (>180) → `normal` with opaque text

### You animate `letter-spacing` on the word entry.

You saw a "typewriter breath" effect somewhere and added `letterSpacing: "0.04em"` to the `fromTo` "from" state. Result: inline-block reflow every frame → the whole `.cap` line box recomputes → captions visibly jump between rows. See the "Some → line 2" bug from memory-wall.

**Animate only opacity + transform.** If you want a breath effect, use `scale` or `y`, not letter-spacing.

---

## Animation

### You fade both the group container AND each word.

Default feels safer: fade the container in, then fade each word in. But container.opacity × word.opacity produces a non-linear curve — at progress 40% the combined opacity is 16%, not 40%. Visible result: captions seem to "pop" into view around halfway.

Set container opacity to 1 at entrance via `tl.set`, then fade only the words. Single-layer opacity = linear perceived fade.

### You stack captions in a flex column.

Flex-direction: column with multiple `.cap` children looks tidy in source. At runtime, hidden captions (`visibility: hidden`) still reserve flex space. The newly-entering caption shows up at the _bottom_ of the column instead of the designed position — landing in the gesture zone, getting clipped.

Use `position: absolute` for each `.cap` inside the plane so hidden ones don't occupy layout. Both shipped templates do this.

### You start the timeline at t=0.

Caption at exactly t=0 feels like it was there before the video started. Offset 0.1-0.3s (hyperframes motion-principles.md agrees). Same for the very last caption — let it exit before the video fades.

---

## Scene admission

### You trust that "looks like one speaker" = "is one speaker throughout."

TV archive clips cut to B-roll mid-sentence. Interview clips insert cutaways to the interviewer. You didn't check frames beyond the first one, so you rendered captions across a shot transition. Result: captions designed for Subject A are placed relative to Subject B's position (or empty frame) for half the render.

Sample frames at 20%, 50%, 80% BEFORE planning. If the scene changes, trim to the largest single-subject segment.

### You ignore baked-in captions / pillarbox / watermarks.

You saw black bars on the sides and didn't computing the safe-zone. Your captions cross the pillarbox. Or: the source already has burned-in subtitles and you added more — two caption systems fighting for attention.

Run the **letterbox probe** first. If the source has existing captions, refuse with: "source already captioned, adding more would conflict."

### You ship Whisper's transcript without checking timings against the beat.

Transcription is Whisper (via `transcribe.cjs`, no API key) — good word timings, but not infallible: a word can land with a near-zero duration or a timestamp a beat off. This skill is verbatim + on-beat, so `check-timing.cjs --strict` (80ms tolerance) is the gate, not a suggestion — fix drift in `plan.json` before rendering, and never pack two transcript words into one timed entry (the second inherits the first's timestamp and fires early).

---

## Matting

### You enable CoreML for the matting ONNX.

It's the Apple way, obviously faster. No. CoreML partitions the ONNX graph across providers. The mixed-precision boundary produced (observed with the previous RVM engine) alpha=30 inside the subject's face while background correctly reads 0. Captions shine through face. Pin the CPU execution provider only (`onnxruntime-node`). Our `matte.cjs` already does this; don't "optimize" it by re-adding CoreML.

### You pick a matte model by "general vs human."

DECISION FLIPPED 2026-06-12 after a 5-model × 6-scene A/B with caption renders: the matte's job here is CAPTION LAYERING, not prop fidelity. `u2net_human_seg` (via hyperframes `remove-background`) usually excludes thin offset furniture (mic boom arms) from the matte — words stop being sliced by booms, which beat PP-MattingV2's prop-preserving behavior on real caption videos. It is NOT surgical: large salient objects near the subject (telescope rigs) can still leak in — always sample frames_fg/. Known cost: HELD products can drop out intermittently (captions pass in front) — route product-demo climaxes away from held objects. `isnet-general-use` lost outright (backlit-hair collapse). birefnet-portrait (MIT) beat everything semantically (keeps held items AND drops furniture) but is 928 MB / ~7 s-per-frame CPU — a future quality tier, not the default.

---

## Grouping

### You caption every word.

Transcripts are verbose — "you know, um, I, I mean, you know" is 5 words from a single beat. Captioning all of them clutters the screen and breaks reading rhythm.

Editorially drop filler. Use the rules in [caption-grouping.md](caption-grouping.md): merge short fragments, cut repeated discourse markers, keep the meaning, trim the noise. You are writing typography to support speech, not a court transcript.

### You group by fixed word-count.

"3 words per caption, always" makes every caption look the same and fights the natural cadence of speech. Break on sentence boundaries, 250ms+ pauses, and semantic units instead. Caption sizes will naturally vary from 2 words to 5 — that's correct.

---

## The meta anti-pattern

### You read one reference doc and skip the rest.

You'll read this file alone and feel covered. These anti-patterns reference concepts defined in `layout-heuristics.md`, `typography-presets.md`, `scene-types.md`. If you haven't read those, the fix advice here won't make sense.

**Order of reading for a new video**:

1. SKILL.md (decision gate + pipeline + pre-flight probes)
2. bespoke-vs-presets.md (**first check if a canonical example fits — clone if so**)
3. scene-types.md (template selection — all 4 wall conditions)
4. layout-heuristics.md (positions, sides, crown, font scale, pillarbox formula)
5. typography-presets.md (font-size × column-width table, starting points)
6. caption-grouping.md (word → group)
7. **This file last** (to catch yourself before committing to plan.json)

If you're pressed for time, still read this one — it flags the failures you're about to make.
references/bespoke-vs-presets.md
# Bespoke design vs. presets — when to override, when to clone

The 5 preset styles (`intro / phrase / emph / dream / crown`) and the 3 templates (`wall-embed`, `corner-column-crown`, `portrait-header`) are **scaffolds**, not rules. The best renders we've shipped all override presets for specific groups because **typography is a per-scene decision**, not a general rule.

If you only use presets, your render will look generic. If you only copy existing renders, your skill won't adapt. The right workflow is:

1. **Decide the shape first** (template choice, plane position, blend mode).
2. **Check if a canonical example is close enough** → clone and tweak words + timings.
3. **Otherwise start from presets** → override per-group via `custom_css`.

---

## Canonical example renders

Full HTML for two validated renders is in `references/example-renders/`:

| File               | Scene                                                   | What makes it work                                                                                                                                                                                         |
| ------------------ | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `memory-wall.html` | Introspective monologue, right-side foam wall, mid-tone | Right-aligned cascade, per-group bespoke sizes (`cap-1` 78 italic / `cap-2` 66 italic + right hanging-indent / `cap-3` 72 upright / `cap-4` 90 uppercase). `mix-blend-mode: screen` for the dark-ish foam. |
| `champion.html`    | Podcast interview, cluttered bookshelf, 1920×1080       | Upper-left column + center-stage crown. Tuned preset class sizes (`cap-intro` 52 / `cap-phrase` 60 / `cap-emph` 70 / `cap-crown` 140). `screen` blend reads the shelves through text.                      |

**When a new scene matches one of these closely** (similar framing, similar subject-center, similar backdrop type): clone the HTML and only replace the GROUPS array + word timings. Don't re-derive the design from presets — you'll lose the specific choices that took many iterations to validate.

---

## When presets are wrong

You'll reach for presets like `"style": "emph"` when what the scene really needs is:

### "This cap is at position N and deserves its own treatment"

`memory-wall.html` uses `cap-1 / cap-2 / cap-3 / cap-4` — **position-indexed**, not role-indexed. Each one is a bespoke design for a specific phrase at a specific point in the arc:

- cap-1 (soft opener, 4 words): 78px italic 600 — feels like a whisper
- cap-2 (dreamy modifier, 3 words): 66px italic 500 + `padding-right: 44px` — hanging indent creates ragged right-edge stagger
- cap-3 (turn, 2 words): 72px upright 700 — the syntactic pivot, no italic
- cap-4 (climax, 4 words): 90px uppercase 900 — three lines cascade right-aligned

`phrase/emph/intro` can't express "this cap has a hanging indent" or "this cap is the syntactic pivot". When that matters, invent your own class names:

```json
{
  "template": "wall-embed",
  "custom_css": "
    .cap-1 { font-size: 78px; font-weight: 600; font-style: italic;
             letter-spacing: -0.01em; }
    .cap-2 { font-size: 66px; font-weight: 500; font-style: italic;
             padding-right: 44px; }
    .cap-3 { font-size: 72px; font-weight: 700; letter-spacing: -0.015em; }
    .cap-4 { font-size: 90px; font-weight: 900; letter-spacing: -0.03em;
             text-transform: uppercase; line-height: 1.0; }
  ",
  "groups": [
    { "id": "cg-0", "style": "1", "words": [...] },
    { "id": "cg-1", "style": "2", "words": [...] },
    ...
  ]
}
```

The `"style": "1"` field becomes `class="cap-1"` on the element — any string works, no validation.

### "The template's blend doesn't suit this backdrop" → pick a different template, do NOT override

**Cinematic mode does not override colour or blend.** A template's `mix-blend-mode` + fill are **locked DNA** — `make-composition.cjs` ignores `plan.cap_color` / `blend_mode` / `text_shadow` / `text_filter`. Selecting a template commits to its look; the only agent-authored things are layout (planes/positions) and per-group typography.

So use the caption-region luminance to **choose** a template that already fits — never to recolour one:

| Region luminance                  | What fits                                                                                     | Why                                                                        |
| --------------------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| < 60 (dark / low-key)             | a cream + `screen` template (`cinematic-cream`, `memory-wall`, `champion`, `portrait-header`) | light text glows, picks up the scene                                       |
| 60–180 (mid-tone)                 | a cream + `screen` template still reads (add a scrim via Standard if marginal)                | text picks up texture                                                      |
| > 180 (bright: window, pale wall) | **none of the cream/`screen` Cinematic templates — they wash out**                            | → use **Standard mode** (opaque rail, set per the chosen template) instead |

If the scene is bright and the cream/`screen` look washes out, that's the signal to switch to **Standard mode** (which sets opaque colour in the HTML), not to recolour a Cinematic template into something it isn't.

### "Hanging indent / outdent / letter-width tweak"

These are per-group affordances you'll occasionally need. Express via `custom_css`:

```css
#cg-2 {
  padding-right: 44px;
} /* right-aligned: shrinks right edge, creating left outdent */
#cg-2 {
  padding-left: 44px;
} /* left-aligned: offset right start */
.cap-emph .w:first-child {
  font-size: 110%;
} /* oversize first word only */
```

The `#cg-N` selector always works because `make-composition.cjs` writes `<div id="cg-N" ...>` for every group.

### "Caps should accumulate (flex stack) instead of swap"

All three templates default to `position: absolute` on `.cap` inside their plane — caps stack at one spot and only the active one shows (single-caption swap). This is correct for `portrait-header` and `corner-column-crown`, where each caption replaces the last.

`memory-wall` uses **flex column accumulation** — captions pile up like a poem. The template doesn't default to this, so override via custom_css:

```css
.wall-plane {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: flex-end; /* or flex-start for left-aligned */
  text-align: right;
  gap: 14px;
}
.wall-plane .cap {
  position: static; /* un-do template's absolute */
  top: auto;
  right: auto;
  max-width: 100%;
}
```

With this + staggered `in` / `out` times, cap-0 fades in at t=0.2, cap-1 at t=2.55 (below cap-0 in flex order), cap-2 at t=4.90 (replaces both as they fade out together at t=4.85) — this is how the memory-wall poem pages work.

### "Font size doesn't match the scene"

Template preset sizes are tuned for a specific column width + frame size. Don't fight them — override:

```json
"custom_css": ".cap-intro { font-size: 52px; } .cap-phrase { font-size: 60px; }"
```

Then check [typography-presets.md § Font-size scales with column width](typography-presets.md) for what to aim at given your plane's actual dimensions.

---

## The clone-and-tweak workflow

For a new video that's clearly similar to an existing canonical example:

```bash
# 1. Scaffold the project
hyperframes init <project> --non-interactive --video <video.mp4> --skill=embedded-captions

# 2. Matte + transcribe
node scripts/matte.cjs <project>
node scripts/transcribe.cjs <project>

# 3. Copy the canonical HTML instead of writing plan.json
cp references/example-renders/memory-wall.html <project>/index.html

# 4. Replace GROUPS array with the new transcript's grouping (hand-edit index.html)

# 5. Render directly (skip make-composition.cjs since we're not using plan.json)
bash scripts/render-and-composite.sh <project>
```

This skips the preset-based plan.json entirely. Use when:

- Subject framing, shot composition, and backdrop type are similar to the example
- You just need to swap the words and timings
- The bespoke typography from the example is what you want

**Don't clone when**:

- Subject position differs significantly (e.g. centered vs off-center)
- Scene luminance / blend-mode needs are different
- You want to experiment with new typography

In those cases, start with plan.json + custom_css and iterate.

---

## Rendering history

`render-and-composite.sh` now snapshots `index.html` + `plan.json` into `<project>/history/` with a timestamp before every render. If the user says "the previous one was better", diff against the latest snapshot:

```bash
ls <project>/history/
diff <project>/history/index-20260422-203947.html <project>/index.html
```

This lets you recover a design you iterated away from, without re-reading the agent transcript.
references/caption-grouping.md
# Caption Grouping

How to turn the Whisper word-level transcript into the `groups[]` array of plan.json.

## Goal

**Each group is one visual phrase**: enters, reveals word-by-word, exits. Rule of thumb: 1 group ≈ 1 comma-to-comma clause or 1 breath of speech.

## Input

`transcript.json` from `transcribe.cjs`:

```json
{
  "words": [
    { "text": "Some", "start": 0.24, "end": 0.44, "type": "word" },
    { "text": " ", "start": 0.44, "end": 0.48, "type": "spacing" },
    { "text": "memories", "start": 0.48, "end": 0.82, "type": "word" }
  ]
}
```

Drop `type: "spacing"` entries; you only need words.

## Break boundaries

Cut a new group at ANY of:

1. **Pause ≥ 500ms** (gap between word.end[i] and word.start[i+1]) — speaker took a breath.
2. **Sentence terminator** — word ends with `.`, `?`, `!`, or an em-dash-like pause.
3. **Strong comma** — `,` followed by pause ≥ 250ms.
4. **Discourse reset** — words like "but", "so", "and then", "you know" starting a clause often merit their own or new group.
5. **Group reaches 6 words OR 2.5 seconds** — whichever first. Long groups feel like subtitles, not embedded typography.

Hard constraints:

- Minimum 2 words per group (1-word exceptions: interjections like "Wait." or the crown line).
- Minimum 0.5s on screen. If a group is less, merge into neighbor.
- No overlapping groups — at most one visible at a time.

## Timing the group

For a group with words `w[0]..w[n-1]`:

- `in` = `w[0].start - 0.08` (enter slightly before first word)
- `out` = `min(next_group.in - 0.05, w[n-1].end + 0.6)` (linger ~0.6s after last word, but don't collide with next)

The last group extends to the video end if needed.

## Style & tone (cross-reference)

See `typography-presets.md` for how to pick `style` and `tone` per group. Work left-to-right through the groups and:

1. First group: default `intro` + `soft`.
2. Watch for emphasis signals (ALL CAPS in transcript is rare but possible; more often it's semantic — superlatives, proper nouns).
3. Escalate tone into `present` once the monologue shifts from setup to statement.
4. Reserve `crown` for at most ONE group, typically the final line.

## Editorial surgery is allowed

You do NOT have to caption every word. It's fine to:

- **Drop filler** like extra "you know"s, "um"s, "I mean"s if they bloat the visual pace.
- **Condense** a 6-word run into 4 by cutting function words, as long as the meaning and timing remain truthful.
- **Skip the whole thing** during obvious silence or non-speech (laugh, music interlude).

Editorial rule: you are writing typography to support the speech, not a court transcript. Keep meaning, trim noise.

## Example (champion)

Transcript: "You know, for me I've had this kind of upbringing, had the great foundation and, you know, I've achieved incredible things. I was dreaming of becoming number one in the world and becoming a Wimbledon champion"

Groups after editorial pass:

```json
[
  {
    "id": "cg-0",
    "style": "intro",
    "tone": "soft",
    "words": ["You", "know", "for", "me"],
    "in": 0.1,
    "out": 1.45
  },
  {
    "id": "cg-1",
    "style": "phrase",
    "tone": "soft",
    "words": ["I've", "had", "this", "kind", "of", "upbringing"],
    "in": 1.4,
    "out": 3.35
  },
  {
    "id": "cg-2",
    "style": "phrase",
    "tone": "soft",
    "words": ["the", "great", "foundation"],
    "in": 3.5,
    "out": 5.35
  },
  {
    "id": "cg-3",
    "style": "emph",
    "tone": "present",
    "words": ["I've", "achieved", "incredible", "things"],
    "in": 6.05,
    "out": 8.3
  },
  {
    "id": "cg-4",
    "style": "dream",
    "tone": "present",
    "words": ["dreaming", "of", "becoming", "number", "one"],
    "in": 8.5,
    "out": 10.4
  }
]
```

Plus the crown:

```json
{ "id": "cg-crown", "style": "crown", "words": ["Wimbledon", "Champion"], "in": 10.8, "out": 12.08 }
```

Notice "had" was dropped from cg-2 ("had the great foundation" → "the great foundation"), "I was" was dropped from cg-4, and "a" was dropped from crown — all for visual cadence.

## word.start/end inside groups

Pass through the original timestamps from the transcript. Don't retime individual words — only the group `in`/`out`. The word-level karaoke reveal inside each group uses the original w.start.
references/composition-craft.md
# Composition craft — EMBED track only

This is the deep "how to lay a caption INTO the scene" manual — it governs the **embed**
track only (captions composited behind the subject). The default **rail** track (standard
lower-third subtitle, where most text lives) has its own, much simpler spec → [rail.md](rail.md).
Read this before authoring any **promoted** phrase, in either authoring mode (template
Cinematic `plan.json` or Standard `index.html`).

Topics: phrase grouping, planes & clean-zone anchoring, zone coherence, climax pop,
readability, edge breathing, the occlusion 3-step judgement, accumulation, and persistence.

> **Two-track model (SKILL.md § Caption model).** Render states are `drop` / `rail` / `embed`.
> The `bg / fg / hybrid` axis used throughout the text below is **superseded**: read **"bg" as
> embed** (behind subject / in-scene), **"fg" as rail or promote-out** (in front), and "hybrid"
> as simply rail + embed coexisting. Everything here applies to the **embed** track — don't
> apply this craft to the rail.

(Granular sub-topics also have their own files — see SKILL.md § Shared knowledge.)

---

### Caption layer: BG vs FG (aesthetic axis)

Separately from template choice, decide whether captions are **embedded** (behind subject, matte occludes — default "embed" cinematic feel) or **announced** (in front of subject, no occlusion — poster / lower-third feel):

- **bg** — classic embed. Captions sit behind the matte, subject's body partially occludes letters. **Default for any cap that fits the frame.** Partial occlusion is the cinematic feature — small narrator caps with face nibbling 20-30% of letters reads as embedded texture; climax words with face crossing 30-40% reads as Vogue masthead. The eating is what sells the embed. Don't shrink to avoid it.
- **fg** — captions float on top, subject can't occlude. **Fallback for when bg cannot work.** Two scenarios:
  - **Climax fg**: the climax word is so big at impactful size that subject + frame width together leave no place for bg to render at all (subject fills frame AND climax phrase is long; bg would be eaten past readability into broken).
  - **All-fg fallback**: rare, only when the entire scene has no usable bg zone for any caption.
- **hybrid** (per-group `layer:` field) — when one cap (usually the climax) hits the "no place to fit bg" wall but the others (smaller body caps) still work fine in bg. **Body bg + climax fg** is the canonical hybrid pattern.

**Rule of thumb:**

- Small narrator/emphasis captions: bg almost always. Subject eating a few letter-edges = embed feature.
- Climax: try bg first, sized to maximally cross subject. If that size won't fit frame width, AND no clean zone elsewhere, use fg.
- Don't invert the hybrid: body fg + climax bg is the WRONG direction (sacrifices the easy bg embed wins on body, while the climax that needed protection from over-occlusion gets sent into the fire).

**Hybrid worked example — Startup_Host (subject fills frame).**

Subject fills the frame top-to-bottom (head_top y=126 / 10% from top). For body captions there's no large clean zone, but small italic narrator caps in upper corners get eaten ~25% by subject — that's the embed showcase. For the climax "SUCCESSFUL STARTUP" at maximally cinematic size, bg would be eaten 40-50%+ → past cinematic into broken. Climax goes fg.

```json
{
  "caption_layer": "bg",
  "groups": [
    {
      "id": "cg-0",
      "plane": "body",
      "layer": "bg",
      "css": "font-size: calc(0.055 * var(--h)); font-style: italic; ..."
    }, // narrator bg — face eats edges, embed texture
    {
      "id": "cg-1",
      "plane": "body",
      "layer": "bg",
      "css": "font-size: calc(0.085 * var(--h)); font-weight: 800; text-transform: uppercase; ..."
    }, // emphasis bg — Vogue masthead
    {
      "id": "cg-5",
      "plane": "crown",
      "layer": "fg",
      "css": "font-size: calc(0.09 * var(--h)); font-weight: 900; text-transform: uppercase; ..."
    }
    // ↑ climax fg: subject too dense for bg to land readably; fg fallback. Sized to fit frame width fully (single line).
  ]
}
```

Result: body bg captures embed cinematic across the subject (eaten edges = texture not bug), climax fg sits on top fully visible because there was nowhere else for it to go. make-composition.cjs emits both `index.html` (bg groups) and `index_fg.html` (fg groups); render-and-composite.sh runs two parallel Chromium passes and ffmpeg screen-blends fg over bg+matte.

Set via `caption_layer: "bg" | "fg"` plan-level (default for groups without explicit `layer:`), and `layer: "bg" | "fg"` per-group to override for hybrid.

**Decision rule — bg is default, fg is fallback, hybrid is for mixed semantic content.**

For BODY captions (narrator + emphasis):

1. **Find the actual clean zone.** Sample frames at 20/50/80% and mentally trace where the subject is (head, shoulders, gesturing hands). The clean zone is what's left. Top band above the head, bottom band below the shoulders, or off-center side column.
2. **Anchor the plane in that zone.** Body caps fit comfortably with NO subject overlap → `layer: "bg"`.
3. **If body cap stack inherently overlaps subject heavily (>40% per check-occlusion peak)** even at smallest reasonable fonts → body `layer: "fg"`. Subject fills frame too much for embed.

For CLIMAX caption (the payoff):

1. **Default: `layer: "bg"`, sized to maximally cross subject for embed effect.** The face/body eating part of the climax word IS the visual win. Don't shrink to avoid occlusion. Use the largest size that fits frame WIDTH (sub-pixel readable letters fully on-screen) — vertical position should land it across the subject's hair / forehead / face.
2. **`layer: "fg"` for climax** ONLY when frame width physically can't hold a font big enough to feel climactic AND there's no compositional way to break the word across multiple lines that bg embed could carry.
3. **Hybrid (body bg + climax fg)** is the canonical pattern for subject-fills-frame scenes. Body small caps stay bg — subject eats letter edges = embed texture (cinematic, not a bug). Climax goes fg ONLY because at impactful size + subject filling frame there's no place where bg climax could land readably (would be 50%+ eaten = past cinematic into broken). The opposite direction (body fg + climax bg) sacrifices the easy bg embed wins on small caps and forces the climax into a slot it can't survive — never do that.

**Editorial split — the spatial form of body bg + climax fg.** When the hybrid above is laid out with **bg in the upper half and fg in the lower-third**, you get a magazine-cover composition for free — no separate template needed. The rule:

- **Body caps (BG)** — placed in the upper half (top: 6-30%), at the head/hair zone. They get partially occluded by hair/forehead = vogue-masthead embed texture.
- **Climax cap (FG)** — placed in lower-third (top: 70-85%), in front of subject's chest/below. It's fully clear, fully readable, dominates the bottom band.

The viewer's eye reads top-to-bottom: first the partially-veiled body line ("decorative, atmospheric"), then the clean climax statement ("the point"). Same Z-axis layering as Vogue / Apple Keynote (decorative top behind subject + clear bottom in front), but driven by cinematic-cream's existing per-cap `layer:` field — no new template, no separate masthead track.

Two timing modes inside this layout:

- **Sequential cross-fade (default)** — body fades out as climax fades in over 0.3-0.5s. The brief overlap window IS the editorial dual-layer moment. Standard cinematic-cream behavior.
- **Sustained overlap** — extend body's `out` to coincide with or extend past climax's `in` so both are visible together for 1-2s. Use sparingly: works when body line is short (≤3 words) and acts as a "kicker" above the climax. Long body phrases sustained alongside a fg climax = visual noise.

**The plane-conflict rule for sustained overlap (debugged in Startup_Host).** If you extend a body bg cap's `out` into a window that contains another cap with `layer: "fg"` IN THE SAME PLANE, the two will visually overlap in the final composite — bg pass renders the extended bg cap, fg pass renders the fg cap, both at identical plane position, screen-blended on top of each other. Result: muddy double-text at the same spot.

Before extending any body bg's `out`:

1. Check what other caps occupy the body plane during the extended window
2. If ANY of them is `layer: "fg"`, the extension causes plane overlap → don't do it
3. Workaround: either (a) skip sustained overlap and use sequential cross-fade only, or (b) author the body kicker on a DIFFERENT plane / absolute position so the two don't share a plane

Concrete example: Startup_Host has cg-3 "diving deep" (body bg, plane=body, 3.1–4.15s) and cg-4 "into what it really takes to build a" (body fg fallback, plane=body, 4.15–6.42s). Both occupy `plane=body`. Extending cg-3 to overlap with the climax cg-5 (6.42–8.06s) ALSO drags cg-3 across cg-4's window where cg-3 (bg) and cg-4 (fg) collide on the body plane. Sequential editorial-split (climax in lower-third, body bg already faded out) was the only safe option for this case. AI_Insights had no fg in body plane between kicker and climax, so sustained overlap worked there.

When NOT to use editorial split:

- Body and climax are both information-carrying full sentences → forces viewer to read both at once, fails. Editorial split needs the body line to be _atmospheric / setup_ (italic mood), climax to be _the payoff_ (bold uppercase).
- Subject takes the full vertical column (no top clean zone, no bottom clean zone) → there's no upper or lower band to anchor either layer. Fall back to all-fg with crown.
- Climax is a long phrase that wraps to 3+ lines in lower-third → eats too much vertical real estate, leaves no room for the body bg above. Either shrink climax or split into more groups.

This pattern was historically what we called "hybrid" but the spatial framing — upper bg behind / lower fg in front — is what makes it read as editorial design rather than just two unrelated caption tracks. Keep this in mind when authoring per-cap `layer:` for hybrid scenes.

Common mistakes (debugged in real cases):

- Defaulting to "right column" because memory-wall did it → narrow 400px column forces long phrases to wrap 4-5 lines, pushing the stack DOWN into the subject's body. Fix: use top-band full-width instead, wider wrap fits fewer lines.
- **Flipping climax to fg because "viewer needs to read it" → kills cinematic embed.** Climax is supposed to feel half-eaten by subject; that's the magazine masthead energy. Don't fix what isn't broken.
- All-fg whole plan when subject fills frame → safe but sterile. Hybrid (body bg + climax fg) is almost always better — small body caps eaten = embed texture, climax fully visible since it had no readable bg slot.
- Sizing caps before choosing the plane → typography is a function of plane geometry, not the other way round.
- **Putting LONG multi-line phrases on bg in subject-fills-frame scenes.** "Subject eats letter edges = embed texture" only holds for short caps (1-3 words / 1 line). A 6-8 word italic phrase wraps 3-4 lines, and when subject's head is high in frame, **whole lines** of the phrase land deep in the body/face zone — entire words obliterated, not edge-textured. That's not embed, it's broken. **For long body phrases on subject-dense scenes: switch THAT specific cap to `layer: "fg"` even if other body caps stay bg.** Hybrid lets you mix per-cap. Sample the matte at the cap's time window: if head_top y is climbing and the cap's stack height extends into y > head_top, that cap needs fg.
- **Subject doesn't move uniformly across the clip — sample matte per BLOCK time window, not just once per case.** Startup_Host's head_top swings y=224→96 across 8 seconds (128px / 10% of frame). A block plan that worked at t=2 (head low, plenty of space) breaks at t=5 (head high, no space left). Per-block matte sampling > one-shot per case.

### Step 0 for ANY plan — transcript role annotation (mandatory)

Before writing a single caption group, do a **pre-plan agentic pass** over the transcript. Read every word and grade it. This is judgment, not a lookup — context decides (e.g. "like" is content in "like this", filler in "like, um, yeah"). No hardcoded word lists; agent decides per token.

**The grade is the SELECTION INPUT — it picks the render state** (`drop` / `rail` / `embed`); it is not itself a typography class (see SKILL.md § Caption model):

| Grade        | What it is                                                                                                                                               | → render state                                                                                    |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| **drop**     | Interjections/hesitation (um, uh, er, ah), self-corrections ("I think— I mean actually…" keep only final), pure stutters ("the the the") collapse to one | **drop** (not shown)                                                                              |
| **normal**   | Ordinary spoken content — connective glue + the content words that carry the idea                                                                        | **rail** (dense conversational glue may be trimmed for readability; content/structure words stay) |
| **emphasis** | The 1–2 punch words in a phrase                                                                                                                          | **rail** + `emphasis` highlight (inline — stays on the rail)                                      |
| **peak**     | The payoff of a beat/section — the line you'd say out loud paraphrasing it                                                                               | **promote to embed** (apply the craft below); the single biggest of the whole piece = `apex`      |

A **peak** is per-beat, **not** per-clip: a short clip usually has one (its climax); a long/multi-section explainer has ~one per section. **Embed is scarce + spaced** — ≤1 per sentence/beat, never two adjacent or co-visible, ≥ a beat apart, at most one `apex`. (The old "1 climax / 2–4 emphasis / 4–10 body / 6–12 narrator / 2–8 drop per 10–20s clip" was calibrated for short single-beat clips; it generalises to "one peak per beat, spaced — everything else rides the rail.")

**This step replaces any filler-word list.** If you find yourself reaching for a regex, you're doing it wrong — go back and judge per-token.

Then build the plan from the annotated words:

- drops → do not appear in any word entry
- narrator → either fold into adjacent body groups OR become tiny italic subordinate groups if rhythm demands
- body/emphasis/climax → shape the group structure, with typography matching role

### The compositional unit is a PHRASE, not a chunk (read this twice)

A **caption group** is a semantic phrase — typically 2–8 words bound by intonation and meaning. It is NOT a mechanical "2–4 word chunk". A group has ONE typography (one font-size, one weight, one case, one position) matching its role; the "every-word" feel comes from **per-word GSAP stagger INSIDE the group**, not from splitting the phrase into many tiny groups.

Canonical reference — memory-wall, 12 spoken words, **4 groups**:

- cg-0 `"Some memories feel soft"` (4 words, italic 600, right-top)
- cg-1 `"like old film"` (3 words, italic 500, right-mid, smaller)
- cg-2 `"but suddenly"` (2 words, upright 700 pivot, right-center)
- cg-3 `"EVERYTHING IS SHARP AGAIN"` (4 words, uppercase 900 climax, spanning width)

Every word is shown. Groups **cascade and accumulate** — cg-0 stays on screen while cg-1 enters below; by the climax, all 4 are visible simultaneously as a poem. Per-word stagger (~80ms) animates each word into its group in sequence.

If you find yourself producing 15+ groups for a 10s clip, stop — you're treating verbatim as mechanical chunking. Go back, re-read the transcript, identify 4–7 phrases, and make them groups.

**Don't split idioms, collocations, or bound phrases across groups.** "Not even kidding", "at the end of the day", "as a matter of fact", "whole lot of", "more or less", "a little bit", "kind of", "sort of", proper nouns ("Blackmagic Pocket 4K"), brand phrases, and other fixed expressions are semantic units. The climax of "I'm not even kidding" is the whole idiom, not just "KIDDING". The climax of the memory-wall canonical is "EVERYTHING IS SHARP AGAIN" as one group, not just "AGAIN". Splitting these across group boundaries breaks meaning AND typography (you can't render "not even kidding" with "not even" italic-small and "kidding" uppercase-huge — it reads as two different statements).

### Captions are always verbatim

Every word the speaker says gets captioned. The skill no longer ships a "highlight" mode — curated/edited transcripts produced fewer beats but lost the rhythm of the spoken sentence and ended up feeling like generic subtitle tracks. Verbatim with strong typographic hierarchy (small italic narrator → emphasis upright → climax massive uppercase) gives the cinematic feel without dropping content.

The only words you remove from the transcript:

1. **Pure phonetic interjections** (um, uh, er, ah, hmm) — not words, not captioned.
2. **Exact immediate stutters** ("the the the" → keep one; "is is is is" → keep one, aligned with the phrase that follows). The viewer should see one "the", not three.
3. **Self-correction backtracks** ("I think— I mean actually…" → keep only "I mean actually").

**All content words AND structure words stay** (articles, conjunctions, prepositions, auxiliaries, pronouns). Dropping a structure word like "in some" or "and I've" or "out of it" is a bug, not a stylistic choice.

### Planes — shared parent containers for spatial coherence (RECOMMENDED)

The memory-wall and champion canonicals don't set `top:` on each caption. They have a **parent container** (`wall-plane` / `left-plane` / `crown-plane`) that owns the spatial anchor, and caps are children that stack automatically (flex column with gap) or replace (absolute at same anchor).

Your plan.json can opt into this by declaring `planes` and assigning each group to one:

```json
{
  "planes": {
    "body": {
      "css": "top: 4%; right: 4%; width: 720px; display: flex; flex-direction: column; align-items: flex-end; gap: 14px;"
    },
    "crown": {
      "css": "top: 32%; left: 0; right: 0; text-align: center;"
    }
  },
  "groups": [
    { "id": "cg-0", "plane": "body",  "css": "font-size: calc(0.08*var(--h)); font-weight: 600; font-style: italic;", ... },
    { "id": "cg-1", "plane": "body",  "css": "font-size: calc(0.10*var(--h)); font-weight: 800;", ... },
    { "id": "cg-2", "plane": "crown", "css": "font-size: calc(0.20*var(--h)); font-weight: 900; text-transform: uppercase;", ... }
  ]
}
```

The plane CSS owns positioning (top/left/right + display:flex/grid/block). Per-group CSS owns **typography only** (font-size, weight, style, case, letter-spacing, line-height). **No top/left/right/position on a group when it's inside a plane** — the parent decides.

**Typography is agent-authored per group, NOT looked up from a preset.** The `cinematic-cream` template locks only the DNA (Inter family, motion curves, `mix-blend-mode`, `cap_color` palette, text-shadow/filter derived from luminance). Everything else — `font-size`, `font-weight`, `font-style` (italic vs upright), `text-transform` (uppercase vs mixed), `letter-spacing`, `line-height` — is written by the agent, per group, in `plan.json`'s `css` field. No "cap-1 must be italic 600, cap-2 must be uppercase 900" rule. Read the scene + transcript, pick typography that matches the semantic role (narrator/body/emphasis/climax) and the visual weight the frame can carry. See [references/typographic-moves.md](typographic-moves.md) for _recommended_ combinations — treat as palette, not schema.

When to use planes:

- **Multi-phrase sentence in one home zone** — use a flex column plane. All body caps inside accumulate/stack automatically, no collision math. This is the memory-wall pattern.
- **Body zone + climax zone** — two planes: body (flex stack) + crown (center). Body caps live in body-plane; climax pops in crown-plane. Cross-plane transition = earned break.
- **Any clip with ≥3 body phrases** — single-plane flex stacking beats hand-placing each group's top% every time.

### Picking the plane anchor — find the ACTUAL clean zone in THIS scene

**Do not default to "right column" because memory-wall did.** Memory-wall worked because its subject sat left-center with a clean wall on the right. Different scenes have different clean zones:

- **Subject centered (most selfies, vlogs)** → top band above the head is usually cleanest (clear sky / ceiling / wall). Right or left columns get eaten by shoulders or hands.
- **Subject left (interview with right-column backdrop)** → right column cascade (memory-wall classic).
- **Subject right (reverse interview)** → left column cascade.
- **Two-shot or busy background** → may need to use a corner, or sit the caption on the ONE surface that's consistently clean (a solid jacket, a whiteboard, a ceiling).

Before writing plane CSS, look at a frame sample. Mentally trace the subject's bounding box and hand-gesture reach. The plane anchors into whatever's LEFT — negative space where the cascade won't be eaten by body or busy backdrop.

Small-size italic narrator text is especially vulnerable — it disappears into mottled backgrounds or body occlusion. Keep smallest caps in the cleanest part of the zone, not at the edges where subject extends.

When NOT to use planes (the remaining free-mode use cases):

- **1–2 isolated groups** with deliberately different positions — free mode is simpler.
- **Specific clip-spanning motion** where a group needs to drift/move independently.

Free mode (no `planes` declared) is still supported — each group's CSS owns its own top/left/right. Use when you genuinely need bespoke per-group placement.

Wrong: "primary slot for 9:16 is top:12%". That's a lookup; it produces generic output.
Right: look at THIS scene, find where captions will read cleanly, respect the speaker's framing, and pick a composition pattern that serves the content's narrative arc.

Example patterns an agent can pick (not exhaustive, not ranked):

- **Right cascade accumulating** (memory-wall): all groups right-aligned, stepping down in top%, they stay — final frame shows a poem. Works when subject is centered-left and there's clean right-column space.
- **Center stack climax** (Sunset): italic openers at top, upright pivot mid, uppercase climax takes the full top as the final beat, earlier groups fade out before climax. Works when subject is centered and scene has clean sky + clean below-shoulders.
- **Left-right split** (memory-wall variant): narrator voice on one side italic, statement voice on the other side upright, climax takes center. Works when subject has off-center clean zones.
- **Single-line subtitle rail**: legit choice when scene is dense and no clean zones exist (busy background, subject fills frame). This is the "accessibility" fallback, not the default.

Whichever pattern is picked, the agent's job is to **justify the choice**: "Subject is on the left, clean gradient sky on the right — I'll cascade right-aligned so captions have clean backdrop throughout." Write that reasoning as a `_notes` field in plan.json so the intent is inspectable.

Anti-patterns to NEVER ship:

- Alternating top↔bottom per chunk — reads as chaos (one sentence's words appearing in different vertical zones).
- Locking every group to the same `top` value — reads as subtitle track, kills cinematic feel.
- Placing body/narrator groups directly over the face when clean space exists — the Vogue-embed effect is for the CLIMAX word only, not default behavior.
- **Dropping transcript words to fit caps in fewer blocks.** NEVER. Verbatim is verbatim. If 4 caps don't fit in 1 block, split into 2 sub-blocks of 2 caps each — keep all words. The only legitimate drops are SKILL.md's filler list (um/uh/exact-stutters/self-corrections), not "we ran out of room."
- **Mechanically aligning all caps in a plane to share `out` time.** This makes everyone stay on screen longer, _increasing_ density. Wrong fix for the no-jump problem. Correct fix: split natural-handoff caps into separate blocks where each block's caps share `out` (same exit moment, no jump), and `next_block.in ≥ prev_block.out` (clean handoff between blocks, no overlap).

**Block boundary three laws** (must hold for every plane in every plan):

1. Within a block, all caps share `out` — they fade together, no per-cap exit jitter.
2. Between adjacent blocks in the same plane, `next_block.in == prev_block.out` (or `next.in > prev.out` with a small gap). Caps from prev_block must FULLY exit before next_block's caps mount; otherwise the prev cap's exit at its `out` time triggers a flex reflow that yanks next_block's already-mounted caps upward = jump.
3. `prev_block.out ≤ next_block.first_word.start` so next_block's first word still animates from its real spoken time (the in-time only constrains when the container mounts; word animations fire at their per-word `start`).

A common mistake: setting `next_block.in` slightly EARLIER than `prev_block.out` to give the new block a head-start fade-in, while prev_block still has a different (later) `out`. The 50-300ms overlap looks invisible in the plan but produces a visible jump on screen because two caps with different `out` coexist in the same flex stack.

- **Vertical gap between body bottom and crown top.** Looks disconnected ("The pace is" at top:3% with "UNPRECEDENTED" at top:20% creates 144px void). Bond the setup phrase + climax word in the SAME crown plane via flex-stack — the Street_Talk "and being / CONSISTENT" pattern.

### Zone coherence — one sentence, one home zone

**Never switch spatial zones mid-sentence.** A full semantic unit (sentence or clause) should live in ONE home zone (e.g. right column, or top band). The viewer's eye follows the phrase group. Ping-ponging between top/bottom/left/right for consecutive phrases of the SAME sentence reads as chaos — the reader has to re-find the caption every beat.

Zone changes are earned at:

- **Sentence boundaries** (period, long pause, topic shift) — allowed to move to a new home zone, often paired with a tonal shift (italic body → uppercase emphasis)
- **The single climax** — the one break from the home zone, always allowed, often takes center-full-width

Heuristic: if you find cg-N and cg-N+1 are in different zones (top vs bottom, left vs right) AND they're parts of the same sentence (no period/long pause between them), you've violated zone coherence. Fix by picking one home zone for the whole sentence.

### The climax has to POP — not just "be a bit bigger"

A climax caption that's the same size as body emphasis is a failed climax. The viewer's eye needs a clear visual break: THIS is the payoff.

**Size the embedded climax to the FRAME, not to the rail (two-track rule).** The climax is a hero — its size is anchored to frame height: **`font-size: calc(0.16–0.22 * var(--h))`** (default ~0.18–0.20h; go bigger for more impact — it should dominate the frame, only width/readability caps it). It is **NOT** a multiple of the rail: the rail is a small standard subtitle (~0.045h), so "≥1.8× the rail" would yield a tiny, weak climax. Express rail, embed-body and embed-climax all as `var(--h)` fractions so they scale across resolutions — **never hardcode px**. The ratios below are a **floor / hierarchy check** (the climax must clear body-emphasis by ≥1.8× and the smallest tier by ≥2.5×), not the climax's sizing basis. Width is the real ceiling: shrink only if the word can't fit the frame at a legible size (see "Readability is a hard constraint").

Contrast floor — the climax must at least clear these:

| Ratio                      | Feels like                                                                                  |
| -------------------------- | ------------------------------------------------------------------------------------------- |
| **1.0-1.3× body emphasis** | Flat — the climax is just another beat. Fix it.                                             |
| **1.5-1.8× body emphasis** | Noticeable but not explosive. OK for subtle payoffs, weak for punchlines.                   |
| **≥1.8× body emphasis**    | Pops. Viewer's eye snaps to it. Target for "punchline" / "reveal" / "reaction" climaxes.    |
| **≥2.5× body italic**      | Compare against the SMALLEST body tier too — the multi-tier jump is what creates hierarchy. |

A quick audit script — before shipping, dump every plan's max-body vs max-crown font-size ratio, and flag anything <1.8×:

```js
const bodyMax = Math.max(...plan.groups.filter((g) => g.plane === "body").map(fsize));
const crownMax = Math.max(...plan.groups.filter((g) => g.plane === "crown").map(fsize));
const ratio = crownMax / bodyMax; // aim for ≥1.8
```

**Split pattern — when one word alone should carry the climax.** If the climax phrase is 2+ words ("and being consistent", "not even kidding") and one of them is the actual payoff, split into setup + payoff BUT keep them spatially bonded:

- Setup words → small italic narrator (soft, trailing off) on line 1
- Payoff word → massive uppercase on line 2, same plane, flex-stacked directly below
- **Both groups live in the SAME (crown) plane**, flex-column, gap 4-8px. Not two different planes — otherwise "and being" sits in the top band and "CONSISTENT" sits mid-frame and they read as two unrelated captions.

Crown plane CSS for split climax:

```
"crown": "top: 26%; left: 0; right: 0; display: flex; flex-direction: column; align-items: center; text-align: center; gap: 6px;"
```

Street_Talk canonical example: original was `"AND BEING CONSISTENT"` all in crown at 0.085h (same as body emphasis, ratio 1.0×). Rewrite: crown plane becomes a flex-column; `"and being"` → 0.055h italic at top of column (line 1); `"CONSISTENT"` → 0.11h 900 uppercase stacked below (line 2). The payoff POPs because of size contrast within a bonded 2-line composition, AND it crosses the subject's head since the crown column is anchored at ~26-30% top.

Timing: both groups share `out` (at clip end or block boundary) so neither fades alone — they hold together as one composition. Setup enters first (soft fade-in), payoff enters later when the word is spoken (snappier present scale-in).

**Readability is a hard constraint — bleed can't eat the word.** Your first move when a climax word overflows is NOT "accept the bleed, it's cinematic" — it's "shrink to the largest size that fits." Compute the max font size that fits the frame:

```
max_font_px = frame_width / (chars × char_ratio × letter_spacing_mult)
```

For "CONSISTENT" (10 chars), uppercase 900 (char_ratio 0.62), letter-spacing -0.05em (mult 0.95), 720px frame: `max = 720/(10*0.62*0.95) = 122px ≈ 0.095h`. Use 0.09h for a small safety buffer. The word fully occupies the frame edge-to-edge — that's already maximum visual impact without losing any letter.

Cinematic bleed is only OK when **a few pixels of 1-2 outer letters** get mild cropping from scale-in animation or sub-pixel rounding — that reads as "bigger than frame." Any bleed that forces the reader to mentally complete missing letters (>15-20px per side on a 10-char word) is a BUG, not style. "ONSISTE" with 80px each side clipped is NOT a valid climax — it's broken.

Hard rules:

- **Readable first.** Every letter should be fully on-screen, with at most a few-px crop on the first/last letter.
- **If max legible size doesn't meet ratio target**, raise impact via _other_ signals (weight 900, all-caps, bg head-cross, multi-line composition with setup word above), not via illegible bleed.
- **Never bleed body/narrator captions.** Only the crown/climax is allowed any edge crop at all.
- `OCCLUSION_SKIP=1` is an escape hatch for iterating; the shipped plan should pass `check-occlusion-v2.py --strict` (pixel-perfect via headless Chromium DOM).

### Edge breathing + climax-crosses-head (bg showcase)

Two placement tweaks that separate "looks like a caption sitting in the scene" from "looks like a pasted subtitle":

- **Non-climax caps need edge breathing room.** Body-plane groups should NOT hug `left: 0; right: 0` flush to the frame. Add `left: 3-6%; right: 3-6%` (or `right: 30-40px` for pixel-anchored planes). The breathing sells the compositional choice — flush edges read as "default subtitle track", indented edges read as "considered composition". Top edge the same: `top: 2-6%` not `top: 0`.

- **The climax SHOULD cross part of the subject's head — deliberately.** This is what makes bg-layer captions feel cinematic instead of just "text floating above the speaker". The effect is strongest when 20-40px of the word's last line dips behind the hair or forehead — viewer reads the word clearly AND sees it being occluded at the edge. Not "covered by the face" (unreadable), just "grazed by the hair" (legible + cinematic).

  How to place the crown with intent:
  1. Sample the matte PNG at climax time: `f_{frame_idx:04d}.png` where `frame_idx = round(climax_time * fps)`.
  2. Scan rows from top until `alpha > 128` — that y is the subject's topmost pixel (usually hair).
  3. Position crown so the _bottom of the last line_ lands 30-80px below that y. For a 2-line crown at 0.085h on 1290h frame, line heights ~104px each; total height ~200px. If subject top is at y=445, set `crown.top` such that `top + height = 445 + 50 ≈ 495` → `top = 295px ≈ 23%`.
  4. Verify with checker — the HERO/crown peak occlusion target is ~30–55% (the embed effect; the head crossing the middle). Below ~15% it reads as a floating label (the hero-weak advisory fires); above ~65% the word is eaten (the gate FAILs). Small narrator caps: ~20–30% texture.

  Practical rule of thumb: **crown peak occlusion 3-10%** is the sweet spot. The checker's number tells you directly — not a subjective "it looks good" judgment.

  **Crown defaults are scene-specific, not template-wide.** Don't set `crown.top: 6%` because "that's what the previous case used." If the subject's head_top is at y=380 (29% of frame) and your crown is at top:6% with 100px height, the crown bottom lands at y=200 — 180px ABOVE the head, completely floating in sky, zero embed effect. Always sample the matte once per case and compute `crown.top` from `head_top - crown_height + (30 to 80)` so the crown's bottom edge dips into hair. This was a real bug across 6 cases until enforced.

  FG-layer crowns skip this entirely (they render on top, no occlusion to showcase) — FG is announcement, not embed.

### Pre-render occlusion + frame-overflow gate

**`check-occlusion.cjs` (canonical, pixel-perfect)** is the only occlusion checker in `scripts/`. It auto-runs `measure-layout.cjs` (headless Chromium via Puppeteer) which loads the compiled `index.html`, seeks the GSAP timeline to 4 sample times per group (15/40/65/90% through window), and queries `getBoundingClientRect()` on every `.cap` and child `.w` span. Output is `_layout.json` with the actual rendered pixel coordinates of every word at every sample. Then it computes per-word, per-cap occlusion against the real subject-matte alpha (read via `sharp`):

- **Per-word peak occlusion** — the load-bearing metric: a 5-word phrase where 3 words are fully eaten averages the same as 8 words half-eaten, but only one of those is shippable, so per-WORD peak is what gates. Per-word peak ≥65% = `obliterated`, ≥35% = `warn`. A `WARN` band hit on a single climax word is OK (cinematic edge crop); two adjacent words obliterated is not.
- **Per-cap aggregate** — avg + peak occlusion across all samples. Cap fails at peak ≥50%.
- **fg-layer caps are auto-skipped** (they render above the matte; no occlusion possible).

**Why measure the real DOM, not estimate a bbox.** An earlier heuristic estimated each word's box from `char_ratio × font × text_length` and was wrong in both directions. Startup_Host case: it reported `cg-1 "podcast." OK at 9%/12%` because it guessed y=134 (above the subject's head), but the rendered DOM was at y=209 (mid-head) → actually `FAIL 54%/60%`. Same case, it reported `cg-3 "diving deep" FAIL at 76%/77%` while the real measurement was `WARN 36%/40%` (the box is wider than the head — most pixels clear, only descenders dip into the silhouette). Estimation is retired; `check-occlusion.cjs` measures the rendered pixels.

**Frame-edge overflow** — the checker also flags when a measured word bbox extends past the canvas (text the ffmpeg crop would clip), reported as `cg-N  [overflow] "word" off-frame: left Xpx, right Ypx (@Ts)`. Because cropped body text is always wrong, a clear glyph clip (>8px past an edge) **fails `--strict`**; a sub-glyph graze (≤8px — the first/last-letter crop the climax is allowed) is a non-blocking `[overflow-warn]`.

**The checker reports facts, the agent applies judgment.** A FAIL is NOT an automatic "must re-layout" signal — it's evidence to interpret. Two competing goals coexist:

1. **Readability** — every sentence must remain comprehensible to the viewer
2. **Cinematic embed** — partial occlusion is the desired vogue-masthead effect; zero occlusion looks like a pasted subtitle

When the checker reports a per-word obliteration (≥65%), apply this **3-step decision rule** before changing anything:

**Step 1 — POS check.** Is the obliterated word a function word (the / a / an / of / in / on / at / to / I / it / is / are / was / be / that / this / which / and / or / but)? Function words are grammatically optional in most contexts — a 100%-eaten "the" or "I" or "of" usually does NOT break the sentence. Content words (nouns, verbs, adjectives, climax words) carry meaning — they MUST stay legible.

**Step 2 — sentence parse without the word.** Mentally delete the obliterated word from the sentence. Does the remainder still convey the meaning?

- "like this are why I travel" minus "I" → "like this are why travel" — first-person obvious from context, **OK**
- "take it all in" minus "all" → "take it in" — complete English idiom on its own, **OK**
- "a whole lot of information" minus "of" → "a whole lot information" — slightly broken but recoverable, **OK**
- "diving deep into startups" minus "deep" → "diving into startups" — works fine, **OK**
- "diving deep into startups" minus "diving" → "deep into startups" — loses the verb, sentence breaks, **NOT OK**

**Step 3 — cinematic check.** A single content word at 30-65% partial occlusion is the _intended_ embed look (vogue-masthead "subject grazes the text"). Don't "fix" it. Only escalate to re-layout when:

- a content word is ≥80% gone (not just partially eaten — almost fully missing)
- two or more adjacent words are ≥50% obliterated (sequential damage compounds; "X X X really takes" with first three eaten is unrecoverable)
- the obliterated word IS the climax / key noun the viewer is supposed to read (e.g. the subject of a sentence, a brand name, a product feature)
- key letters of a climax word are eaten (e.g. "STARTUP" with the S gone reads as "TARTUP" — the partial-letter pattern matters more than the whole-word percentage)

**Real examples from the 13-case batch** — all four caps below got a per-word obliteration FAIL, all four were correctly judged OK by this rule:

| case               | obliterated word @% | sentence                              | rule applied                           |
| ------------------ | ------------------- | ------------------------------------- | -------------------------------------- |
| Sunset_Stroll cg-1 | "I" 100%            | "like this are why I travel"          | function word + parses without it → OK |
| Sunset_Stroll cg-3 | "all" 75%           | "take it all in"                      | "take it in" is a complete idiom → OK  |
| yt_gtds cg-6       | "of" 69%            | "a whole lot of information"          | function word + recoverable → OK       |
| AI_Insights cg-2   | "that" 66%          | "that don't replace human creativity" | relative pronoun, optional → OK        |

**When the rule says re-layout** — what to change:

- per-word obliterated AND content word AND breaks readability → switch THAT specific cap to `layer: "fg"` (per-cap override, not whole-plan), OR shrink/reposition, OR split the cap into a different block where the subject isn't crossing it.
- per-cap fail (peak ≥50%) when the whole stack is in the head zone → lift y or shrink the block.
- frame-edge overflow → shrink font OR tighten `letter-spacing` OR split the phrase. Cropped text is ALWAYS wrong, no aesthetic excuse.

**Override** (for iterating before shipping): `OCCLUSION_SKIP=1 bash render-and-composite.sh <project>` — don't ship past this without applying the 3-step rule and either fixing or consciously accepting each FAIL.

### Accumulation capacity — split into blocks when the plane can't hold more

Each scene's clean zone has a finite capacity for simultaneously-readable caps. **The agent must judge this before writing the plan**, not rely on CSS `bounded height` / `overflow: hidden` — those cause visual bugs (caps overflow off-screen, are clipped, or content is centered outside the plane bounds).

Rule of thumb (capacity = max caps visible SIMULTANEOUSLY in one block):

- **9:16 portrait, narrow right-column plane** (width ~380-420px) → max **2 caps** per block. Long phrases wrap to 2-3 lines; a 3rd cap starts spilling into the subject.
- **16:9 landscape, subject centered** (e.g. selfie vlog) → max **2 caps** per block. Even if the frame is wider, a centered subject leaves narrow columns on both sides and hands reach far.
- **16:9 landscape, subject on one side** (e.g. interview with clean opposite side — the memory-wall canonical scenario) → max **3-4 caps** per block. This is where accumulation shines.
- **Top-band full-width plane** (either orientation) → max 1-2 caps before overflow into face zone.

Capacity is about the specific scene — same orientation can have very different room depending on subject position and background complexity.

When your phrase count exceeds the capacity, split into multiple accumulation blocks at **sentence or clause boundaries**. Example memory-wall canonical on a 12-word clip:

- Block 1 (0–4.85s): `"Some memories feel soft"` + `"like old film"` — 2 caps accumulate, fade together at 4.85s
- Block 2 (4.90–8.04s): `"but suddenly"` + `"EVERYTHING IS SHARP AGAIN"` — 2 caps accumulate on a cleared plane

All caps in one block share the same `out` time (sentence boundary). Next block's `in` > previous block's `out` so the plane clears before the new accumulation starts. This gives each cap ~3-5s of read time, avoids overcrowding, and matches memory-wall's cinematic rhythm.

**Why the shared `out` is load-bearing.** The template's exit path is `tl.set(sel, { opacity: 0, display: "none" }, g.out)` — at `out`, each cap releases its flex space so the NEXT block's caps can take the top slot from the plane's origin (no permanent downward drift). If caps inside the same block had staggered `out` times, the plane would reflow mid-block (cap 1 vanishes, caps 2-3 jump up, cap 4 arrives… visible jitter). With block-synchronized `out`, the reflow happens ONCE at the boundary — invisible to the viewer because the block fades as a unit. **Never stagger `out` times within an accumulation block.** If you need individual fade-outs, you're not accumulating — switch to stagger-fade pattern (one group at a time, no flex plane needed).

**Anti-pattern: bounded `height` + `justify-content: center` on planes.** Do NOT write `height: 70%; justify-content: center` on a flex-column plane hoping to vertically center the caps. When the block's total cap height exceeds the bounded height, flex centering pushes content OUTSIDE the plane bounds on BOTH top and bottom — the topmost cap ends up above the frame (off-screen, unreadable). Instead:

- Let the plane grow with its content — set `top:` (origin anchor) and let caps stack down naturally.
- Use `gap:` between caps; don't try to "center the stack".
- Trust the block-capacity rule above to keep the stack within the clean zone — don't paper over overflow with `overflow: hidden` (it clips the very caps you wanted the viewer to read).

### Accumulation — vertical spacing, not single-line

Accumulation works with multi-line phrases too — memory-wall canonical's groups are multi-word, right-aligned cascade in 16:9 landscape, and several wrap to 2 lines. They don't collide because **each group's `top` is placed below the previous group's bottom extent with a margin**.

Math you must do before shipping accumulate:

For each group, estimate its rendered height:

- `line_count ≈ ceil(chars × font_px × 0.72 / usable_width_px)` (0.72 = Inter 900 uppercase; use 0.60 for italic/regular)
- `group_height_pct ≈ line_count × font_size_pct × line_height × 100`

Then for each pair (cg-N, cg-N+1) you want to coexist:

- `cg-(N+1).top ≥ cg-N.top + cg-N_height_pct + 2%` (margin)

If the math doesn't fit in the available clean zone (top-clear + bottom-clear zones minus face/body bbox), you have two options:

- **Shrink fonts or merge phrases** so they fit.
- **Switch persistence to stagger-fade** (groups don't coexist, no spacing constraint).

9:16 portrait specifically: clean zone is typically top 0–32% + bottom 72–90%, split by the subject. That's ~50% of frame height usable. At 15% per 2-line group + 2% margin, you can fit ~3 accumulating groups in top zone. A 4th must either (a) be smaller to fit, (b) go to the bottom zone (zone change allowed at sentence boundary), or (c) replace an earlier one as climax.

Landscape 16:9 has ~70–80% usable vertical — more room for 4–5 accumulating groups, which is why memory-wall canonical works cleanly there. Don't give up accumulation on portrait; just do the arithmetic first.

### Persistence — how long each group stays (agent decides per case)

The group's `out` time is not a default, not a rule, not a formula. It's an aesthetic choice. The same 5-group plan renders very differently depending on whether groups accumulate, stagger, crossfade, or roll — and each is right for different content.

Patterns to pick from:

| Pattern                                     | What happens                                                                 | Good for                                                                                            | Bad for                                                            |
| ------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| **Accumulate-through-climax** (memory-wall) | All groups stay on screen until climax arrives, then all fade together       | Poetic builds; emotional crescendo; ≤5 groups where each word matters                               | 8+ groups (screen crowds); punchy comedic beats (breaks the laugh) |
| **Stagger-fade** (Sunset-style)             | Each group fades ~0.8–1.5s after its words end; next enters into clean space | Independent punchy beats; clip where each phrase is its own moment                                  | Long poetic builds that want the stanzas to coexist                |
| **Crossfade-handoff**                       | Current group fades during next's entrance (~200ms overlap)                  | Dense verbatim (6+ groups in short clip); conversational flow                                       | Short sparse clips — feels like subtitle track                     |
| **Rolling window**                          | Keep N=2–3 visible; earliest fades when N+1 enters                           | Longer transcripts (15s+) where full accumulation would crowd, but some layering is still cinematic | Clips <8s (rolling isn't perceptible)                              |

Decision axes:

- **Narrative arc** — crescendo/revelation → accumulate; list of independent points → stagger; dense conversation → crossfade
- **Group count** — ≤4: any pattern works. 5–7: prefer stagger or rolling. 8+: crossfade is usually forced.
- **Scene density** — busy background eats stale captions; accumulate only works when backdrop is clean enough that older captions stay legible.
- **Clip length** — ≥12s erodes accumulation (viewer loses connection to earliest caption); prefer stagger or rolling for long clips.

Write the reasoning in `_notes` in plan.json so the decision is inspectable and the agent's intent survives into review. e.g.:

```json
"_notes": "Morning_Jog: 5 groups in 8s. Narrative is crescendo (Okay→honestly→changed my life→KIDDING), and background is clean (trees, sky). Accumulate-through-climax — all 4 earlier groups stay visible so KIDDING lands on top of a built-up poem. Cascading right with varied top%/alignment so the stack reads as composition, not a subtitle column."
```

This is where per-case judgment earns its keep. The skill gives you a palette of patterns; the pick is yours.

### Spatial coherence — the main slot rule

**Every plan has ONE primary slot.** Most groups land there. Secondary slots exist only for intentional rhythm-breaks (once every ~30s, never per-chunk alternation). The old autogen rotation that threw each chunk to a different corner violated this and read as chaos.

Choose a primary slot based on scene:

- **9:16 portrait, subject mid-frame** → primary slot is `top: 4–20%` (sky / clean zone above the head). Secondary: `top: 72–78%` (below shoulders, above UI). Never: bottom 22% (UI/hand zone per [rule 17](aesthetic-principles.md)).
- **16:9 landscape, subject left** → primary slot is the right column (`left: 50–55%`, right-aligned or centered in the column). Secondary: below subject.
- **16:9 landscape, subject centered** → primary slot is `top: 10–20%` OR `top: 70–80%`. Pick one and stick.

Within the primary slot, vary **size and weight** to create hierarchy (narrator small, body mid, emphasis large, climax huge). That's the rhythm inside a coherent position. Secondary slots are for **break-rhythm moments**, not for adjacent chunks just to "avoid collision".

Collision validator still runs — but now the agent should be hitting it only in exceptional cases (intentional layering), not because every chunk is in a different place.

**Verbatim recipe** — every word is shown, but it is **not** a flat TikTok subtitle rail. Use the cinematic DNA: per-group typography variation, position variation, size modulation. Italic openers, upright pivots, uppercase climaxes, spatial cascade, per-word soft/present tones — all of it.

Chunking: walk the transcript and split into groups of **1–4 words** at pause boundaries (>0.25s gap = hard cut; within a phrase, chunk by emotional beat — short 1–2 word chunks for emphasis words, longer 3–4 word chunks for connective tissue).

Typography per chunk — treat each chunk as a mini-beat. The sizes below are a **starting palette, not rules.** The agent picks the actual `font-size` per group based on the scene's clean-zone width, the phrase's character count, and the desired visual weight relative to neighboring groups. Shift by ±30% whenever the scene demands (narrow portrait column → shrink; wide landscape hero → enlarge). Pick from [typographic-moves.md](typographic-moves.md):

- **Filler / connective** ("you know", "like", "so I was", pronouns, conjunctions) → italic 500–600, small (~0.05–0.08h starting point), softer tone, subordinate position. These read as narrator voice, not statement.
- **Emphasis / content words** (verbs, nouns carrying meaning) → upright 600–800, mid (~0.08–0.12h starting point), centered or cascaded position, present tone.
- **Climax / hook words** (the payoff word of the sentence) → uppercase 800–900, large (~0.14–0.22h starting point), full-frame or near-frame-width, present tone with scale pop.

**Size is always relative, never absolute.** What matters is the _contrast ratio_ between a group and its neighbors inside the same plane — climax should be ~2× the emphasis, emphasis ~1.3–1.5× the narrator. An agent that writes `0.08h / 0.08h / 0.08h` for three caps in a plane has failed regardless of which bucket it picked from. The hierarchy is the point.

Spatial composition: use the full frame as a canvas. Filler chunks tuck to corners or sit small at subordinate positions; emphasis chunks claim the middle; climax chunks own the frame. **Don't lock everything to `bottom: 12%`** — that's the boring-subtitle trap.

Time windows: either spatially-separate overlapping groups (cascade, poem-accumulation) or hand-off so only one chunk shows at a time. Handoff at the same slot reads jittery (too many cuts in a short window) — prefer spatial cascade where adjacent chunks coexist at different positions, or accumulation blocks bounded by sentences.

Layer: default `"bg"` for any chunk whose position has clean scene around it (sky, wall, the area above/below the subject) — that's the embed effect. Use `"fg"` only when the chunk's position would be ≥80% occluded by the body (e.g. chyron-style chunks at bottom in 9:16 portrait where the torso fills that zone).

**Climax chunks across the subject — readability first.** The Vogue-masthead effect (big uppercase crossing the body) is the most cinematic move, but only if the word is still readable. Use `bg` across the subject when **all** of:

1. The caption is multi-line (≥2 lines), so even if one line is mostly eaten, the others stay clean and the viewer can read the phrase. Single-line captions should never sit directly over the face/body — either reposition above/below the subject, or use `fg`.
2. The subject's bounding box covers ≤40% of the caption's total pixel area. For 9:16 portrait with a typical head-and-shoulders subject (~30% vertical × ~50% horizontal at mid-frame), a caption at `top: 16%` spanning 4 lines × 0.14h meets this — the face eats one line, the other three read clean. A caption at `top: 40%` sitting directly on the face does not.
3. Each individual word has visible letter endpoints on at least one side — a word that's split mid-letter with only interior strokes showing is unreadable even if "25% of total pixels" are covered.

If any of these fail, either **reposition** the BG caption to clean scene (above the head / below the shoulders / into an off-center corner), or **fall back to `fg`**. FG for climax is the right call when the scene has no clean zone big enough — don't force the embed aesthetic at the cost of legibility.

Rule of thumb: if you flattened your plan to just the climax chunks, the result should still read as a satisfying tweet-length headline. If it doesn't — you've under-varied your typography and the climax doesn't pop hard enough.
references/direction-catalog.md
# Direction catalog

10 distinct aesthetic directions the skill can express. Each is a complete look: font, motion, color, placement, rhetorical defaults. The agent picks by **content tone × shot type × platform**, not preset name.

Current shipped templates (`modes/cinematic/*`) cover about 30% of this catalog. The rest are future ship-ready aesthetics.

Classification matrix — pick direction by intersection:

| Tone ↓ / Platform → | 9:16 portrait                            | 16:9 landscape           | 1:1 square               |
| ------------------- | ---------------------------------------- | ------------------------ | ------------------------ |
| **Documentary**     | documentary-dignified (vertical variant) | documentary-dignified    | broadcast-dignified      |
| **Conversational**  | portrait-header + default                | memory-wall OR champion  | broadcast-dignified      |
| **Energetic**       | high-energy-vlog                         | high-energy-vlog-wide    | high-energy-vlog-sq      |
| **Poetic**          | lyrical-poem-on-wall (portrait)          | lyrical-poem-on-wall     | chapter-card             |
| **Keynote/tech**    | — (9:16 not ideal)                       | tech-keynote-confident   | tech-keynote-confident   |
| **Investigative**   | —                                        | investigative-typewriter | investigative-typewriter |
| **Music video**     | k-pop-lyric                              | k-pop-lyric              | k-pop-lyric              |

---

## Direction specs

### 1. documentary-dignified _(Errol Morris / PBS Frontline)_ — Standard-mode direction (no prebuilt Cinematic template)

- **Family**: Söhne Mono OR GT Sectra for name cards; Inter for body
- **Weights**: 500 body, 700 name card
- **Color**: bone `#F5EFE6` on charcoal, OR charcoal `#1A1A1A` on bone. No accent.
- **Motion**: burn-in (no animation). Held 0.5s past last word.
- **Placement**: bottom-left block, 10% margin, locked position for whole interview.
- **Rules**: 2-line max. Speaker name card once at first utterance + after each cut. No emphasis styling — gravitas IS the style.
- **Rhetorical**: aggressive filler suppression. Every word is chosen.

### 2. cinematic-noir _(Kyle Cooper / Se7en)_

- **Family**: Distressed serif (Hoefler Text) + tight mono for detail
- **Weights**: 700 mostly; thin italic ONLY for whispers
- **Color**: off-white `#E8E0D0` with deep red `#8B0000` single-word accent for menace
- **Motion**: typewriter with 10% chance of 1px jitter on entry; scratched etch for chapter beats
- **Placement**: off-axis — avoid dead-center, slightly high-left or low-right
- **Rules**: no emoji, no rounded corners, everything hand-feeling

### 3. tech-keynote-confident _(Apple / Jony Ive ad)_

- **Family**: SF Pro Display OR Inter, nothing else
- **Weights**: 800 headline keyword, 400 context
- **Color**: pure white on any bg; `mix-blend-mode: difference` fallback for bright scenes
- **Motion**: swipe-reveal from bottom via clip-path (400ms); exit fades 400ms
- **Placement**: dead center, large. NOT lower-third.
- **Rules**: one caption on screen at a time. 2–5 words per caption. Long held silences between. Feels like slides.

### 4. lyrical-poem-on-wall _(extends current memory-wall template)_

- **Family**: Serif with personality — EB Garamond / GT Sectra / Caslon Italic
- **Weights**: 400–500 only. **This direction rejects bold.**
- **Color**: picks up wall via `mix-blend-mode: overlay`, opacity 0.85, no stroke
- **Motion**: etch in 600ms, hold 1.5–3s, etch out
- **Placement**: always on a surface in the scene (back wall, foam, whiteboard). Uses matte pipeline.
- **Rules**: captions are **fewer** than spoken text — 40–60%. Intentional silences. No other tool does this; strong differentiation.

### 5. high-energy-vlog _(controlled Hormozi)_

- **Family**: Inter ExtraBold, uppercase
- **Weights**: 900 always
- **Color**: white body + ONE accent (`#FFD600` or `#00E676`) + narrow 2px dark stroke
- **Motion**: word-pop with elastic, 60ms stagger; active word scales 1.15× on speech onset
- **Placement**: center-frame, ~35% from top
- **Rules**: max 3 words per frame. Hard break at breath pauses. Accent color on ~15% of words. **One full silence every 10s** (what Hormozi imitators miss).

### 6. chapter-card _(Wes Anderson / Grand Budapest)_

- **Family**: Futura Bold (modern) / Archer (warm) / Bodoni (formal)
- **Weights**: 700 chapter title, 400 attribution
- **Color**: pastel hue sampled from scene
- **Motion**: full-frame static card between interview segments
- **Placement**: centered card for chapters; lower-left name card during speech
- **Rules**: chapter cards get their own beat (0.8s silence before, 0.4s after). Feels authored.

### 7. investigative-typewriter _(Frontline / 60 Minutes / Morris Interrotron)_

- **Family**: IBM Plex Mono OR JetBrains Mono
- **Weights**: 500
- **Color**: white with 3px shadow, no stroke
- **Motion**: typewriter char-by-char 25ms/char; cursor blink 600ms after last char
- **Placement**: bottom band with 40% opacity gradient up — **NOT a hard box**
- **Rules**: one line only, two-line max. Every caption ends with 1-frame cursor-hold before next enters.

### 8. k-pop-lyric _(music video typographic flex)_

- **Family**: oversized display serif (Playfair Display / GT Super) + tiny mono detail line
- **Weights**: display 900, mono 400
- **Color**: deep saturated single hue per song/segment
- **Motion**: cascade entries alternating sides; words cross subject with mix-blend
- **Placement**: intentionally LARGE — sometimes bigger than the face
- **Rules**: emotional/musical moments only. Never informational content. **Breaks rule #1 (subject wins) on purpose** — that's the whole point.

### 9. broadcast-dignified _(BBC / NYT documentary standard)_

- **Family**: Helvetica Now / Neue Haas Grotesk
- **Weights**: 500 body, 700 name
- **Color**: white on 40% black gradient pill (not hard box, not plain shadow — gradient is the signature)
- **Motion**: 200ms fade-up, 150ms fade-down
- **Placement**: bottom-center, 10% margin, title-safe locked
- **Rules**: strict BBC — 160–180wpm, 32–34 chars/line, 2 lines max, 1.5s min gap. "Export to broadcast" mode. Boring on purpose. Never wrong.

### 10. conference-lower-third-killer _(B2B but not lame)_

- **Family**: GT America / Aktiv Grotesk
- **Weights**: dual-weight name card — role 400, name 700, same size
- **Color**: scene-sampled accent on name, white on role
- **Motion**: name card swipes in from left 400ms on first appearance only; subsequent static
- **Placement**: lower-left card + active-word highlight inline in dialogue above
- **Rules**: mode for podcast clips, founder interviews, keynote pulls. Sophisticated without being arty.

---

## Which to ship next

Current templates: `memory-wall` (roughly direction #4), `champion` (roughly a mix of #5 and #9), `portrait-header` (default vertical).

Priority ship order based on competitive gap:

1. **documentary-dignified** — no one else does this well; Errol Morris aesthetic is an unserved niche
2. **lyrical-poem-on-wall** (as distinct template) — the one that most uses our matte-embed moat
3. **high-energy-vlog** — matches but out-designs Hormozi/Submagic presets
4. **tech-keynote-confident** — big for product launch videos
5. **investigative-typewriter** — niche but highly distinctive

---

## How the agent picks

1. Classify the clip: `tone × shot × platform` (see matrix above)
2. Look up direction in matrix
3. Read that direction's spec in this file
4. Translate spec into plan.json parameters OR clone the canonical HTML from `examples/`
5. Tune layout per scene (agent judgment, see `aesthetic-principles.md §1–18`)

If no direction fits cleanly → **Standard mode**. Don't fake-fit an ill-matched direction.
references/example-renders/champion.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
    <style>
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: #000;
        font-family: "Inter", system-ui, sans-serif;
      }

      #a-roll {
        position: absolute;
        inset: 0;
        width: 100%;
        height: 100%;
        object-fit: cover;
      }

      /*
        This scene has no flat wall — subject is center with a busy
        bookshelf backdrop. So instead of a wall-plane, we place a
        HEADER band across the top of the frame with subtle perspective
        (slight rotateX so it lies on the bookshelf rather than floats).
        Screen-blend picks up the shelf's darker patches.
      */
      #stage {
        position: absolute;
        top: 0;
        left: 0;
        width: 1920px;
        height: 1080px;
        perspective: 2400px;
        perspective-origin: 25% 50%;
        pointer-events: none;
        z-index: 2;
      }

      /*
        Left-column layout: subject fills x=550-1200, so the bookshelf
        at x=0-550 is the clean zone. Captions right-align there, with
        a mild perspective that makes them lie on the shelf.
      */
      .left-plane {
        position: absolute;
        /* Upper-left region. Hands gesture widely below y≈460, so we
           keep the plane above that line. Head sits to the right of
           x≈600, so x=40-720 here is the clean bookshelf zone. */
        top: 40px;
        left: 40px;
        width: 720px;
        height: 420px;
        transform-style: preserve-3d;
        transform: rotateY(4deg);
        transform-origin: left center;
        padding: 24px 36px;
      }

      /* Each cap stacks at the same absolute position within the plane
         so hidden captions don't reserve flex space. */
      .left-plane .cap {
        position: absolute;
        top: 24px;
        right: 36px;
        max-width: 648px; /* plane 720 - padding 36×2 */
      }

      /* A single "crown" caption placed center-stage for the payoff
         ("A WIMBLEDON CHAMPION") — deliberately crosses the head to
         deliver the emphasis via cinematic occlusion. */
      .crown-plane {
        position: absolute;
        top: 420px;
        left: 0;
        right: 0;
        text-align: center;
        pointer-events: none;
      }

      .cap {
        display: block;
        position: relative;
        line-height: 1.02;
        opacity: 0;
        mix-blend-mode: screen;
        color: #fff5df;
        text-shadow:
          0 0 18px rgba(255, 220, 170, 0.55),
          0 3px 8px rgba(0, 0, 0, 0.85);
        filter: brightness(1.12) contrast(1.08);
        will-change: transform, opacity;
      }

      .cap .w {
        display: inline-block;
        opacity: 0;
        will-change: opacity, transform;
      }

      /*
        Story beats sized to emphasize meaning:
        - intros / fillers = smaller italic
        - key statement phrases = bigger upright
        - emphasis phrases (incredible things) = largest bold
        - crown (Wimbledon Champion) = huge, center-stage, behind subject
      */
      .cap-intro {
        font-size: 52px;
        font-weight: 500;
        font-style: italic;
        letter-spacing: -0.005em;
      }
      .cap-phrase {
        font-size: 60px;
        font-weight: 600;
        font-style: normal;
        letter-spacing: -0.015em;
      }
      .cap-emph {
        font-size: 70px;
        font-weight: 800;
        font-style: normal;
        letter-spacing: -0.025em;
      }
      .cap-dream {
        font-size: 64px;
        font-weight: 700;
        font-style: italic;
        letter-spacing: -0.015em;
      }
      .cap-crown {
        font-size: 140px;
        font-weight: 900;
        font-style: normal;
        letter-spacing: -0.035em;
        text-transform: uppercase;
        line-height: 1;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="12.08"
      data-width="1920"
      data-height="1080"
    >
      <video
        id="a-roll"
        class="clip"
        src="source.mp4"
        muted
        playsinline
        data-duration="12.08"
        data-track-index="0"
        style="z-index: 1"
      ></video>

      <div id="stage">
        <!-- Left column: G1-G5 live here, on the clean bookshelf -->
        <div class="left-plane">
          <div id="cg-0" class="cap cap-intro">
            <span class="w" data-i="0">You</span>
            <span class="w" data-i="1">know,</span>
            <span class="w" data-i="2">for</span>
            <span class="w" data-i="3">me</span>
          </div>
          <div id="cg-1" class="cap cap-phrase">
            <span class="w" data-i="0">I've</span>
            <span class="w" data-i="1">had</span>
            <span class="w" data-i="2">this</span>
            <span class="w" data-i="3">kind</span>
            <span class="w" data-i="4">of</span>
            <span class="w" data-i="5">upbringing</span>
          </div>
          <div id="cg-2" class="cap cap-phrase">
            <span class="w" data-i="0">the</span>
            <span class="w" data-i="1">great</span>
            <span class="w" data-i="2">foundation</span>
          </div>
          <div id="cg-3" class="cap cap-emph">
            <span class="w" data-i="0">I've</span>
            <span class="w" data-i="1">achieved</span>
            <span class="w" data-i="2">incredible</span>
            <span class="w" data-i="3">things</span>
          </div>
          <div id="cg-4" class="cap cap-dream">
            <span class="w" data-i="0">dreaming</span>
            <span class="w" data-i="1">of</span>
            <span class="w" data-i="2">becoming</span>
            <span class="w" data-i="3">number</span>
            <span class="w" data-i="4">one</span>
          </div>
        </div>
        <!-- Center-stage crown: the payoff. Sits where the subject is,
             matte layering makes it *behind* him — the occlusion is the moment. -->
        <div class="crown-plane">
          <div id="cg-5" class="cap cap-crown">
            <span class="w" data-i="0">Wimbledon</span>
            <span class="w" data-i="1">Champion</span>
          </div>
        </div>
      </div>

      <audio
        id="a-roll-audio"
        src="source.mp4"
        data-start="0"
        data-duration="12.08"
        data-track-index="3"
        data-volume="1"
      ></audio>
    </div>

    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });
      const DUR = 12.08;

      /* Word-level transcript — ElevenLabs scribe_v2 */
      const GROUPS = [
        {
          id: "cg-0",
          in: 0.1,
          out: 1.45,
          tone: "soft",
          words: [
            { text: "You", start: 0.18, end: 0.24 },
            { text: "know", start: 0.28, end: 0.36 },
            { text: "for", start: 0.46, end: 0.62 },
            { text: "me", start: 0.66, end: 0.86 },
          ],
        },
        {
          id: "cg-1",
          in: 1.4,
          out: 3.35,
          tone: "soft",
          words: [
            { text: "I've", start: 1.5, end: 1.68 },
            { text: "had", start: 1.76, end: 1.94 },
            { text: "this", start: 1.98, end: 2.14 },
            { text: "kind", start: 2.2, end: 2.34 },
            { text: "of", start: 2.36, end: 2.44 },
            { text: "upbringing", start: 2.48, end: 3.06 },
          ],
        },
        {
          id: "cg-2",
          in: 3.5,
          out: 5.35,
          tone: "soft",
          words: [
            { text: "the", start: 3.56, end: 3.64 },
            { text: "great", start: 3.7, end: 3.9 },
            { text: "foundation", start: 3.96, end: 4.6 },
          ],
        },
        {
          id: "cg-3",
          in: 6.05,
          out: 8.3,
          tone: "present",
          words: [
            { text: "I've", start: 6.36, end: 6.5 },
            { text: "achieved", start: 6.62, end: 7.08 },
            { text: "incredible", start: 7.38, end: 7.86 },
            { text: "things", start: 7.88, end: 8.16 },
          ],
        },
        {
          id: "cg-4",
          in: 8.5,
          out: 10.4,
          tone: "present",
          words: [
            { text: "dreaming", start: 8.54, end: 8.84 },
            { text: "of", start: 8.88, end: 9.0 },
            { text: "becoming", start: 9.04, end: 9.4 },
            { text: "number", start: 9.48, end: 9.64 },
            { text: "one", start: 9.72, end: 9.8 },
          ],
        },
        {
          id: "cg-5",
          in: 10.8,
          out: 12.08,
          tone: "present",
          words: [
            { text: "Wimbledon", start: 10.92, end: 11.28 },
            { text: "Champion", start: 11.38, end: 11.84 },
          ],
        },
      ];

      GROUPS.forEach(function (g) {
        var sel = "#" + g.id;
        var words = g.words.map(function (_, i) {
          return sel + " .w[data-i='" + i + "']";
        });
        var isSoft = g.tone === "soft";

        /* Container visible just before entrance; single-layer fade is on words. */
        tl.set(sel, { opacity: 1, y: 0 }, Math.max(0, g.in - 0.01));

        g.words.forEach(function (w, i) {
          if (isSoft) {
            tl.fromTo(
              words[i],
              { opacity: 0, y: 8 },
              { opacity: 1, y: 0, duration: 0.42, ease: "power2.out", overwrite: "auto" },
              w.start,
            );
          } else {
            tl.fromTo(
              words[i],
              { opacity: 0, y: 6, scale: 1.04 },
              {
                opacity: 1,
                y: 0,
                scale: 1.0,
                duration: 0.22,
                ease: "power3.out",
                transformOrigin: "50% 50%",
                overwrite: "auto",
              },
              w.start,
            );
          }
        });

        /* Group exit — each group fades out before the next enters. */
        if (g.out < DUR) {
          tl.to(
            sel,
            { opacity: 0, duration: 0.45, ease: "power2.in", overwrite: "auto" },
            g.out - 0.45,
          );
        }
        tl.set(sel, { opacity: 0, visibility: "hidden" }, g.out);
      });

      /* Self-lint */
      GROUPS.forEach(function (g) {
        var el = document.getElementById(g.id);
        if (!el) return;
        tl.seek(g.out + 0.01);
        var cs = window.getComputedStyle(el);
        if (cs.opacity !== "0" && cs.visibility !== "hidden") {
          console.warn("[caption-lint] " + g.id + " visible at t=" + (g.out + 0.01).toFixed(2));
        }
      });
      tl.seek(0);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/example-renders/memory-wall.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1280, height=720" />
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
    <style>
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1280px;
        height: 720px;
        overflow: hidden;
        background: #000;
        font-family: "Inter", system-ui, sans-serif;
      }

      #a-roll,
      #fg-overlay {
        position: absolute;
        inset: 0;
        width: 100%;
        height: 100%;
        object-fit: cover;
      }

      /* Foreground matte sits above the captions so the person & mic occlude
         the text — classic cinematic lower-third layering. */
      #fg-overlay {
        z-index: 3;
        pointer-events: none;
      }

      /*
        Caption stage — perspective container so the text plane lies on the
        acoustic-foam wall in the upper-right of the frame. Small jitter is
        applied to the stage by GSAP to fake handheld motion tracking.
      */
      #stage {
        position: absolute;
        top: 0;
        left: 0;
        width: 1280px;
        height: 720px;
        perspective: 1400px;
        perspective-origin: 30% 40%;
        pointer-events: none;
        z-index: 2;
        will-change: transform;
      }

      .wall-plane {
        position: absolute;
        /* Right-aligned cluster, but widened and pushed slightly left
           so emphasis words (EVERYTHING, SHARP AGAIN) reach past the
           subject's silhouette. Short words still hug the wall. */
        top: 90px;
        right: 30px;
        width: 720px;
        height: 520px;
        transform-style: preserve-3d;
        transform: rotateY(-13deg) rotateX(1deg);
        transform-origin: right center;
        padding: 24px 32px;
        display: flex;
        flex-direction: column;
        justify-content: center;
        align-items: flex-end;
        text-align: right;
        gap: 14px;
      }

      .cap {
        display: block;
        position: relative;
        line-height: 1.05;
        opacity: 0;
        /* The "embed in scene" move: overlay blend picks up the wall's
           luminance and color so the text feels lit BY the scene. */
        mix-blend-mode: screen;
        color: #fff4dc;
        text-shadow:
          0 0 12px rgba(255, 210, 150, 0.45),
          0 2px 5px rgba(0, 0, 0, 0.7);
        filter: brightness(1.1) contrast(1.05);
        will-change: transform, opacity, filter;
      }

      .cap .w {
        display: inline-block;
        opacity: 0;
        will-change: opacity, transform, filter;
      }

      /*
        Unified cinematic typography — no mode switch between soft & sharp.
        Elegant serif-inflected sans, italic for the dreamy first half,
        upright for the second half. One look, one feeling, progressing.
      */
      .cap-1 {
        font-size: 78px;
        font-weight: 600;
        font-style: italic;
        letter-spacing: -0.01em;
      }
      .cap-2 {
        font-size: 66px;
        font-weight: 500;
        font-style: italic;
        letter-spacing: -0.005em;
        padding-right: 44px; /* hanging indent on right for right-aligned flow */
      }
      .cap-3 {
        font-size: 72px;
        font-weight: 700;
        font-style: normal;
        letter-spacing: -0.015em;
      }
      .cap-4 {
        font-size: 90px;
        font-weight: 900;
        font-style: normal;
        letter-spacing: -0.03em;
        text-transform: uppercase;
        line-height: 1;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="8.04"
      data-width="1280"
      data-height="720"
    >
      <!-- Layer 0: background plate (full video) -->
      <video
        id="a-roll"
        class="clip"
        src="source.mp4"
        muted
        playsinline
        data-duration="8.04"
        data-track-index="0"
        style="z-index: 1"
      ></video>

      <!-- Layer 2: caption stage (between background and foreground matte) -->
      <div id="stage">
        <div class="wall-plane">
          <div id="cg-0" class="cap cap-1">
            <span class="w" data-i="0">Some</span>
            <span class="w" data-i="1">memories</span>
            <span class="w" data-i="2">feel</span>
            <span class="w" data-i="3">soft</span>
          </div>
          <div id="cg-1" class="cap cap-2">
            <span class="w" data-i="0">like</span>
            <span class="w" data-i="1">old</span>
            <span class="w" data-i="2">film</span>
          </div>
          <div id="cg-2" class="cap cap-3">
            <span class="w" data-i="0">but</span>
            <span class="w" data-i="1">suddenly</span>
          </div>
          <div id="cg-3" class="cap cap-4">
            <span class="w" data-i="0">everything</span>
            <span class="w" data-i="1">is</span>
            <span class="w" data-i="2">sharp</span>
            <span class="w" data-i="3">again</span>
          </div>
        </div>
      </div>

      <!-- Foreground occlusion (person + mic) is applied in post via ffmpeg
           overlay. Browser-side alpha webm is unreliable across encoders. -->

      <!-- Audio -->
      <audio
        id="a-roll-audio"
        src="source.mp4"
        data-start="0"
        data-duration="8.04"
        data-track-index="3"
        data-volume="1"
      ></audio>
    </div>

    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });
      const DUR = 8.04;

      /* ─────────────────────────────────────────────────────────────────
         Word-level transcript — ElevenLabs timestamps.
         ───────────────────────────────────────────────────────────────── */
      const GROUPS = [
        {
          id: "cg-0",
          words: [
            { text: "Some", start: 0.24, end: 0.44 },
            { text: "memories", start: 0.48, end: 0.82 },
            { text: "feel", start: 0.9, end: 1.14 },
            { text: "soft", start: 1.2, end: 1.64 },
          ],
          in: 0.2,
          out: 4.85,
          tone: "soft",
        },
        {
          id: "cg-1",
          words: [
            { text: "like", start: 2.66, end: 2.78 },
            { text: "old", start: 2.86, end: 3.02 },
            { text: "film", start: 3.14, end: 3.46 },
          ],
          in: 2.55,
          out: 4.85,
          tone: "soft",
        },
        {
          id: "cg-2",
          words: [
            { text: "but", start: 5.02, end: 5.16 },
            { text: "suddenly", start: 5.22, end: 5.76 },
          ],
          in: 4.9,
          out: 8.04,
          tone: "present",
        },
        {
          id: "cg-3",
          words: [
            { text: "everything", start: 6.8, end: 7.14 },
            { text: "is", start: 7.16, end: 7.28 },
            { text: "sharp", start: 7.3, end: 7.52 },
            { text: "again", start: 7.56, end: 7.92 },
          ],
          in: 6.7,
          out: 8.04,
          tone: "present",
        },
      ];

      /* ─────────────────────────────────────────────────────────────────
         Each caption group: cinematic word-by-word reveal. No flashes, no
         grain — purely text animation. Soft groups drift up gently, later
         groups resolve firmly into place to match the sentence turn.
         ───────────────────────────────────────────────────────────────── */
      GROUPS.forEach(function (g) {
        var sel = "#" + g.id;
        var words = g.words.map(function (_, i) {
          return sel + " .w[data-i='" + i + "']";
        });

        var isSoft = g.tone === "soft";

        /* Container stays fully visible — each word animates its own fade.
           This avoids a double-fade (container × word) that produced a
           non-linear pop in the first ~100ms of the first caption. */
        tl.set(sel, { opacity: 1, y: 0 }, Math.max(0, g.in - 0.01));

        /* Per-word reveal — opacity + translate only. No letter-spacing
           or filter:blur here: both can perturb inline-block metrics
           mid-animation and cause the cap line-box to reflow, which
           makes words like "Some" visibly shift between rows when a
           sibling word enters. transform-only = no reflow. */
        g.words.forEach(function (w, i) {
          if (isSoft) {
            tl.fromTo(
              words[i],
              { opacity: 0, y: 8 },
              {
                opacity: 1,
                y: 0,
                duration: 0.45,
                ease: "power2.out",
                overwrite: "auto",
              },
              w.start,
            );
          } else {
            tl.fromTo(
              words[i],
              { opacity: 0, y: 6, scale: 1.04 },
              {
                opacity: 1,
                y: 0,
                scale: 1.0,
                duration: 0.22,
                ease: "power3.out",
                overwrite: "auto",
                transformOrigin: "50% 50%",
              },
              w.start,
            );
          }
        });

        /* Group exit — soft groups linger and fade as the sentence turns,
           present groups hold to the end. Clean fades, no trickery. */
        if (g.out < DUR) {
          tl.to(
            sel,
            { opacity: 0, duration: 0.5, ease: "power2.in", overwrite: "auto" },
            g.out - 0.5,
          );
        }
        tl.set(sel, { opacity: 0, visibility: "hidden" }, g.out);
      });

      /* Self-lint */
      GROUPS.forEach(function (g) {
        var el = document.getElementById(g.id);
        if (!el) return;
        tl.seek(g.out + 0.01);
        var cs = window.getComputedStyle(el);
        if (cs.opacity !== "0" && cs.visibility !== "hidden") {
          console.warn("[caption-lint] " + g.id + " visible at t=" + (g.out + 0.01).toFixed(2));
        }
      });
      tl.seek(0);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/failure-modes.md
# Failure Modes — learned the hard way

Things that broke during skill development. Check against this list before you ship.

## Matting

### CoreML execution provider corrupts face alpha

Running the matting ONNX through the CoreML EP partitions the graph across providers; the mixed-precision boundary produced alpha ≈ 30 (out of 255) inside the subject's face while the background correctly read 0 (observed with the previous RVM engine — don't re-try). Result: caption text visibly shines THROUGH the face.

**Fix**: Only ever use `providers=["CPUExecutionProvider"]` for the matting ONNX. Our script does this; don't "optimize" it by adding CoreML.

### Aspect distortion / sharp channel-stride make the alpha garbage

(historical — the PP-MattingV2 engine was replaced 2026-06-12 by hyperframes `remove-background`; kept because the lessons generalize) Two earned scars from the ONNX era: (1) fixed-size model exports — squashing a portrait clip into a landscape canvas distorts humans; contain-pad (aspect preserved, centered, alpha cropped back) instead. (2) sharp returns a **3-channel** buffer from `.raw()` after resizing a 1-channel raw input — reading it with a 1-channel stride silently produces a smeared, striped, displaced matte that can _look_ plausible. Always `toBuffer({resolveWithObject:true})` and stride by `info.channels` (other scripts in this skill still use sharp).

**Fix**: both handled inside `matte.cjs`; preserve them if you touch it.

### isnet-general-use / u2net_human_seg miss props

rembg's general/human models capture the person but drop handheld props (mic, notebook). If the caption crosses the mic, it will NOT be occluded by the mic — text floats in front of it unnaturally.

**Fix**: For scenes where props matter, either (a) accept that the human-matting model captures what it captures, or (b) add a manual mask ROI for the prop region. We haven't productized (b) yet.

## Layout

### Flex-stacked captions push hidden ones into gesture zone

First champion iteration put all cap elements inside a flex column with `display: block`. Hidden captions (visibility: hidden) STILL reserve flex space. Result: cg-4 "dreaming" got pushed to y=700, landing right on the hand gesture zone and getting clipped.

**Fix**: Inside the caption plane, each `.cap` is `position: absolute` at the same coords. Hidden ones don't occupy layout. Both templates in this skill do it this way.

### Header-overlay on close-crop shots

First champion iteration used a full-width header band across the TOP of the frame (`top: 30, left: 40, right: 40, height: 360, rotateX: -3deg, justify-content: flex-start, align-items: center`). Subject's head crosses the center, eating the middle of every caption. "know, for me" became a faint "now, for" with the letters sliced apart.

**Fix**: On close-crop podcast shots, don't use symmetric full-width bands. Use `corner-column-crown` with caption column in the corner opposite the head.

### Text right-align + align-items: flex-end + no max-width → flows off-screen

If you set `align-items: flex-end` on the plane and don't constrain width, each `.cap` shrinks to content width. Long words then extend left beyond the plane's "visible" box without wrapping, clipping into the subject.

**Fix**: Set `max-width: calc(100% - padding*2)` on each `.cap`. Templates do this.

## Animation

### letterSpacing animation causes inline-block reflow jumps

Had `letterSpacing: "0.04em"` as the "from" value in `tl.fromTo(word, {opacity:0, letterSpacing: ".04em"}, {letterSpacing:"0"}, w.start)`. GSAP snaps the "from" state at w.start. The width of the word then changes over the tween → inline-block reflow → the `.cap` line box recomputes → "Some" visibly jumped between rows as "memories" entered.

**Fix**: Animate only **transform** properties (opacity, y, scale). NEVER animate letter-spacing, font-size, or filter:blur on `.w` spans. Templates follow this.

### Double-fade (container × word) produces a non-linear pop

Fading both the group container AND each word independently multiplies: effective_opacity = container.opacity × word.opacity. Even with matched eases, the combined curve is nonlinear and feels like a snap-in around the 40% mark.

**Fix**: Keep container at fixed `opacity: 1` during the group's life (set via `tl.set` just before first word). Animate only per-word opacity. Templates do this.

## Blending

### mix-blend-mode: overlay vanishes into dark regions

`overlay(white_text, black_bg) ≈ black`. If part of the caption falls on a dark shadow / dark prop, the letters disappear. The first champion render had `SHARP` become `ARP` because "S" and "H" landed in the dark mic's shadow.

**Fix**: For scenes with significant dark regions under the caption, use `mix-blend-mode: screen` (which preserves white on dark). `wall-embed` defaults to overlay (for mid-tone walls); `corner-column-crown` defaults to screen (for dark bookshelves).

### Bright backgrounds wash out screen blend

Opposite problem: `screen(white, bright) ≈ white` regardless of the text — letters become invisible on bright backgrounds like sunlit windows or white walls.

**Fix**: If average luminance of the caption region exceeds 180/255, switch CSS to `mix-blend-mode: normal` with an opaque color. Sample the background pixels in the caption bbox across the clip to check.

## Render pipeline

### Frame-rate mismatch between bg render and matte PNGs

If hyperframes renders at 30fps default but the matte was extracted at 24fps source rate, ffmpeg overlay gets a temporal mismatch — alpha from frame N is overlaid on bg content of some fractional frame ≠ N. Result: person's current silhouette and matte silhouette are shifted by a few frames, so the occlusion lags or leads the body.

**Fix**: Always pass `--fps` to hyperframes render matching the source's native FPS (usually 24). `render-and-composite.sh` reads fps from plan.json and passes it through.

### WebM alpha (VP8/VP9) flaky in Chromium

We tried making foreground an alpha-channel WebM. Chromium's support for VP9 alpha is inconsistent; VP8 alpha needs specific metadata the default ffmpeg encode doesn't produce.

**Fix**: Don't try to put the matte inside hyperframes. Do the overlay in post with ffmpeg against a PNG sequence. `render-and-composite.sh` does this.

## Crown / centered text

### Centered crown eaten by body

Default `.crown-plane` in `corner-column-crown` is `left: 0; right: 0; text-align: center`. That mechanically centers the word at `frame_width/2`. If the subject sits right of center (Jobs at x=1100 in a 1920 frame) OR the word isn't wide enough to poke both clean zones, the body eats 50-70% of the crown and it reads as "THE \_\_\_ S".

**Fix**: Check subject center_x vs frame center before choosing crown strategy (see [layout-heuristics.md § Crown placement](layout-heuristics.md)). Either size the crown large enough to cross both clean zones, or shift it into the larger clean zone with a narrower width. Keep `text-align: center` within the (re-positioned) box, not the frame.

### Crown font too small for frame → half-swallowed

On landscape 1920×1080, a centered crown at 140px is only ~500px wide. If the subject is 700-900px wide, the crown vanishes behind them. Symptom: only "THE" and "S" visible for "THE BEATLES".

**Fix**: For full-frame centered crown, font must be big enough that `crown_width > subject_width + 400`. See font-size table in [typography-presets.md](typography-presets.md). Prefer 180-260px for landscape centered crowns.

### Wider caption column + default font size → underweight

Template defaults (66/78/92/140) assume a ~560px column. Expanding the plane to 700+ px without bumping fonts makes the text feel too small relative to the column.

**Fix**: Column width 600-760 → phrase ~108px, emph ~128px, crown ~220px centered. See table in [typography-presets.md](typography-presets.md).

## Scene admission

### Handheld / fast camera → matte alpha flickers

The matte is temporally smoothed (EMA in matte.cjs) but moderate motion only. Fast camera pans (vlog selfie) still produce frame-to-frame alpha jitter that shows as flickering caption edges.

**Fix**: SKILL.md decision gate lists handheld vlog as `⚠️` — degrade or refuse. Production version would add `vidstabdetect` compensation; v1 does not.

### Multi-person scenes → matte is ambiguous

The human-matting model segments "foreground" as a single alpha. With two people, it captures both but they're treated as one blob. Captions can't go "behind person A but in front of person B". If the scene has both speakers visible, the skill should split by shot boundaries first.

**Fix**: Either pre-split with PySceneDetect + per-shot planning, or refuse for `⚠️` multi-subject scenes.

### Undetected shot cut inside the clip

Some sources (interviews, broadcast clips) cut to B-roll/archive footage mid-clip. The matte + caption layout assumes the subject is the same person throughout — a hard cut mid-render creates garbage (caption placement relative to old subject applied to a totally different shot).

**Fix for v1**: Probe the clip at 3-5 timestamps before accepting it. If any frame doesn't contain the primary subject at the same position, trim the clip to end before the cut. `Steve Jobs 60 Minutes` cut to Beatles archival at t≈9s, was manually trimmed to 8.5s.

**Fix for future**: Run PySceneDetect as part of the admission gate; split into shots automatically.

### Pillarbox / letterbox content with baked-in graphics

TV archive clips often have pillarbox (black bars at sides) plus baked lower-third logos ("60 Overtime") and date labels ("2003"). These are part of the video file, can't be matted out, and shrink the usable frame area.

**Fix**: Detect black-bar margins in the first frame (scan rows/columns for all-black). Constrain caption safe-zone to `[left_margin+pad, right_margin-pad, top_margin+pad, bottom_margin-pad]`. For Jobs, safe content zone was x=280-1640 (not 0-1920). Layout positions must respect this.

### transcribe.cjs sees an existing transcript and skips

Transcription is Whisper now, via `transcribe.cjs` (it wraps `hyperframes transcribe` — no API key). `hyperframes init --video <mp4>` may itself auto-write a `transcript.json` in hyperframes' raw whisper shape (a flat word array, no top-level `language_code`). `transcribe.cjs` only treats a transcript as done when it's ALREADY in our normalized schema (`{ words: [...], language_code }`); otherwise it (re-)runs Whisper and normalizes the flat word list into `{ words:[{text,start,end,type:"word"}], language_code }`.

**Fix / expectation**: If you init via hyperframes first, expect `transcribe.cjs` to normalize that transcript into our schema. Don't hand-leave a half-normalized file (e.g. our `words` shape but no `language_code`) — that's the one state the skip-guard can misread.

### Transcript gaps > 3s with no speech

If the original audio has a long silence, leaving the caption plane empty feels wrong mid-video. Either (a) let the prior caption linger, (b) show a title-card crown during the silence, or (c) cut the silence out of the clip.

**Fix**: During caption-grouping, detect silences > 3s. The skill should prompt the user about this, not silently proceed.
references/layout-heuristics.md
# Layout Heuristics

How to pick `wall_position` / `crown_position` in plan.json.

## Step 1: Sample 3 frames

```bash
ffmpeg -y -ss 0.5 -i source.mp4 -vframes 1 frame0.jpg
ffmpeg -y -ss <mid> -i source.mp4 -vframes 1 frame1.jpg
ffmpeg -y -ss <end-0.5> -i source.mp4 -vframes 1 frame2.jpg
```

Read all three. Note: subject's head bbox, shoulder top line, and wherever hands move during gestures. Worst-case foreground envelope = union across all 3.

## Step 2: Find the clean zone

The caption plane should live in pixels that are **always background** across the clip. Never where the body lands.

Annotate approximate ranges:

- Head bbox: `head_x_min`, `head_x_max`, `head_y_min`, `head_y_max`
- Hand gesture envelope (if any): usually below `y = shoulder_top ≈ head_y_max + 40`
- Props (mic, cup): typically static, note bbox

**Clean zones**, in priority order:

1. **Corner farthest from head** (usually opposite the gaze direction)
2. **Upper strip above head_y_min minus 30px margin**
3. **Lower-third** if upper is occupied (last resort — breaks "embedded" aesthetic)

## Step 2.5: Which side of the subject?

Once you know where the subject and baked graphics are, decide which side (left or right of the subject) hosts the caption column. Order of precedence:

### 1. Hard constraints first — baked graphics

Logos, watermarks, date stamps, "60 Overtime"-style lower-thirds are already in the source. They occupy permanent zones you must avoid. Map them out from frame samples:

- Jobs 60 Minutes: "2003" at upper-left (x=280-460, y=30-90); "60 Overtime" at bottom-left (x=280-770, y=960-1080). Left side partially constrained; still usable above/below these.
- TikTok re-uploads: username watermark that rotates corners every few seconds — hard to plan around, often a refusal reason.

If one side has a hard constraint that eats > 60% of that side's clean zone, default to the other side.

### 2. Subject body bias — pick the bigger clean zone

Compute clean-zone widths on both sides:

```
left_clean_width  = body_x_min − safe_left_margin
right_clean_width = safe_right_margin − body_x_max
```

Pick the side where clean zone is wider. If the difference is within 10% of frame width, the sides are ~equivalent → fall through to step 3.

Worked examples:

- **Champion** (Djokovic, 1920×1080): body x ≈ 550-1200. Left clean = 510, right clean = 720. Right is wider → but we put captions LEFT because of gaze (see step 3). The narrow difference made either workable.
- **Jobs**: body x ≈ 700-1480 (right-of-center). Left clean = 420, right clean = 160. Left wins decisively. Captions went LEFT.
- **Memory Wall**: surface location dictated the side (see note below about wall-embed).

### 3. Gaze direction — the "looking room" rule (tiebreaker and aesthetic)

Classic cinematic framing: leave more empty space on the side the subject is **looking toward**. This preserves their gaze path and feels uncluttered. **Captions go to the OPPOSITE side** (the side the subject is facing away from), so they don't steal looking room.

Quick check: sample 3 frames. Estimate the eye-line vector. Does it point more left or right?

- Looking screen-right → captions on LEFT
- Looking screen-left → captions on RIGHT
- Looking forward at camera → no preference from gaze, use step 2 only

The champion / Djokovic shot has him addressing camera slightly from the left — captions on LEFT actually read like text he's looking toward, which can feel like he's acknowledging them. That's fine here but is a flavor choice; generally prefer gaze-opposite.

### 4. Narrative emphasis (for optional crown)

If you're using a **center-stage crown**, it sits across the subject — no left/right choice for it. But the other captions (the column) still follow steps 1-3.

If you're using a **clean-zone crown** (shrunk, placed in one clean zone instead of crossing the body), put it in the **same side as the main column** for visual consistency. Don't split crown and column on opposite sides — it creates ping-pong.

### Special case — wall-embed

When the template is `wall-embed`, the side is **dictated by where the usable surface is**, not by body bias or gaze. Memory Wall's foam panel was on the right, so captions went right even though the subject was slightly left-of-center. Surface location wins because the whole effect depends on the text sitting ON that specific surface.

### Decision summary

```
Side = f(baked graphics, body bias, gaze, surface)

priority:
1. Hard constraints (baked logos)    — never place here
2. Wall-embed surface location       — wins if using wall-embed
3. Bigger clean zone                 — default for corner-column
4. Gaze direction (looking room)     — tiebreaker when both sides similar
```

If the subject actively swings their gaze across the clip (turning head L→R), pick a side that works for both extremes, not just the most frequent. Or accept that looking room will briefly be violated — it's a 10-second video, nobody cares.

## Step 3: Pick template + position

### If scene has a flat back wall (acoustic foam, plaster, fabric backdrop)

→ `wall-embed.html`

```
wall_position: {
  top:       max(40, head_y_min - 40),
  right:     20-60 (hug outer edge),
  width:     video_width * 0.35 – 0.50,
  height:    shoulder_top - top,
  rotateY:   -10 to -16 deg if wall angles away on the left,
             +10 to +16 deg if mirrored, 0 if wall is parallel,
  rotateX:   0-3 deg subtle downtilt only
}
```

`mix-blend-mode: overlay` in this template — works on mid-tone walls. If wall is near-black (luminance < 60), switch CSS to `screen`.

### If scene has a cluttered but dark backdrop (bookshelf, plants, set dressing)

→ `corner-column-crown.html`

```
wall_position: {
  top:       40,
  left:      40,               // anchor to clean corner opposite head
  width:     video_width * 0.40,
  height:    clamp(360, 520),  // don't reach below shoulder top
  rotateY:   3-6 deg (subtle, not flashy)
}
crown_position: {
  top:       video_height * 0.40   // center-ish; body will cut middle letters
}
```

`mix-blend-mode: screen` is correct here (bookshelf is dark).

### If subject fills >70% of frame

Template doesn't matter — there's nowhere clean. **Refuse**, suggest classic lower-third.

## Step 3.5: Crown placement (when using `corner-column-crown`)

**Default preference: center-stage crown.** A big, centered crown word (think "WIMBLEDON CHAMPION", "BEATLES", "SHARP AGAIN") that crosses the subject is the most powerful embed move — body occludes the middle letters, clean zones on both sides hold the outer letters, and the word reads as a title drop. **Use this whenever conditions allow.**

### Conditions where a centered crown works

Sample 3 frames, eyeball the subject's horizontal envelope across the clip. Let `body_x_min / body_x_max` = tightest horizontal bounds that contain the head+shoulders+arms in any frame.

A centered crown at font size F (where `crown_width ≈ F × 0.55 × char_count`) reads as dramatic IF:

1. **Subject is roughly centered**: `|body_center_x − frame_width/2| < frame_width × 0.10`. The body sits in the middle third.
2. **Clean zones on both sides are non-trivial**: `body_x_min > frame_width × 0.15` AND `body_x_max < frame_width × 0.85`. There's ≥15% of frame width clean on each side.
3. **The crown word is wide enough to poke out both sides**: `crown_width > body_width + 400px`. If the word ends before reaching the body's right edge, most letters just get swallowed — ugly.

If all three hold, centered crown is right. Go big — font sized so the word spans `0.8 × frame_width` or more. "BEATLES" at 140px (~500px wide) fails condition 3 on an 1920 frame with Jobs-sized body (780px wide). "WIMBLEDON CHAMPION" at 140px (~1700px) on the same frame passes easily.

### When center fails — move crown to the clean zone

If any of the three conditions fails, center-stage eats too much and the word becomes illegible ("THE BEATLES" → "THE" and a sliver of "S"). Two strategies:

**Option A — shrink crown to fit cleanly in the larger clean zone.** Pick the side opposite the subject's bias. Compute `clean_zone_width` on that side. Pick crown font such that the word wraps to 1-2 lines inside it. Tail letters can touch the subject edge for a hint of embed, but the body of the word lives on backdrop.

```
// Jobs: subject center ≈ x=1100 (right of frame center 960). Left clean zone is larger.
crown_plane: { left: 200, width: 560, text-align: center }
font-size: 118px  →  "THE / BEATLES" wraps to 2 lines, fits in x=230-770
                      leaving "S" tail at x~760 lightly touching Jobs's shoulder
```

**Option B — drop the crown entirely, promote the word to `emph` in the main column.** Simpler when the phrase doesn't deserve dramatic center-stage treatment. Works fine for 2-word emph like "the Beatles" or "incredible things" — they become large bold text in the left column with body occluding their tails.

### Rules of thumb for choosing

| Subject occupies center X%+ of frame | Clean zones L/R          | Crown approach                                  |
| ------------------------------------ | ------------------------ | ----------------------------------------------- |
| < 50%, roughly centered              | both ≥ 15% of width      | Centered crown, go BIG (frame_w × 0.8+)         |
| 50-70%, slightly offset              | one side ≥ 25%           | Crown shifted to larger clean zone              |
| > 70%, fills most of frame           | neither side wide enough | Drop crown, use `emph` in column                |
| Close-crop face, fills > 80%         | essentially none         | No crown. All captions in a header/footer strip |

## Step 4: Respect these invariants

1. **Never cover the eyes.** Eye bbox is sacred. Add 20px margin around it when checking caption bbox intersection.
2. **Caption bbox must not exit frame.** Reserve 4% broadcast-safe margin on all edges.
3. **Large emphasis words should have horizontal slack.** At `cap-emph` (92px), an 8-char word ≈ 580px. Plane needs ≥ 620px width OR rely on wrapping at 2 lines.
4. **Crown lives at `top ≈ shoulder_top − crown_font_size/2`** so it sits across the upper body, not the face.
5. **If aspect ratio is portrait (9:16), rotate layout**: wall-plane becomes a **top band** (full width, height ≈ 20%), crown goes below it if used at all.

## Step 5: Validate before render

Before calling `render-and-composite.sh`, sanity check:

- Did you place the plane in the opposite quadrant from the face? ✓
- Does the plane exit the frame anywhere? If yes, shrink.
- Is the font too big for the plane width? Calculate: longest word in words[] × 0.55 × font_size < plane_width - padding\*2.
- For `corner-column-crown`, did you set `crown_position.top` so the crown actually crosses the body (not above the head, not below the torso)?
- **Pillarbox / letterbox hard check.** If the source has black bars (see pre-flight probe in SKILL.md), compute `leftmost_text_x` using the correct formula for the text alignment:

  | Alignment                   | Formula                                            |
  | --------------------------- | -------------------------------------------------- |
  | Left-aligned                | `plane_left + padding_left`                        |
  | Right-aligned (main column) | `plane_right − padding_right − longest_word_width` |
  | Center-aligned (crown)      | `plane_center − longest_word_width / 2`            |

  Where `longest_word_width ≈ font_size × char_count × 0.55` (adjust 0.55 down to ~0.50 for italic, up to ~0.62 for uppercase bold). **All** of these must be ≥ `pillarbox_left_edge + 10–20px` (safety margin). Mirror for the right bar.

  **Gotcha from past iteration**: setting `plane_left = 180` with a right-aligned column on a Jobs 60 Minutes clip (pillarbox at 280) looked "inside the plane" but the right-aligned text at font 108px produced `leftmost = plane_right − padding − 468 = 180 + 700 − 36 − 468 = 376` … wait, that IS past pillarbox. The actual bug was different: for a 3-line wrap ("four very / talented / guys") the widest line is "talented" at ~468px, not the whole phrase. Compute against the longest **single line** after wrap, not total phrase width.

  **Re-check after font-size changes** — bumping font 84→104 shifts right-aligned text leftward by ~30px. If the budget was already tight, the bump blows through the pillarbox.

  **Empirical target**: leftmost text at pillarbox + 10-20px looks "anchored" to the bar (visually grounded). Pushing it much deeper (50px+) makes captions feel floaty in the middle of the frame.

## Worked examples

### memory-wall (1280×720, acoustic foam wall)

```json
{
  "template": "wall-embed",
  "wall_position": {
    "top": 40,
    "right": 30,
    "width": 720,
    "height": 520,
    "rotateY": -13,
    "rotateX": 1
  }
}
```

### champion (1920×1080, bookshelf backdrop)

```json
{
  "template": "corner-column-crown",
  "wall_position": { "top": 40, "left": 40, "width": 720, "height": 420, "rotateY": 4 },
  "crown_position": { "top": 440 }
}
```
references/motion-vocabulary.md
# Motion Vocabulary — named word-entry moves

10 motion primitives the skill uses. Each has a specific timing + easing + situational fit. Agent picks by **content tone**, not default to one.

**Easing philosophy:** `cubic-bezier(.2,.7,.2,1)` is the "considered confident" default. `linear` is almost always wrong. Back-easing (elastic) is for playful content only — never documentary.

**Exit rules:** Default exit is the same tween reversed at 60% duration. Never exit with a different motion than entry unless doing a rhetorical pivot. Never fade out during speech — only during the gap between phrases.

---

## The 10 moves

### 1. typewriter

Char-by-char reveal, 25–35ms/char, linear easing. Documentary / data-heavy / tense / interrogative.

```js
// GSAP
words.forEach((w, i) => {
  const chars = w.textContent.split("");
  w.textContent = "";
  chars.forEach((c, j) => {
    const span = document.createElement("span");
    span.textContent = c;
    span.style.opacity = 0;
    w.appendChild(span);
    tl.to(span, { opacity: 1, duration: 0 }, wordStart + j * 0.028);
  });
});
```

### 2. word-fade-up (DEFAULT)

Per-word, 80ms stagger, `opacity 0→1 + translateY 8→0`, `cubic-bezier(.2,.7,.2,1)` (ease-out). Default for conversational content.

```js
tl.fromTo(
  word,
  { opacity: 0, y: 8 },
  { opacity: 1, y: 0, duration: 0.45, ease: "power2.out" },
  wordStart,
);
```

### 3. word-pop

Per-word, 60ms stagger, `scale 0.85→1 + opacity 0→1`, elastic-light `cubic-bezier(.34,1.56,.64,1)`. Energetic / YouTube-vlog / high-tempo. **Never for documentary.**

### 4. swipe-reveal

Whole phrase at once, 400ms, `clip-path inset 0 100% 0 0 → 0 0 0 0`, expo `cubic-bezier(.77,0,.175,1)`. Keynote / tech product / announcement.

```css
.cap {
  clip-path: inset(0 100% 0 0);
}
/* animate to inset(0 0 0 0) */
```

### 5. drop-and-settle

Per-word, 120ms stagger, `y: -30→0` with back-bounce `cubic-bezier(.68,-.55,.27,1.55)`. Playful but controlled. **Not for documentary.**

### 6. etch

Whole word at once, 600ms, opacity ramp `0→1` with slight blur→sharp transition. Ease-in-out. Poetic / lyrical / "quote on wall" direction.

### 7. crosshair

Per-word, 200ms total: two 1px lines converge on word center (linear 150ms), then word snaps in (expo-out 50ms). Thriller / investigative doc.

### 8. breathing-hold

**Static but alive.** No entry animation. Once visible, micro-scale loop `1.00↔1.008` over 2s, sine ease. Used on climax lines held 1.5s+.

### 9. cascade

Each word from alternating direction: L, R, L, R. `translateX: ±40→0`, ease-out 300ms. **Rhythm-break move** — use sparingly, once per 30s max.

### 10. burn-in

**Zero animation.** Sudden 1-frame opacity 1. No fade, no transform. Interview-doc style. Use for every 6–8th phrase in a documentary direction for variety (Standard mode; see direction-catalog §1).

---

## Tone → timing lookup table

| Tone           | Stagger                   | Hold        | Entry move                         |
| -------------- | ------------------------- | ----------- | ---------------------------------- |
| documentary    | 150ms                     | 600ms       | burn-in (70%) + word-fade-up (30%) |
| conversational | 80ms                      | 400ms       | word-fade-up                       |
| energetic      | 50ms                      | 300ms       | word-pop                           |
| poetic         | 250ms                     | 1200ms      | etch                               |
| broadcast      | 0 (burn-in)               | BBC timings | burn-in                            |
| investigative  | 25ms/char                 | 500ms       | typewriter                         |
| keynote        | 0 per-word (phrase swipe) | 600ms       | swipe-reveal                       |

Agent picks tone → this table → GSAP parameters. Data, not guesswork.

---

## Per-word vs per-phrase animation axis

Orthogonal to the 10 moves above.

- **Per-word animation** (default): each word enters at its `w.start` timestamp. Tight sync with speech.
- **Per-phrase animation**: whole group enters/exits at `g.in`/`g.out`. Words appear together. Used for:
  - swipe-reveal moves
  - burn-in (no animation, but the "reveal" is a group event)
  - title cards / chapter breaks

Rule: per-word for content that tracks speech; per-phrase for moments that step outside the speech flow.

---

## Stagger breakdown by context

Fine-tune stagger within a tone:

- **Documentary dialog**: 150ms (measured)
- **Documentary punchline**: 250ms (weighted)
- **Vlog opener**: 50ms (fast hook)
- **Vlog narration**: 70ms (conversational but snappy)
- **Poem / lyric**: 250–400ms per word, with 500–1000ms gaps between phrases
- **Keynote hero**: per-phrase only (stagger = N/A)
- **Interview name card**: 0 stagger (static)

---

## Anti-patterns (banned, from anti-patterns.md)

- Animating `letter-spacing` → reflow
- Animating `filter:blur` → reflow + can wash out text
- `Math.random()` / `Date.now()` in timeline → non-deterministic render
- Entries under 100ms total → frantic, unreadable
- Same motion for every group for 30s+ → eye adapts, stops registering

---

## When to break the vocabulary

The 10 moves are a comfortable coverage, not a ceiling. For Standard mode, invent new moves when the scene demands it:

- Kyle Cooper's scratched typewriter for noir
- K-pop lyric's directional word-from-side
- Film-title flash-cut burn-in with chromatic aberration

When inventing: stay deterministic, stay transform+opacity only, state the motion in terms of the 4 axes (entry, hold, exit, stagger).
references/rail.md
# Rail track — standard lower-third subtitle

The **default** caption track and the workhorse: a clean, readable subtitle that sits **in
front** of everything (never occluded by the matte) in the lower third. It carries **most**
of the transcript. It is deliberately **plain** — the cinematic craft (planes, occlusion,
Vogue-masthead, accumulation) lives in the _embed_ track only ([composition-craft.md](composition-craft.md)).

For talking-head / explainer / voiceover, the typical output is **the whole transcript on the rail**
with only the climax(es) promoted to embed. Rail is not a fallback — it's the baseline.

> **Implementation note.** A dedicated rail renderer is the next build step. The rail is a
> plain `fg` caption track and maps cleanly onto hyperframes' native caption pipeline
> (`media-use` captions) — prefer reusing that over hand-rolling. Until wired, render
> the rail as a simple `data-caption-layer="fg"` composition (no matte overlay for these caps).

## Position & safe area

- **Lower third, horizontally centered.** Landscape (16:9): baseline ~80–120px above the
  bottom edge. Portrait (9:16): lower-middle, ~ 600–700px from the bottom (clear of platform UI).
- **Title-safe margins:** keep text within ~90% width / inside any letterbox-pillarbox bars
  (see the letterbox probe). Never flush to the frame edge.
- **One caption group on screen at a time.** No accumulation, no cascade — that's embed-track behaviour.
- It rides above the subject; the matte does **not** occlude it (rail = in front).

## Lines, length, timing

- **≤ 2 lines.** Broadcast target ~ 32–42 chars/line; break at a clause/phrase boundary, never
  mid-word, never leave a dangling 1-word line.
- **Word-synced.** Each group's window envelops its words (`group.in ≤ first word.start`,
  `group.out ≥ last word.end`); each group ≥ 0.5s on screen; ~1.5s min gap discipline so it
  doesn't strobe. Word timings within 80ms of transcript (same gate as everywhere).
- **Grouping** = short readable phrases (see [caption-grouping.md](caption-grouping.md)) — not the
  embed track's "phrase = composition" rule; here it's just legible subtitle chunking.

## Look (restrained on purpose)

- **Size: ~`calc(0.045 * var(--h))`** (≈48px @1080, ≈58px @1290) — readable, _not_ hero. The rail is a subtitle; it is deliberately much smaller than the embed body/climax. Express as a `var(--h)` fraction (never hardcode px) so it scales across resolutions. The embed climax is sized to the frame independently (see composition-craft § POP) — **never** size the climax as a multiple of this rail.
- One clean sans (Inter / Helvetica Now / Neue Haas), weight 500–600; white (or near-white).
- **Legibility without grading the video:** a tight text treatment local to the glyphs only —
  a soft dark drop-shadow, or a subtle rounded gradient pill / 30–40% scrim **sized to the text
  box** (not a full-frame bar, never a frame-wide grade). On luminance > 180 backgrounds, keep
  the scrim; never rely on bare light text.
- Motion is minimal: 150–250ms fade-up in / fade-down out. No glitch, no scale-pop, no per-word
  choreography on the rail — that energy is reserved for embed.

## The `emphasis` flag (active-word highlight)

The only intensity the rail carries. When a word is graded `emphasis` (the 1–2 punch words in a
phrase), give it an **inline** lift on the rail — accent colour and/or +weight, optionally a
karaoke-style active-word pop (≤1.1× scale) as it's spoken. Keep it inline; it does **not**
leave the rail. Anything that wants to leave the rail is an `embed`, not an emphasis.

## What the rail never does

- Never goes behind the subject (that's embed).
- Never accumulates into a multi-line poem (that's embed).
- Never crosses the face / uses occlusion as an effect.
- Never grades or textures the underlying video.
- Doesn't drop content to "fit fewer blocks" — split into more groups instead. The only drops
  are filler (um/uh, exact stutters, self-corrections); dense conversational `narrator` glue
  ("you know", "sort of") may be trimmed for readability, content/structure words stay.

## Hand-off to embed

Rail + embed coexist in one render: the rail runs the whole clip; an embedded peak appears over
it at its moment (the rail can briefly clear or dim under the embed if they'd collide). Decide
which phrases promote via the role read in SKILL.md § Caption model; author the promoted ones
with [composition-craft.md](composition-craft.md).
references/reference-bar.md
# The reference bar — what world-class looks like

Self-review without an external bar always passes. Before shipping, hold the render
against these references — not "is anything broken" (the gates did that) but "would a
designer at these houses sign it".

## Per register

**premium-warm / editorial (cream, editorial)** — the bar is a _Vogue cover_ and the
A24 title card: type interacts with the subject (masthead behind the head), one or two
sizes feel chosen not defaulted, the frame would work printed as a poster. Check: does
the climax frame, screenshotted alone, look like a designed cover — or like a subtitle
that grew?

**tech-premium (keynote, ink)** — the bar is an _Apple keynote slide_ over footage:
opaque, weighty, dead-center confidence; negative space is doing the work; nothing
decorating. Check: would this frame look at home in a WWDC sizzle?

**formal (documentary)** — the bar is _Errol Morris / PBS Frontline_: the type has the
restraint of a court document; stillness reads as authority. Check: does ANY motion call
attention to itself? If yes, cut it.

**loud** — the bar is a _k-pop lyric video / Nike spot_: type fights the subject and
wins on purpose, color is saturated and scene-true, the beat hits WITH the word. Check:
does the hero frame make you want to replay it?

## The five positive checks (run on the preview sheet, after the failure checks)

1. **Poster test** — screenshot the climax frame alone. Would you post it as a still?
   If it needs the motion to excuse the composition, the composition isn't done.
2. **Timid test** — is any body caption apologizing (tiny, corner-parked, far from the
   silhouette)? Small is a choice; timid is a default. Hug the subject or commit to the
   margin as a designed column.
3. **One-glance hierarchy** — at thumbnail size (shrink the sheet), can you still tell
   opener / body / hero apart? If the tiers blur together, the contrast ratios are off.
4. **Scene handshake** — name three ways the type acknowledges THIS footage (accent
   sampled from the scene, occlusion crossing, shadow direction, depth blur, palette
   temperature). Fewer than two = it's a sticker, not an embed.
5. **Dead-air audit** — scrub the no-caption stretches. Are they _composed rests_
   (subject carries the frame) or _gaps_ (the design just stopped)? Rests are earned by
   rhythm; gaps are bugs in pacing.

## How to use with fresh-eyes review

Give the sub-agent the preview sheet + the 5 failure checks (SKILL.md § Visual QA) +
these 5 positive checks, and ask for verdicts on both lists. A render ships when it
passes the failure checks AND at least 4/5 positive checks with specific evidence.
references/scene-types.md
# Scene Types — picking the right template

The three templates differ in one thing: **what surface (if any) the text physically sits on**. Pick by reading the scene, not by the genre label.

## wall-embed — "text is printed on a real surface"

Original Memory Wall render used this. Text uses perspective transform + `mix-blend-mode: overlay` to look like it was silk-screened onto a wall behind the subject. The illusion is strong when it works and completely broken when forced.

### Four conditions — ALL must hold

1. **A flat or near-flat surface exists somewhere in the frame, larger than the caption block.** Good: acoustic foam panel, painted wall, door, stone facade, large backdrop cloth, blackboard. Bad: bookshelf (3D depth + decorative items), mic shield (too small), window (transparent, content behind it shifts), brick wall with mortar patterns (competes).
2. **The surface is at a non-zero angle to the camera.** Perspective is what sells "embedded". If the wall is perfectly parallel (0° rotation relative to camera), pasting rotateY:0 text onto it looks like a sticker, not embedded. Memory Wall's foam wall was tilted ~13° — perfect. A wall directly behind a centered frontal subject usually has no exploitable angle.
3. **The surface is mid-luminance.** Overlay blend neutralizes mid-tones — text keeps contrast against a mid-grey wall. On near-black surfaces (luminance < 60), overlay makes text vanish → switch to `mix-blend-mode: screen`. On near-white surfaces (> 180), overlay also fails → switch to `mix-blend-mode: normal` with opaque color.
4. **The surface has no competing graphical content.** Plain paint, uniform acoustic foam, blurred OOF background all work. Printed wallpaper, picture frames, text on signage, busy bookshelves all compete with the caption and ruin the embed illusion.

If **any** condition fails, **do not use wall-embed**. Pick another template.

### What memory-wall looked like

The acoustic foam wall to the right of the subject took ~40% of frame width, tilted ~13° toward the viewer, mid-grey color, uniform dimpled texture. Perfect. Text with `rotateY(-13deg)` and overlay blend sat on it like printed letters.

### Anti-examples (wall-embed would FAIL)

- **champion / demo1_avatarv** — bookshelf background with books, white vases, picture frames. Cluttered + 3D depth. Fails condition 1 and 4. Used corner-column-crown instead.
- **Jobs 60 Minutes** — blurry potted plant background. No flat surface at all. Fails condition 1.
- **Randy Pausch "Time is all we have"** — completely black backdrop. No perspective to exploit, no mid-tone surface. Fails 2 and 3. Used portrait-header.
- **Any vlog / selfie with handheld camera** — surface moves frame-to-frame, can't lock perspective. Even if conditions 1-4 hold briefly, motion breaks the illusion.

## corner-column-crown — "text sits in a clean region next to the subject"

The default for most talking-head content. Works when wall-embed doesn't.

### Conditions

1. **Single subject, fixed camera.** Handheld is tolerable if drift is small.
2. **At least one clean zone (≥ 15% of frame width) to the left or right of the subject** where captions can sit without continuous body occlusion.
3. **Background can be cluttered or 3D** — we're not relying on a flat surface, just readable contrast. Screen blend mode picks up darker backgrounds (bookshelves, dark walls) naturally.
4. **Landscape aspect** (16:9 or similar). For portrait, use `portrait-header`.

### When to add the crown vs when to skip it

- Add crown if there's a natural "title drop" line — the one phrase that lands the story (e.g. "WIMBLEDON CHAMPION", "BEATLES", "SHARP AGAIN"). Check [layout-heuristics.md § Crown placement](layout-heuristics.md) for the three geometric conditions.
- Skip crown for factual / neutral monologues where no phrase carries the climax. Short videos (<10s) often don't need it.

## portrait-header — "subject fills center, text lives in top banner"

For 9:16 aspect where the subject inevitably fills the middle.

### Conditions

1. **Portrait aspect** (9:16, 1080×1920 etc.).
2. **Subject occupies the vertical middle of frame**, leaving ~15-25% of the frame height clear at the top.
3. **Optional crown at bottom** — only works if there's also clean space beneath the subject's waist, which is rare in close-crop portrait.

### What to watch

- Subject's head top often approaches y=40 — top banner captions at `top: 30px` will get partially occluded by hair. Accept the partial occlusion (it reads as embed) or bump the caption size down to clear the hair.
- If the subject already has **baked-in captions** (like the burned-in subtitles test case with yellow subtitles burned in), the skill should refuse — two caption systems compete.

## Quick decision flow

```
START
  │
  ├─ Aspect is 9:16 / portrait?
  │    ├─ YES → portrait-header
  │    └─ NO ──▼
  │
  ├─ Does the scene have a flat uniform surface that passes
  │  ALL 4 wall-embed conditions?
  │    ├─ YES → wall-embed
  │    └─ NO ──▼
  │
  ├─ Single subject + fixed camera + ≥15% clean zone on one side?
  │    ├─ YES → corner-column-crown
  │    └─ NO ──▼
  │
  └─ Refuse with reason:
     "No flat surface for embed, no clean caption zone for column.
      Suggest a classic lower-third instead."
```

## Mode checklist (run this before committing to wall-embed)

Read 3 frames (start, mid, end). For each, honestly answer:

- [ ] Is there a single surface behind/beside the subject that occupies ≥ 30% of the frame?
- [ ] Is that surface relatively flat (not curved, not 3D-stacked shelving)?
- [ ] Does that surface angle at 5-20° to the camera (not perfectly parallel, not warped)?
- [ ] Is the surface mid-luminance (not near-black like dark bookshelves, not near-white like a sunny window)?
- [ ] Does the surface contain NO printed/decorative elements that compete with caption text?
- [ ] Does the surface remain visible across all 3 frames (subject doesn't step in front of it)?

**Need all six YES to use wall-embed.** Even one NO → step down to corner-column-crown or portrait-header.
references/typographic-moves.md
# Typographic moves — a palette, not a cage

`cinematic-cream` template locks DNA only (font-family, motion, color
palette defaults). It does NOT enforce slot structure. The agent writes
each caption group's typography from scratch via the plan.json `css`
field.

This doc is a **palette** of tried combinations — pick what fits the
scene, invent new ones, skip these if the content demands. These are
recommendations, not rules.

## Size vs text length — DO THIS BEFORE PICKING SIZE

Bigger is NOT better if the word doesn't fit the frame width. Every time
you pick a font-size, sanity-check against word length:

**Rough rule for Inter 900 uppercase at -0.03em tracking:**
`max_font_px ≈ frame_width / (longest_word_chars × 0.72)`

The 0.72 multiplier is intentionally conservative — it targets the text
filling ~90% of the frame width so there's a small side margin. If you
want edge-to-edge, drop to 0.65, but you lose a bit of safety margin.

720px portrait:

- 3-char word (15%): max ~330px (a single big number like "15%")
- 5-char word (TAKE): max ~200px
- 7-char word (AUGMENT / QUANTUM / MATTERS): max ~143px
- 10-char word (CREATIVITY): max ~100px
- 13-char word (UNPRECEDENTED): max ~77px

1920px landscape:

- 7-char word: max ~380px
- 10-char word: max ~267px

For italic/upright 500-700 it's slightly narrower — increase max by ~10%.

**If you want the word bigger than it fits:**

- Split into multiple words per group (natural wrap), OR
- Add explicit `<br>` between syllables, OR
- Drop letter-spacing to -0.05em, OR
- Accept that the matte will cut part of it (Vogue-cover embed effect —
  only works when the cut area is exactly where the subject is)

Never let the text OVERFLOW the frame edge — that's not cinematic, that's
just broken. Use the heuristic.

## Sizing discipline (frame-height-relative)

Portrait 9:16 (720×1290) and landscape 16:9 (1920×1080) have very
different comfort ranges. Use `calc(<pct> * var(--h))` for responsive:

| Feel                   | % of frame height | Example calc            |
| ---------------------- | ----------------- | ----------------------- |
| Whisper / label        | 4-6%              | `calc(0.05 * var(--h))` |
| Soft opener            | 6-9%              | `calc(0.07 * var(--h))` |
| Narrative body         | 9-14%             | `calc(0.11 * var(--h))` |
| Emphasis               | 14-22%            | `calc(0.18 * var(--h))` |
| Climax / monumental    | 22-35%            | `calc(0.28 * var(--h))` |
| Title-bigger-than-face | 40%+              | `calc(0.48 * var(--h))` |

Use absolute px only when a specific scale needs to look identical
across frame sizes (rare). Prefer `calc` + `var(--h)`.

## Named moves — pick per group

**Italic contemplative opener** (memory-wall cap-1 lineage):

```css
font-weight: 600;
font-style: italic;
letter-spacing: -0.01em;
font-size: calc(0.075 * var(--h));
```

**Italic continuation / dreamier continuation** (cap-2 lineage):

```css
font-weight: 500;
font-style: italic;
letter-spacing: -0.005em;
font-size: calc(0.065 * var(--h));
```

**Upright pivot / statement** (cap-3 lineage — "but suddenly"):

```css
font-weight: 700;
letter-spacing: -0.015em;
font-size: calc(0.07 * var(--h));
```

**Uppercase climax / monumental** (cap-4 lineage — "EVERYTHING IS SHARP AGAIN"):

```css
font-weight: 900;
text-transform: uppercase;
letter-spacing: -0.03em;
line-height: 0.95;
font-size: calc(0.18 * var(--h));
```

**Chapter card / full-frame Adam Curtis manifesto**:

```css
font-weight: 700;
text-transform: uppercase;
letter-spacing: -0.015em;
line-height: 0.9;
font-size: calc(0.14 * var(--h));
```

**News chyron / broadcast label** (small, locked):

```css
font-weight: 500;
letter-spacing: 0.02em;
font-size: calc(0.038 * var(--h));
```

**Tiny side note / caption-on-surface**:

```css
font-weight: 400;
font-style: italic;
font-size: calc(0.045 * var(--h));
```

**Vogue-masthead monumental**:

```css
font-weight: 900;
text-transform: uppercase;
letter-spacing: -0.04em;
line-height: 0.85;
font-size: calc(0.32 * var(--h));
```

## Narrative arcs (suggestions, not required)

**Poem arc** — memory-wall canonical: italic opener → italic continuation
→ upright pivot → uppercase climax. Four steps.

**Question-answer arc**: italic question small → uppercase answer big.
Two steps.

**List-of-three arc**: three equal-weight narration segments, one
climactic uppercase. Four steps.

**Single-statement arc**: one monumental uppercase held long. One step.

**Whisper-hook arc** (short-form): tiny italic hook → massive uppercase
reveal. Two steps.

The **number** of groups comes from the **content**. A 25s Pausch talk
has ~8 beats. A 7s TikTok hook has 2-3 beats. Don't force 4 or 5 or 7 —
read the transcript and mark breath-groups.

## Position + layer vocabulary

**Position** is where the caption lives in frame. Use:

- `top / left / right / bottom` (percentages or px) for anchor
- `transform: translateX/Y` for fine offsets
- `text-align: center / left / right` for alignment

**Layer** per group:

- `bg` (default) — behind subject. Matte occludes. Cinematic embed. Works
  on clean zones (sky, walls) OR intentionally crosses subject for the
  Vogue-masthead effect.
- `fg` — on top of subject. Announcement. Use for climax breakthroughs or
  when bg would be 100% occluded by body.

**Tone** per group:

- `soft` — gentle per-word opacity+y drift (0.45s power2.out). Use for
  introspective, dreamy, or narrative content.
- `present` — snappy per-word opacity+y+scale pop (0.22s power3.out). Use
  for emphatic or declarative beats.

## Anti-patterns the DNA protects against

The DNA defaults prevent most ugly outcomes. But avoid in per-group CSS:

- Multiple different `font-family` within one video (DNA is one family)
- Multiple saturated colors (one accent at most; DNA defaults cream or
  deep warm)
- `letter-spacing` above ±0.05em unless there's a specific reason
- `font-weight` 300 or below — too light for screen
- `font-style: italic` on emphasis — italic is for lyricism, not stress

## When cinematic-cream is wrong

This DNA assumes mid-to-dark backgrounds (luminance 60-180). For:

- Bright white studios → cinematic-cream's cream + `screen` washes out, and the DNA is
  **locked** (you cannot recolour it) → use **Standard mode** (opaque rail) instead
- Documentary formal → Standard mode (documentary-dignified direction; see references/direction-catalog.md §1)
- Energetic vlog hooks → future `kinetic-vlog` DNA (not yet shipped)

## Example: memory-wall original, expressed in freeform

Same output as the canonical memory-wall.html, but using slot-free plan:

```json
{
  "template": "cinematic-cream",
  "caption_layer": "bg",
  "groups": [
    {
      "id": "cg-0",
      "tone": "soft",
      "in": 0.2,
      "out": 4.85,
      "css": "top: 14%; right: 6%; left: auto; text-align: right; font-size: calc(0.09 * var(--h)); font-weight: 600; font-style: italic; letter-spacing: -0.01em;",
      "words": [
        { "text": "Some", "start": 0.24, "end": 0.44 },
        { "text": "memories", "start": 0.48, "end": 0.82 },
        { "text": "feel", "start": 0.92, "end": 1.14 },
        { "text": "soft", "start": 1.2, "end": 1.64 }
      ]
    },
    {
      "id": "cg-1",
      "tone": "soft",
      "in": 2.55,
      "out": 4.85,
      "css": "top: 26%; right: 10%; left: auto; text-align: right; font-size: calc(0.075 * var(--h)); font-weight: 500; font-style: italic;",
      "words": [
        { "text": "like", "start": 2.66, "end": 2.78 },
        { "text": "old", "start": 2.88, "end": 3.02 },
        { "text": "film", "start": 3.16, "end": 3.46 }
      ]
    },
    {
      "id": "cg-2",
      "tone": "present",
      "in": 4.9,
      "out": 8.04,
      "css": "top: 18%; right: 6%; left: auto; text-align: right; font-size: calc(0.085 * var(--h)); font-weight: 700;",
      "words": [
        { "text": "but", "start": 5.04, "end": 5.18 },
        { "text": "suddenly", "start": 5.24, "end": 5.76 }
      ]
    },
    {
      "id": "cg-3",
      "tone": "present",
      "in": 6.7,
      "out": 8.04,
      "css": "top: 32%; right: 6%; left: auto; text-align: right; font-size: calc(0.11 * var(--h)); font-weight: 900; text-transform: uppercase; letter-spacing: -0.03em; line-height: 0.95;",
      "words": [
        { "text": "everything", "start": 6.82, "end": 7.14 },
        { "text": "is", "start": 7.18, "end": 7.28 },
        { "text": "sharp", "start": 7.32, "end": 7.52 },
        { "text": "again", "start": 7.58, "end": 7.92 }
      ]
    }
  ]
}
```

Same visual, no slot constraint. Each group owns its typography.
references/typography-presets.md
# Typography Presets

Five named styles corresponding to the `cap-*` CSS classes. Pick one per caption group in `plan.json` based on the line's semantic role.

| style    | CSS                         | Use when                                                                                                                  |
| -------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `intro`  | 66px italic 500             | First line, filler ("You know…", "So…"), contemplative openings. Low visual weight.                                       |
| `phrase` | 78px upright 600            | Main statement clauses. Default for most lines.                                                                           |
| `emph`   | 92px upright 800            | Lines with emotional peak or key achievement ("I've achieved incredible things").                                         |
| `dream`  | 82px italic 700             | Aspirational / "was dreaming of…" style lines. Italic signals memory/imagination.                                         |
| `crown`  | 140px upright 900 uppercase | **Only for the climax line.** Used in center-stage `crown-plane`. Maximum one per composition — ideally the last caption. |

## Tone field

Independent of style, each group has `tone`: `soft` or `present`.

- **soft** — gentle fade + 8px y-drift entrance, `power2.out` ease. Feels floaty, nostalgic. Use for memory, intro, dream.
- **present** — snappy 6px y + 1.04 scale pop, `power3.out`, transformOrigin center. Feels assertive, in-the-moment. Use for emphasis and crown.

## Picking automatically

Scan each group's words:

1. Does it contain superlatives (incredible, best, only, never, always) or brand/proper nouns? → `emph` + `present`
2. Does it start with discourse markers (you know, so, well, look) or is it short (≤3 words)? → `intro` + `soft`
3. Does it reference dreams, hopes, memory, past tense verbs like "was dreaming"? → `dream` + `soft`
4. Is it the final sentence-clinching line and feels like a title/headline? → `crown` + `present` (only one per video)
5. Otherwise → `phrase` + (match the group's neighbors' tone; default `soft`)

## Font-size scales with column width

The `.cap-*` defaults in each template are tuned for a **~560px column** (the original champion composition). When the caption plane is wider than that, the fonts feel underweight — too much negative space around text. Scale up accordingly.

| Plane width         | intro | phrase | emph | dream | crown (centered, full-frame) | crown (clean-zone only) |
| ------------------- | ----- | ------ | ---- | ----- | ---------------------------- | ----------------------- |
| 460-580 px (tight)  | 66    | 78     | 92   | 82    | 140                          | n/a                     |
| 600-760 px (medium) | 78    | 108    | 128  | 100   | 220                          | 118                     |
| 780+ px (wide)      | 90    | 128    | 150  | 116   | 260                          | 140                     |

Notes:

- Crown sizes assume landscape (1920×1080). For portrait 1080×1920, divide all crown sizes by 1.5.
- "full-frame crown" = centered, designed to span `0.8+ × frame_width` and cross the subject's body. Use only when [layout-heuristics.md § Crown placement](layout-heuristics.md) conditions pass.
- "clean-zone only crown" = crown positioned inside one clean zone, smaller font, tail letters lightly touch subject. Fallback when centered crown would eat too much.

Also: if a plane's `rotateY` is non-trivial (say > 8°), effective visible width shrinks. Scale fonts up ~10% to compensate.

## What NOT to do

- Don't use more than **one crown** per render. The whole point is that it's a singular payoff.
- Don't use `emph` on more than ~30% of groups. Emphasis loses meaning when everything is emphasized.
- Don't pick `intro` for the main content just because the words are short. "I won" (2 words) is `emph` or `crown`, not `intro`.
- Don't mix `soft` and `present` alternately for no reason. Tone should follow the story arc: soft opening → present build → emph peak → (optional crown climax).

## When scene luminance affects color choice

The default color is warm bone-white (`#fff5df`). Adjust in the template CSS:

- Wall is mostly warm wood / tan → keep default, or shift cooler `#e8f0ff`
- Wall is cool blue / tech-lab → push warmer `#fff0c0`
- Wall has strong primary color → desaturate default, luminance first

These adjustments happen in the HTML template, not plan.json. If you repeatedly need different palettes, make new template variants.
scripts/audio-envelope.cjs
#!/usr/bin/env node
/*
 * audio-envelope.cjs — extract a per-50ms RMS loudness envelope from the source audio.
 *
 *   node audio-envelope.cjs <project-dir>     → <project>/envelope.json
 *
 * { hop: 0.05, rms: [linear 0..1 per hop] }
 *
 * Used by lib-dna.cjs to couple the hero's entrance amplitude to how hard the word was
 * actually SPOKEN (percentile of the hero window's loudness within the clip). ffmpeg
 * astats over fixed windows — deterministic, no network, no Python.
 */
const path = require("path");
const fs = require("fs");
const cp = require("child_process");

const HOP = 0.05;

function findSource(project) {
  for (const c of ["source.mp4"].concat(
    fs
      .readdirSync(project)
      .filter(
        (f) =>
          /\.(mp4|mov|webm|mkv|m4v|wav|m4a|mp3)$/i.test(f) &&
          !/^(final|bg_plus_caps|fg_caps|rail|index)/.test(f),
      ),
  )) {
    const p = path.join(project, c);
    if (fs.existsSync(p)) return p;
  }
  return null;
}

function main() {
  const project = path.resolve(process.argv[2] || "");
  if (!process.argv[2]) {
    console.error("usage: audio-envelope.cjs <project-dir>");
    process.exit(1);
  }
  const src = findSource(project);
  if (!src) {
    console.error("[envelope] no source media found");
    process.exit(2);
  }

  // asetnsamples groups audio into fixed windows; astats prints RMS per window
  const sr = 16000,
    n = Math.round(sr * HOP);
  let out;
  try {
    // resample INSIDE the filter chain — an output-option -ar applies after the
    // filtergraph, which would make each window n/<input-rate> seconds instead of HOP
    out = cp.execFileSync(
      "ffmpeg",
      [
        "-v",
        "info",
        "-i",
        src,
        "-map",
        "a:0",
        "-af",
        `aresample=${sr},aformat=channel_layouts=mono,asetnsamples=n=${n}:p=0,astats=metadata=1:reset=1,ametadata=mode=print:key=lavfi.astats.Overall.RMS_level:file=-`,
        "-f",
        "null",
        "-",
      ],
      { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] },
    );
  } catch (e) {
    // ffmpeg writes the metadata to stdout before exiting; some containers still exit 0 — re-throw only if nothing parsed
    out = (e.stdout || "") + "";
    if (!out.includes("RMS_level")) {
      console.error(`[envelope] ffmpeg failed: ${e.message}`);
      process.exit(2);
    }
  }
  const rmsDb = [];
  for (const line of String(out).split("\n")) {
    const m = line.match(/lavfi\.astats\.Overall\.RMS_level=(-?[\d.]+|-inf)/);
    if (m) rmsDb.push(m[1] === "-inf" ? -90 : Math.max(-90, parseFloat(m[1])));
  }
  if (!rmsDb.length) {
    console.error("[envelope] no RMS windows parsed");
    process.exit(2);
  }
  const rms = rmsDb.map((db) => +Math.pow(10, db / 20).toFixed(5)); // linear 0..1
  fs.writeFileSync(path.join(project, "envelope.json"), JSON.stringify({ hop: HOP, rms }));
  const peakAt = rms.indexOf(Math.max(...rms)) * HOP;
  console.log(
    `[envelope] ${rms.length} windows @ ${HOP}s → envelope.json (loudest beat ~${peakAt.toFixed(2)}s)`,
  );
}
main();
scripts/check-occlusion.cjs
#!/usr/bin/env node
/* check-occlusion.cjs — pixel-perfect occlusion gate (Node port of check-occlusion-v2.py).
 * Runs measure-layout.cjs (real Chromium DOM rects), reads the subject-matte alpha via sharp,
 * computes per-word / per-cap occlusion. No Python.
 *   node check-occlusion.cjs <project-dir> [--strict] [--word-fail F] [--word-warn F] [--cap-fail F] [--remeasure]
 */
const path = require("path");
const fs = require("fs");
const os = require("os");
const cp = require("child_process");

function hfResolve(pkg) {
  const roots = [
    process.env.HYPERFRAMES_ROOT,
    path.resolve(__dirname, "..", "..", ".."),
    path.join(os.homedir(), "Downloads", "hyperframes"),
  ].filter(Boolean);
  for (const root of roots) {
    const cands = [path.join(root, "node_modules", pkg)];
    const bun = path.join(root, "node_modules", ".bun");
    try {
      if (fs.existsSync(bun))
        for (const d of fs.readdirSync(bun))
          if (d.startsWith(pkg + "@")) cands.push(path.join(bun, d, "node_modules", pkg));
    } catch {}
    for (const c of cands) {
      try {
        if (fs.existsSync(c)) return require(c);
      } catch {}
    }
  }
  console.error(`[v2] cannot find ${pkg} — set HYPERFRAMES_ROOT`);
  process.exit(3);
}
const sharp = hfResolve("sharp");

function ensureLayoutMeasured(project, force) {
  const lp = path.join(project, "_layout.json"),
    idx = path.join(project, "index.html");
  let stale = force || !fs.existsSync(lp);
  if (!stale && fs.existsSync(idx) && fs.statSync(idx).mtimeMs > fs.statSync(lp).mtimeMs)
    stale = true;
  if (stale)
    cp.execFileSync("node", [path.join(__dirname, "measure-layout.cjs"), project], {
      stdio: "inherit",
    });
  return JSON.parse(fs.readFileSync(lp, "utf8"));
}
async function loadAlphaMask(png) {
  if (!fs.existsSync(png)) return null;
  const { data, info } = await sharp(png)
    .ensureAlpha()
    .extractChannel(3)
    .raw()
    .toBuffer({ resolveWithObject: true });
  return { data, W: info.width, H: info.height };
}
function occlusionForRect(m, x, y, w, h) {
  if (!m) return 0;
  const x0 = Math.max(0, Math.round(x)),
    y0 = Math.max(0, Math.round(y));
  const x1 = Math.min(m.W, Math.round(x + w)),
    y1 = Math.min(m.H, Math.round(y + h));
  if (x1 <= x0 || y1 <= y0) return 0;
  let cnt = 0,
    tot = 0;
  for (let yy = y0; yy < y1; yy++) {
    const row = yy * m.W;
    for (let xx = x0; xx < x1; xx++) {
      tot++;
      if (m.data[row + xx] > 128) cnt++;
    }
  }
  return tot ? cnt / tot : 0;
}
function argf(name, d) {
  const i = process.argv.indexOf(name);
  return i >= 0 ? parseFloat(process.argv[i + 1]) : d;
}

async function main() {
  const project = path.resolve(process.argv[2] || "");
  if (!process.argv[2]) {
    console.error("usage: check-occlusion.cjs <project-dir> [--strict]");
    process.exit(1);
  }
  const strict = process.argv.includes("--strict");
  const wordFail = argf("--word-fail", 0.65),
    wordWarn = argf("--word-warn", 0.35),
    capFail = argf("--cap-fail", 0.5);
  const layout = ensureLayoutMeasured(project, process.argv.includes("--remeasure"));
  const framesDir = path.join(project, "frames_fg");
  if (!fs.existsSync(framesDir)) {
    console.error(`[v2] missing ${framesDir}`);
    process.exit(2);
  }
  const planPath = path.join(project, "plan.json");
  const plan = fs.existsSync(planPath) ? JSON.parse(fs.readFileSync(planPath, "utf8")) : {};
  const planLayer = plan.caption_layer || "bg";
  // Hero groups (the ONE big promoted word) are SUPPOSED to sit ON the subject — for them
  // occlusion is a TARGET (~30–55%), not "minimize". Collect their ids for the advisory below.
  const heroIds = new Set();
  const heroIn = {};
  for (const g of plan.groups || [])
    if (g && (g.hero === true || /^(hero|crown)$/i.test(g.plane || ""))) {
      heroIds.add(g.id);
      heroIn[g.id] = g.in;
    }
  if (plan.crown_group && plan.crown_group.id) {
    heroIds.add(plan.crown_group.id);
    heroIn[plan.crown_group.id] = plan.crown_group.in;
  }
  const M = 2; // frame-edge tolerance (px) — matches check-overflow.cjs
  const frameW = layout.width,
    frameH = layout.height;

  const capStats = {};
  for (const sample of layout.samples) {
    const png = path.join(framesDir, `f_${String(sample.frame_idx).padStart(4, "0")}.png`);
    const mask = await loadAlphaMask(png);
    if (!mask) continue;
    for (const cap of sample.caps) {
      const entry = (capStats[cap.id] ||= { layer: cap.layer || planLayer, samples: [] });
      const wordsData = [];
      for (const w of cap.words || []) {
        if ((w.opacity ?? 1) < 0.3) continue;
        wordsData.push({ text: w.text, occlusion: occlusionForRect(mask, w.x, w.y, w.w, w.h) });
        // Frame-edge overflow — clipped SETTLED text is always wrong; a hero's first
        // 0.5s is its entrance TRANSIENT (slam over-scale, streak fly-in pass through
        // off-frame states by design) — judge overflow on the hold, not mid-flight.
        if (heroIds.has(cap.id) && heroIn[cap.id] != null && sample.t < heroIn[cap.id] + 0.5)
          continue;
        const off = {
          left: Math.max(0, Math.round(-w.x - M)),
          right: Math.max(0, Math.round(w.x + w.w - frameW - M)),
          top: Math.max(0, Math.round(-w.y - M)),
          bottom: Math.max(0, Math.round(w.y + w.h - frameH - M)),
        };
        const score = off.left + off.right + off.top + off.bottom;
        if (score > 0 && (!entry.overflow || score > entry.overflow.score))
          entry.overflow = { text: w.text, off, score, t: sample.t };
      }
      const capOccl = occlusionForRect(
        mask,
        cap.cap_bbox.x,
        cap.cap_bbox.y,
        cap.cap_bbox.w,
        cap.cap_bbox.h,
      );
      entry.samples.push({
        t: sample.t,
        cap_occl: capOccl,
        cap_bbox: cap.cap_bbox,
        words: wordsData,
      });
    }
  }

  const failures = [];
  console.log(
    `[v2] ${path.basename(project)}  word-fail≥${(wordFail * 100).toFixed(0)}%  cap-fail≥${(capFail * 100).toFixed(0)}%`,
  );
  for (const [gid, entry] of Object.entries(capStats)) {
    // Frame-edge overflow applies to every layer (fg too). The skill allows the
    // climax a few-px graze on the first/last letter, so only a clear glyph clip
    // (>8px past an edge) is a hard FAIL; a sub-glyph graze is a WARN.
    if (entry.overflow) {
      const o = entry.overflow;
      const maxOff = Math.max(o.off.left, o.off.right, o.off.top, o.off.bottom);
      const sides = Object.entries(o.off)
        .filter(([, v]) => v > 0)
        .map(([s, v]) => `${s} ${v}px`)
        .join(", ");
      const hard = maxOff >= 8;
      console.log(
        `  ${gid}  [overflow${hard ? "" : "-warn"}] "${o.text}" off-frame: ${sides} (@${o.t}s)` +
          (hard ? " — cropped text is always wrong" : " (graze — within climax tolerance)"),
      );
      if (hard && !failures.includes(gid)) failures.push(gid);
    }
    if (entry.layer === "fg") {
      console.log(`  ${gid}  fg    (occlusion skipped — fg renders above matte)`);
      continue;
    }
    const capOccls = entry.samples.map((s) => s.cap_occl);
    const avgCap = capOccls.length ? capOccls.reduce((a, b) => a + b, 0) / capOccls.length : 0;
    const peakCap = capOccls.length ? Math.max(...capOccls) : 0;
    const wordPeaks = {};
    for (const s of entry.samples)
      for (const wd of s.words)
        wordPeaks[wd.text] = Math.max(wordPeaks[wd.text] || 0, wd.occlusion);
    const oblit = Object.entries(wordPeaks).filter(([, p]) => p >= wordFail);
    const warn = Object.entries(wordPeaks).filter(([, p]) => p >= wordWarn && p < wordFail);
    let status = "OK";
    // HERO caps WANT occlusion (~30–55% is the embed); the generic cap threshold would
    // fail a working hero. Heroes fail only past the feasibility ceiling (68%).
    const capLimit = heroIds.has(gid) ? Math.max(capFail, 0.68) : capFail;
    if (oblit.length || peakCap >= capLimit) {
      status = "FAIL";
      failures.push(gid);
    } else if (warn.length) status = "WARN";
    let s = "";
    if (oblit.length)
      s =
        oblit
          .slice(0, 5)
          .map(([t, p]) => `${t}(${(p * 100).toFixed(0)}%)`)
          .join(" ") + (oblit.length > 5 ? ` …+${oblit.length - 5}` : "");
    else if (warn.length)
      s =
        "[warn] " +
        warn
          .slice(0, 3)
          .map(([t, p]) => `${t}(${(p * 100).toFixed(0)}%)`)
          .join(" ");
    console.log(
      `  ${gid}  ${entry.layer}  avg ${(avgCap * 100).toFixed(0)}%  peak ${(peakCap * 100).toFixed(0)}%  ${status}  ${s}`,
    );
    // HERO target-occlusion advisory (not a failure): a hero should sit ON the subject
    // (~30–55%). If it barely grazes, it reads as a small floating word, not an embed.
    if (heroIds.has(gid) && peakCap < 0.15) {
      // METRIC HONESTY: this advisory uses CAP-AREA occlusion, which saturates ~15%
      // for a width-filled hero over a narrow subject (the 30–55% figure elsewhere is
      // the safe-zones BAND metric — different denominator). If the hero already owns
      // the width, "center it + make it BIG" is unactionable — stay quiet.
      const widest = Math.max(...entry.samples.map((sm) => (sm.cap_bbox && sm.cap_bbox.w) || 0), 0);
      if (widest >= frameW * 0.8) {
        console.log(
          `  ${gid}  [hero-ok] peak ${(peakCap * 100).toFixed(0)}% cap-area — width-saturated hero over a narrow subject; cap-area can't reach the band target (this is the geometry, not a layout fault).`,
        );
      } else {
        console.log(
          `  ${gid}  [hero-weak] peak ${(peakCap * 100).toFixed(0)}% — hero barely crosses the subject; it should sit ON the subject (~30–55% by the safe-zones BAND metric = the embed effect). Center it (safe-zones heroAnchor) + make it BIG; don't park it in a clean margin.`,
        );
      }
    }
  }
  if (failures.length) {
    const uniq = [...new Set(failures)];
    console.error(`\n[v2] ${uniq.length} cap(s) FAIL: ${uniq.join(", ")}`);
    console.error(
      "  → occlusion: set that cap's layer to fg, OR shrink/reposition;  overflow: shrink/reposition (fg won't help)",
    );
  }
  process.exit(strict && failures.length ? 2 : 0);
}
main().catch((e) => {
  console.error("[v2]", e.message);
  process.exit(1);
});
scripts/check-overflow.cjs
#!/usr/bin/env node
/**
 * check-overflow.cjs — mode-agnostic frame-overflow WARNING for custom mode.
 *
 * Template mode has check-occlusion.cjs (which also flags frame-edge overflow),
 * but custom mode runs no gates. This is the cheap safety net: it loads the
 * rendered index.html, seeks the GSAP timeline across the clip, and flags ANY
 * visible text element (regardless of class) whose box leaves the canvas — i.e.
 * captions that fall off-frame (the bug we otherwise only catch by eye).
 *
 * WARNING ONLY — never fails the build (custom designs may bleed intentionally).
 * Exit 0 if it ran (with or without findings); exit 3 if it couldn't run.
 *
 * Usage: node check-overflow.cjs <project-dir>
 */
const path = require("path");
const fs = require("fs");
const os = require("os");

const HF_ROOTS = [
  process.env.HYPERFRAMES_ROOT,
  path.resolve(__dirname, "../../.."),
  path.join(os.homedir(), "Downloads", "hyperframes"),
].filter(Boolean);
let puppeteer = null;
for (const root of HF_ROOTS) {
  const cands = [path.join(root, "node_modules", "puppeteer")];
  const bunDir = path.join(root, "node_modules", ".bun");
  try {
    if (fs.existsSync(bunDir)) {
      for (const d of fs.readdirSync(bunDir))
        if (d.startsWith("puppeteer@"))
          cands.push(path.join(bunDir, d, "node_modules", "puppeteer"));
    }
  } catch {
    /* ignore */
  }
  for (const p of cands) {
    try {
      if (fs.existsSync(p)) {
        puppeteer = require(p);
        break;
      }
    } catch {}
  }
  if (puppeteer) break;
}
if (!puppeteer) {
  console.error("[overflow] puppeteer not found");
  process.exit(3);
}

async function main() {
  const projectDir = process.argv[2];
  const htmlName = process.argv[3] || "index.html"; // Standard mode passes "rail.html" to gate the rail too
  const indexPath = path.resolve(projectDir, htmlName);
  if (!fs.existsSync(indexPath)) {
    console.error(`[overflow] missing ${indexPath}`);
    process.exit(2);
  }

  const html = fs.readFileSync(indexPath, "utf8");
  const num = (re, d) => {
    const m = html.match(re);
    return m ? parseFloat(m[1]) : d;
  };
  const W = num(/data-width="([0-9.]+)"/, 1920);
  const H = num(/data-height="([0-9.]+)"/, 1080);
  const DUR = num(/data-duration="([0-9.]+)"/, 8);

  const exe =
    process.platform === "darwin"
      ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
      : "/usr/bin/google-chrome";
  const browser = await puppeteer.launch({
    headless: "new",
    executablePath: fs.existsSync(exe) ? exe : undefined,
    args: [
      "--disable-web-security",
      "--allow-file-access-from-files",
      `--window-size=${W},${H}`,
      "--disable-dev-shm-usage",
    ],
  });
  try {
    const page = await browser.newPage();
    await page.setViewport({ width: Math.round(W), height: Math.round(H), deviceScaleFactor: 1 });
    const waitTL = async () => {
      const t0 = Date.now();
      while (Date.now() - t0 < 12000) {
        if (await page.evaluate(() => !!(window.__timelines && window.__timelines.main)))
          return true;
        await new Promise((r) => setTimeout(r, 200));
      }
      return false;
    };
    await page.goto(`file://${indexPath}`, { waitUntil: "load", timeout: 20000 });
    let hasTL = await waitTL();
    if (!hasTL) {
      // GSAP loads from CDN — a blip leaves no timeline; retry once
      await page.reload({ waitUntil: "load", timeout: 20000 }).catch(() => {});
      hasTL = await waitTL();
    }
    if (!hasTL) {
      // NEVER claim "ok" when we couldn't actually evaluate the animated layout.
      console.error(
        "[overflow] ⚠ timeline did not register (GSAP CDN blocked?) — overflow check " +
          "INCONCLUSIVE; eyeball the render for off-frame captions.",
      );
      await browser.close();
      process.exit(0);
    }
    await page.evaluate(async () => {
      try {
        await document.fonts.ready;
      } catch {}
    });

    const times = Array.from({ length: 9 }, (_, i) => +((DUR * i) / 8).toFixed(2));
    const found = new Map(); // key text → worst offense

    for (const t of times) {
      await page.evaluate((t) => {
        window.__timelines.main.seek(t);
        void document.body.offsetHeight;
      }, t);
      await new Promise((r) => setTimeout(r, 25));
      const offenders = await page.evaluate(
        (W, H) => {
          const M = 2; // tolerance px
          const root = document.querySelector("#stage") || document.body;
          const out = [];
          for (const el of root.querySelectorAll("*")) {
            if (el.tagName === "VIDEO" || el.tagName === "AUDIO") continue;
            const own = [...el.childNodes]
              .filter((n) => n.nodeType === 3)
              .map((n) => n.textContent.trim())
              .join(" ")
              .trim();
            if (!own) continue;
            const cs = getComputedStyle(el);
            if (
              cs.display === "none" ||
              cs.visibility === "hidden" ||
              parseFloat(cs.opacity) < 0.06
            )
              continue;
            const b = el.getBoundingClientRect();
            if (b.width === 0 || b.height === 0) continue;
            const off = {
              left: Math.max(0, Math.round(-b.left - M)),
              right: Math.max(0, Math.round(b.right - W - M)),
              top: Math.max(0, Math.round(-b.top - M)),
              bottom: Math.max(0, Math.round(b.bottom - H - M)),
            };
            if (off.left || off.right || off.top || off.bottom)
              out.push({ text: own.slice(0, 42), off });
          }
          return out;
        },
        W,
        H,
      );
      for (const o of offenders) {
        const sides = Object.entries(o.off)
          .filter(([, v]) => v > 0)
          .map(([s, v]) => `${s} ${v}px`)
          .join(", ");
        const prev = found.get(o.text);
        const score = Object.values(o.off).reduce((a, b) => a + b, 0);
        if (!prev || score > prev.score) found.set(o.text, { sides, score, t });
      }
    }

    if (found.size === 0) {
      console.log(`[overflow] ok — no caption text leaves the ${W}x${H} frame`);
    } else {
      console.error(
        `[overflow] ⚠ ${found.size} caption(s) leave the frame (custom mode — WARNING only, not blocking):`,
      );
      for (const [text, info] of found)
        console.error(`           "${text}"  → off-frame: ${info.sides}  (@${info.t}s)`);
      console.error(
        `[overflow] if unintentional, reposition/resize; if it's deliberate bleed, ignore.`,
      );
    }
  } finally {
    await browser.close();
  }
}
main().catch((e) => {
  console.error("[overflow]", e.message);
  process.exit(3);
});
scripts/check-rail-climax.cjs
#!/usr/bin/env node
/*
 * check-rail-climax.cjs — enforce the Rail ↔ climax hand-off (PIPELINE.md).
 *
 *   node check-rail-climax.cjs <project-dir>
 *
 * The embed climax is a PROMOTED word — lifted out of the rail into the hero
 * layer. It must NEVER also be revealed in the rail. This gate loads index.html
 * (the climax) and rail.html (the verbatim rail) in headless Chromium, finds the
 * climax's on-screen window + its word(s), then checks whether the rail reveals
 * any of those same words DURING that window. If it does, the promoted word is
 * duplicated on screen → exit 2 (the render aborts). Standard mode only.
 *
 * Conservative by design: only a CONFIRMED duplicate fails. Anything we can't
 * determine (no rail.html, no puppeteer, timeline never registers, no climax)
 * exits 0 — infra problems never block a render.
 */
const path = require("path");
const fs = require("fs");
const os = require("os");

const HF_ROOTS = [
  process.env.HYPERFRAMES_ROOT,
  path.resolve(__dirname, "../../.."),
  path.join(os.homedir(), "Downloads", "hyperframes"),
].filter(Boolean);

function findInBun(root, pkg, sub) {
  const cands = [path.join(root, "node_modules", pkg)];
  const bunDir = path.join(root, "node_modules", ".bun");
  try {
    if (fs.existsSync(bunDir))
      for (const d of fs.readdirSync(bunDir))
        if (d.startsWith(pkg + "@")) cands.push(path.join(bunDir, d, "node_modules", pkg));
  } catch {
    /* ignore */
  }
  for (const c of cands) {
    const p = sub ? path.join(c, sub) : c;
    if (fs.existsSync(p)) return p;
  }
  return null;
}

let puppeteer = null,
  gsapSource = null;
for (const root of HF_ROOTS) {
  if (!puppeteer) {
    const p = findInBun(root, "puppeteer");
    if (p) {
      try {
        puppeteer = require(p);
      } catch {}
    }
  }
  if (!gsapSource) {
    const g = findInBun(root, "gsap", path.join("dist", "gsap.min.js"));
    if (g) gsapSource = fs.readFileSync(g, "utf8");
  }
}

const norm = (s) =>
  String(s)
    .toLowerCase()
    .replace(/[^a-z0-9]/g, "");
const toks = (s) =>
  String(s)
    .split(/\s+/)
    .map(norm)
    .filter((t) => t.length >= 2);

function ok(msg) {
  if (msg) console.log(msg);
  process.exit(0);
} // can't-determine / pass → never blocks
function fail(msg) {
  console.error(msg);
  process.exit(2);
}

async function newPage(browser, W, H) {
  const page = await browser.newPage();
  await page.setViewport({ width: W, height: H, deviceScaleFactor: 1 });
  if (gsapSource) {
    await page.evaluateOnNewDocument(gsapSource);
    await page.setRequestInterception(true);
    page.on("request", (req) => {
      const u = req.url();
      if (req.resourceType() === "script" && /gsap/i.test(u) && /^https?:/i.test(u)) req.abort();
      else req.continue();
    });
  }
  return page;
}
async function load(page, file) {
  await page.goto(`file://${file}`, { waitUntil: "load", timeout: 15000 });
  const start = Date.now();
  while (Date.now() - start < 12000) {
    if (await page.evaluate(() => !!(window.__timelines && window.__timelines.main))) {
      try {
        await page.evaluate(async () => {
          await document.fonts.ready;
        });
      } catch {}
      return true;
    }
    await new Promise((r) => setTimeout(r, 150));
  }
  return false;
}
// visible text of a selector at time t, by seeking the page's main timeline.
// Visibility must be EFFECTIVE (own opacity × every ancestor's): a rail line that
// fades out as a CONTAINER leaves its word spans at opacity:1 — checking only the
// span's own style false-positives on text that is actually invisible.
async function visibleAt(page, t, selector, childSel) {
  return page.evaluate(
    (t, selector, childSel) => {
      const tl = window.__timelines.main;
      tl.seek(t);
      void document.body.offsetHeight;
      const eff = (el) => {
        let o = 1,
          n = el;
        while (n && n.nodeType === 1) {
          const cs = getComputedStyle(n);
          if (cs.display === "none" || cs.visibility === "hidden") return 0;
          o *= +cs.opacity;
          n = n.parentElement;
        }
        return o;
      };
      const out = [];
      for (const el of document.querySelectorAll(selector)) {
        if (eff(el) < 0.05) continue;
        const kids = childSel ? [...el.querySelectorAll(childSel)] : [];
        if (kids.length) {
          for (const k of kids) {
            if (eff(k) > 0.05) out.push(k.textContent);
          }
        } else if ((el.textContent || "").trim()) out.push(el.textContent);
      }
      return out.join(" ").trim();
    },
    t,
    selector,
    childSel,
  );
}

async function main() {
  const project = path.resolve(process.argv[2] || "");
  if (!process.argv[2]) ok("[rail-climax] usage: check-rail-climax.cjs <project-dir>");
  const indexPath = path.join(project, "index.html");
  const railPath = path.join(project, "rail.html");
  if (!fs.existsSync(railPath) || !fs.existsSync(indexPath))
    ok("[rail-climax] no rail.html+index.html — not Standard, skipping");
  if (!puppeteer) ok("[rail-climax] puppeteer unavailable — skipping (set HYPERFRAMES_ROOT)");

  const exe =
    process.platform === "darwin"
      ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
      : "/usr/bin/google-chrome";
  let browser;
  try {
    browser = await puppeteer.launch({
      headless: "new",
      executablePath: fs.existsSync(exe) ? exe : undefined,
      args: ["--disable-web-security", "--allow-file-access-from-files", "--disable-dev-shm-usage"],
    });
  } catch (e) {
    ok(`[rail-climax] could not launch Chromium — skipping (${e.message})`);
  }

  try {
    // index.html → climax window + climax word tokens
    const ip = await newPage(browser, 1920, 1080);
    if (!(await load(ip, indexPath)))
      ok("[rail-climax] index timeline never registered — skipping");
    const dur =
      +(await ip.evaluate(() => {
        const r = document.querySelector("#root") || document.querySelector("[data-duration]");
        return (r && r.dataset && r.dataset.duration) || 0;
      })) || 30;
    const STEP = 0.1;
    const climaxTokens = new Set();
    let winIn = null,
      winOut = null;
    for (let t = 0; t <= dur + 0.001; t += STEP) {
      const txt = await visibleAt(ip, +t.toFixed(2), ".climax", "span,.w");
      if (txt) {
        toks(txt).forEach((w) => climaxTokens.add(w));
        if (winIn === null) winIn = t;
        winOut = t;
      }
    }
    if (!climaxTokens.size || winIn === null)
      ok("[rail-climax] no visible climax found in index.html — skipping");

    // rail.html → words visible during [winIn, winOut]
    const rp = await newPage(browser, 1920, 1080);
    if (!(await load(rp, railPath))) ok("[rail-climax] rail timeline never registered — skipping");
    const hits = new Map(); // token -> first t seen
    for (let t = Math.max(0, winIn - STEP); t <= winOut + 0.001; t += STEP) {
      const railToks = new Set(toks(await visibleAt(rp, +t.toFixed(2), ".w", null)));
      for (const w of climaxTokens) if (railToks.has(w) && !hits.has(w)) hits.set(w, +t.toFixed(2));
    }

    if (hits.size) {
      const list = [...hits.entries()]
        .map(([w, t]) => `"${w}" (rail shows it at t=${t}s)`)
        .join(", ");
      fail(
        `[rail-climax] ✗ DUPLICATE PROMOTED WORD: the climax word(s) ${[...climaxTokens].map((w) => `"${w}"`).join(", ")} ` +
          `are on screen during the climax window [${winIn.toFixed(2)}–${winOut.toFixed(2)}s], ` +
          `and the rail ALSO reveals: ${list}.\n` +
          `  The promoted word must be handed off, never duplicated — see PIPELINE.md "Rail ↔ climax hand-off":\n` +
          `  freeze the rail before the promoted word, let the climax carry it, hold the climax across the rail's\n` +
          `  page-flip, and exit it at the end of the thought. The rail must never reveal the promoted word.`,
      );
    }
    console.log(
      `[rail-climax] ✓ ok — promoted word(s) ${[...climaxTokens].map((w) => `"${w}"`).join(", ")} not duplicated in the rail (window ${winIn.toFixed(2)}–${winOut.toFixed(2)}s)`,
    );
  } finally {
    await Promise.race([browser.close().catch(() => {}), new Promise((r) => setTimeout(r, 8000))]);
  }
}
main()
  .then(() => process.exit(0))
  .catch((e) => {
    console.error(`[rail-climax] (skipped — ${e.message})`);
    process.exit(0);
  });
scripts/check-timing.cjs
#!/usr/bin/env node
/* check-timing.cjs — verify plan.json word timings vs transcript.json (1:1 port of check-timing.py).
 *   node check-timing.cjs <project-dir> [--strict]
 */
const path = require("path");
const fs = require("fs");

const DRIFT_TOL = 0.08;
const CREATIVE_SUBS = {
  "15%": ["fifteen", "percent"],
  "1/3": ["one", "third"],
  "2x": ["two", "times"],
};
const norm = (s) =>
  String(s)
    .replace(/^[ .,?!"']+|[ .,?!"']+$/g, "")
    .toLowerCase();
const splitPacked = (t) =>
  String(t)
    .split(/<br>|\s+/)
    .filter(Boolean);

function estimateVerticalBand(css, words, fw, fh) {
  const top = css.match(/top:\s*(-?[\d.]+)%/);
  if (!top) return null;
  const topPct = parseFloat(top[1]);
  const sizeM = css.match(/font-size:\s*calc\(\s*([\d.]+)\s*\*\s*var\(--h\)\s*\)/);
  const fontPx = sizeM ? parseFloat(sizeM[1]) * fh : 0.05 * fh;
  const lhM = css.match(/line-height:\s*([\d.]+)/);
  const lh = lhM ? parseFloat(lhM[1]) : 1.1;
  const charW = /uppercase/i.test(css) ? 0.72 : 0.6;
  let lineCount = 1,
    cur = 0;
  const spaceW = fontPx * 0.3;
  for (const w of words) {
    const parts = String(w.text).split(/<br>/);
    parts.forEach((part, i) => {
      const pw = part.length * fontPx * charW;
      if (i > 0) {
        lineCount++;
        cur = pw;
      } else {
        const test = cur + (cur > 0 ? spaceW : 0) + pw;
        if (test > fw && cur > 0) {
          lineCount++;
          cur = pw;
        } else cur = test;
      }
    });
  }
  return [topPct, topPct + (100 * (lineCount * fontPx * lh)) / fh];
}

function check(project, strict) {
  const plan = JSON.parse(fs.readFileSync(path.join(project, "plan.json"), "utf8"));
  const t = JSON.parse(fs.readFileSync(path.join(project, "transcript.json"), "utf8"));
  const seq = (t.words || [])
    .filter((w) => w.type === "word")
    .map((w) => [norm(w.text), w.start, w.end]);
  const issues = [];
  const fw = plan.width || 720,
    fh = plan.height || 1290;
  const groups = plan.groups || [];
  const bands = groups.map((g) =>
    g.plane
      ? [g.id || "?", g.in, g.out, null, true]
      : [
          g.id || "?",
          g.in,
          g.out,
          estimateVerticalBand(g.css || "", g.words || [], fw, fh),
          g.allow_overlap || false,
        ],
  );
  for (let i = 0; i < bands.length; i++) {
    const [gi, ai, bi, bbi, oki] = bands[i];
    if (!bbi) continue;
    for (let j = i + 1; j < bands.length; j++) {
      const [gj, aj, bj, bbj, okj] = bands[j];
      if (!bbj || oki || okj) continue;
      if (Math.min(bi, bj) - Math.max(ai, aj) <= 0.05) continue;
      if (Math.min(bbi[1], bbj[1]) - Math.max(bbi[0], bbj[0]) > 2.0)
        issues.push(
          `[${gi}↔${gj}] groups overlap in time (${Math.max(ai, aj).toFixed(2)}–${Math.min(bi, bj).toFixed(2)}s) AND vertically (band ${Math.max(bbi[0], bbj[0]).toFixed(0)}%–${Math.min(bbi[1], bbj[1]).toFixed(0)}%) — reposition one or add "allow_overlap": true if deliberate.`,
        );
    }
  }
  let ti = 0;
  for (const g of groups) {
    const gid = g.id || "?",
      gin = g.in,
      gout = g.out;
    const ws = (g.words || []).map((w) => w.start),
      we = (g.words || []).map((w) => w.end);
    if (ws.length && gin != null) {
      const e = Math.min(...ws);
      if (e < gin - 0.01)
        issues.push(
          `[${gid}] group.in=${gin.toFixed(2)} but earliest word starts at ${e.toFixed(2)} — word delayed by ${(gin - e >= 0 ? "+" : "") + (gin - e).toFixed(2)}s. Lower group.in.`,
        );
    }
    if (we.length && gout != null) {
      const l = Math.max(...we);
      if (l > gout + 0.01)
        issues.push(
          `[${gid}] group.out=${gout.toFixed(2)} but latest word ends at ${l.toFixed(2)} — word clipped. Raise group.out.`,
        );
    }
    for (const w of g.words || []) {
      // Exact pairing: a compiler that KNOWS the transcript index emits `ti` —
      // duplicate words then pair by POSITION, not text-search (which grabs the
      // first occurrence and reports a phantom drift). Sanity-check the text; on
      // mismatch fall back to the text matcher below.
      if (
        Number.isInteger(w.ti) &&
        seq[w.ti] &&
        seq[w.ti][0] === norm(splitPacked(w.text)[0] || "")
      ) {
        const drift = w.start - seq[w.ti][1];
        if (Math.abs(drift) > DRIFT_TOL)
          issues.push(
            `[${gid}] '${norm(w.text)}': plan=${w.start.toFixed(3)} transcript=${seq[w.ti][1].toFixed(3)} drift ${(drift >= 0 ? "+" : "") + drift.toFixed(3)}s`,
          );
        ti = w.ti + 1;
        continue;
      }
      const parts = splitPacked(w.text).map(norm);
      parts.forEach((part, pi) => {
        if (!part) return;
        if (part in CREATIVE_SUBS) {
          for (const sub of CREATIVE_SUBS[part]) {
            const j = seq.findIndex((s, k) => k >= ti && s[0] === sub);
            if (j >= 0) ti = j + 1;
          }
          return;
        }
        let found = seq.findIndex((s, k) => k >= ti && s[0] === part);
        if (found < 0) found = seq.findIndex((s) => s[0] === part);
        if (found < 0) {
          issues.push(`[${gid}] '${part}': NOT IN TRANSCRIPT (plan start=${w.start.toFixed(3)})`);
          return;
        }
        const ts = seq[found][1];
        ti = found + 1;
        if (pi === 0) {
          const drift = w.start - ts;
          if (Math.abs(drift) > DRIFT_TOL)
            issues.push(
              `[${gid}] '${part}': plan=${w.start.toFixed(3)} transcript=${ts.toFixed(3)} drift ${(drift >= 0 ? "+" : "") + drift.toFixed(3)}s`,
            );
        } else
          issues.push(
            `[${gid}] '${part}' packed with '${parts[0]}': transcript=${ts.toFixed(3)} but entry has no distinct timing — split into separate word entries`,
          );
      });
    }
  }
  const name = path.basename(project);
  if (!issues.length) {
    console.log(`${name}: timing OK ✓ (${seq.length} transcript words)`);
    return 0;
  }
  console.log(`${name}: ${issues.length} timing issue(s):`);
  for (const i of issues) console.log(`  ${i}`);
  return strict ? 1 : 0;
}

const args = process.argv.slice(2).filter((a) => a !== "--strict");
if (!args.length) {
  console.error("usage: check-timing.cjs <project-dir> [--strict]");
  process.exit(2);
}
process.exit(check(path.resolve(args[0]), process.argv.includes("--strict")));
scripts/fill-timings.cjs
#!/usr/bin/env node
/*
 * fill-timings.cjs — fill plan.json group/word times DIRECTLY from transcript.json,
 * by SEQUENCE (Cinematic mode). Kills the timing-drift + duplicate-word class of
 * occlusion/timing-gate re-tries: the agent only decides the GROUPING (which words form
 * each group, in spoken order); times come from the transcript by position, so the
 * second "and"/"actions" matches the right occurrence (text-matching pairs the wrong one).
 *
 *   node fill-timings.cjs <project-dir>
 *
 * Reads  <project>/plan.json (groups[].words[].text, in spoken order) + transcript.json.
 * Writes <project>/plan.json with each word's start/end and each group's in/out filled.
 * A group word that can't be matched keeps whatever time it had + is reported (so a typo
 * or an added word degrades gracefully instead of silently mis-timing).
 */
const path = require("path");
const fs = require("fs");

const norm = (s) =>
  String(s == null ? "" : s)
    .toLowerCase()
    .replace(/[^a-z0-9]/g, "");
const LOOKAHEAD = 40; // how far past the pointer to search for a group word (skips dropped fillers)

function fillGroup(g, tw, state) {
  const matched = [];
  const unmatched = [];
  for (const w of g.words || []) {
    const target = norm(w.text);
    if (!target) {
      unmatched.push(w.text);
      continue;
    }
    let found = -1;
    for (let j = state.p; j < Math.min(tw.length, state.p + LOOKAHEAD); j++) {
      if (norm(tw[j].text) === target) {
        found = j;
        break;
      }
    }
    if (found >= 0) {
      w.start = tw[found].start;
      w.end = tw[found].end;
      matched.push(w);
      state.p = found + 1;
    } else {
      unmatched.push(w.text);
    }
  }
  if (matched.length) {
    const firstStart = +matched[0].start.toFixed(3);
    const lastEnd = +matched[matched.length - 1].end.toFixed(3);
    // WORD start/end are filled above (deterministic). For the group's DISPLAY window we
    // only GUARANTEE it brackets the words (never clip a word) — we do NOT tighten it:
    // a deliberately later `out` is the apex HOLD / sentence ACCUMULATION the author set,
    // and an earlier `in` is a pre-entry. Preserve both; clamp only if they'd clip.
    g.in = g.in == null ? firstStart : Math.min(g.in, firstStart);
    g.out = g.out == null ? lastEnd : Math.max(g.out, lastEnd);
  }
  return { matched: matched.length, unmatched };
}

function main() {
  const project = path.resolve(process.argv[2] || "");
  if (!process.argv[2]) {
    console.error("usage: fill-timings.cjs <project-dir>");
    process.exit(1);
  }
  const planPath = path.join(project, "plan.json");
  const trPath = path.join(project, "transcript.json");
  let plan, trWordsRaw;
  try {
    plan = JSON.parse(fs.readFileSync(planPath, "utf8"));
  } catch {
    console.error(`[fill-timings] no plan.json (Cinematic only) — skipping`);
    process.exit(0);
  }
  try {
    trWordsRaw = JSON.parse(fs.readFileSync(trPath, "utf8")).words || [];
  } catch {
    console.error(`[fill-timings] no transcript.json — skipping`);
    process.exit(0);
  }
  const tw = trWordsRaw.filter((w) => w && "start" in w && "end" in w);
  if (!tw.length) {
    console.error(`[fill-timings] transcript has no word timings — skipping`);
    process.exit(0);
  }

  const state = { p: 0 };
  let totalMatched = 0,
    totalUnmatched = 0;
  const groups = plan.groups || [];
  // crown_group (if any) is spoken last in the standard templates; process groups in order, crown after.
  const ordered = [...groups];
  for (const g of ordered) {
    const r = fillGroup(g, tw, state);
    totalMatched += r.matched;
    totalUnmatched += r.unmatched.length;
    if (r.unmatched.length)
      console.error(
        `[fill-timings] ⚠ ${g.id || "(group)"}: ${r.unmatched.length} word(s) not found in transcript from here: ${r.unmatched.join(" ")}`,
      );
  }
  if (plan.crown_group) {
    const r = fillGroup(plan.crown_group, tw, state);
    totalMatched += r.matched;
    totalUnmatched += r.unmatched.length;
    if (r.unmatched.length)
      console.error(`[fill-timings] ⚠ crown_group: ${r.unmatched.join(" ")} not found`);
  }

  fs.writeFileSync(planPath, JSON.stringify(plan, null, 2));
  console.log(
    `[fill-timings] filled ${totalMatched} word time(s) from transcript by sequence` +
      (totalUnmatched
        ? `; ${totalUnmatched} unmatched (kept prior time — check those words)`
        : `; all matched ✓`),
  );
  console.log(
    `[fill-timings] group windows now: ${ordered.map((g) => `${g.id || "?"}[${g.in}-${g.out}]`).join(" ")}`,
  );
}
main();
scripts/fit-fonts.cjs
#!/usr/bin/env node
/*
 * fit-fonts.cjs — shrink any plan.json caption group whose single line would overflow
 * its box, BEFORE render (Cinematic mode). Kills the "font too big → off-frame → shrink
 * → re-render" overflow round. Estimates rendered width from character count × a
 * per-family advance × the group's font-size, compares to the group's box width (its
 * plane width, else the frame), and only ever SHRINKS to fit. Approximate by design —
 * it prevents gross overflow; check-overflow.cjs remains the exact terminal check.
 *
 *   node fit-fonts.cjs <project-dir>
 */
const path = require("path");
const fs = require("fs");

// rough per-family advance (em per char, caps-ish). Condensed faces are narrow; pixel
// faces wide. Default is conservative. (Estimate only — gate confirms.)
const ADV = {
  anton: 0.44,
  oswald: 0.45,
  teko: 0.4,
  "saira stencil one": 0.46,
  "bebas neue": 0.4,
  "press start 2p": 1.05,
  vt323: 0.52,
  monoton: 0.62,
  "special elite": 0.55,
  "jetbrains mono": 0.6,
  "space mono": 0.6,
  "ibm plex mono": 0.6,
  "source code pro": 0.6,
  "archivo black": 0.62,
  bangers: 0.5,
  "bodoni moda": 0.5,
  "playfair display": 0.52,
  cinzel: 0.62,
  "cormorant garamond": 0.45,
  fredoka: 0.55,
  "baloo 2": 0.55,
  "permanent marker": 0.55,
  caveat: 0.4,
  orbitron: 0.66,
  sora: 0.54,
  "space grotesk": 0.54,
};
const DEFAULT_ADV = 0.56;

const cssVal = (css, prop) => {
  const m = String(css || "").match(new RegExp(prop + "\\s*:\\s*([^;}]+)", "i"));
  return m ? m[1].trim() : null;
};
function familyOf(css) {
  const v = cssVal(css, "font-family");
  if (!v) return "inter";
  return v
    .split(",")[0]
    .trim()
    .replace(/^['"]|['"]$/g, "")
    .toLowerCase();
}
function sizePxOf(css, H) {
  const v = cssVal(css, "font-size");
  if (!v) return null;
  let m;
  if ((m = v.match(/calc\(\s*([\d.]+)\s*\*\s*var\(--h\)\s*\)/i))) return parseFloat(m[1]) * H;
  if ((m = v.match(/([\d.]+)\s*cqh/i))) return (parseFloat(m[1]) / 100) * H;
  if ((m = v.match(/([\d.]+)\s*vh/i))) return (parseFloat(m[1]) / 100) * H;
  if ((m = v.match(/([\d.]+)\s*px/i))) return parseFloat(m[1]);
  return null;
}
function planeWidthPx(plan, plane, W) {
  const p = plan.planes && plan.planes[plane];
  const css = p && (typeof p === "string" ? p : p.css);
  const v = cssVal(css, "width");
  if (!v) return null;
  let m;
  if ((m = v.match(/([\d.]+)\s*%/))) return (parseFloat(m[1]) / 100) * W;
  if ((m = v.match(/([\d.]+)\s*px/))) return parseFloat(m[1]);
  return null;
}
function setFontSize(css, fracOfH) {
  const decl = `font-size: calc(${fracOfH.toFixed(3)} * var(--h))`;
  if (/font-size\s*:/i.test(css || "")) return String(css).replace(/font-size\s*:\s*[^;}]+/i, decl);
  return (css ? css.replace(/\s*$/, "").replace(/;?\s*$/, "; ") : "") + decl + ";";
}

function main() {
  const project = path.resolve(process.argv[2] || "");
  if (!process.argv[2]) {
    console.error("usage: fit-fonts.cjs <project-dir>");
    process.exit(1);
  }
  const planPath = path.join(project, "plan.json");
  let plan;
  try {
    plan = JSON.parse(fs.readFileSync(planPath, "utf8"));
  } catch {
    console.error("[fit-fonts] no plan.json (Cinematic only) — skipping");
    process.exit(0);
  }
  const W = plan.width || 1920,
    H = plan.height || 1080;
  const groups = (plan.groups || []).concat(plan.crown_group ? [plan.crown_group] : []);
  const MARGIN = 0.92; // leave 8% breathing room inside the box
  const HERO_MIN_FRAC = 0.18; // a hero below ~0.18·h isn't "big" — keep it punchy, widen its box instead

  let changed = 0;
  for (const g of groups) {
    const text = (g.words || []).map((w) => w.text).join(" ");
    const nChars = text.replace(/\s/g, "").length + (g.words || []).length * 0.5; // glyphs + ~half-space gaps
    if (nChars < 1) continue;
    const fam = familyOf(g.css);
    let adv = ADV[fam] ?? DEFAULT_ADV;
    if (/text-transform\s*:\s*uppercase/i.test(g.css || "") || text === text.toUpperCase())
      adv *= 1.05;
    const sizePx = sizePxOf(g.css, H);
    if (!sizePx) continue; // no explicit size → template default handles it
    const boxW = (g.plane ? planeWidthPx(plan, g.plane, W) : null) || W;
    const usable = boxW * MARGIN;
    // A WRAPPING group only overflows if a SINGLE word is wider than the box (the rest
    // wraps). A NOWRAP group must fit its whole line. So don't shrink wrapping narration
    // down to one line — only force the whole line for nowrap; else just the longest word.
    const nowrap = /white-space\s*:\s*nowrap/i.test(g.css || "");
    const longestWord = (g.words || []).reduce(
      (m, w) => Math.max(m, String(w.text || "").replace(/\s/g, "").length),
      0,
    );
    const constrainChars = nowrap ? nChars : longestWord;
    if (constrainChars < 1) continue;
    const estW = constrainChars * adv * sizePx;
    if (estW > usable) {
      const newPx = usable / (constrainChars * adv);
      let frac = Math.max(0.02, newPx / H);
      const isHero = g.hero === true || /^(hero|crown)$/i.test(g.plane || "");
      if (isHero && frac < HERO_MIN_FRAC) {
        // The hero must stay BIG. If it won't fit its box while staying punchy, the BOX is
        // too narrow (the hero should span the subject) — keep it at the floor + flag it,
        // rather than silently shrinking the peak into a small word.
        frac = HERO_MIN_FRAC;
        g.css = setFontSize(g.css, frac);
        changed++;
        console.log(
          `[fit-fonts] ⚠ ${g.id || "(group)"} HERO "${text.slice(0, 28)}" won't fit box ${Math.round(usable)}px while staying big → KEPT at floor ${frac}·h. WIDEN the hero plane (it should span the subject, centered), not shrink the peak.`,
        );
        continue;
      }
      g.css = setFontSize(g.css, frac);
      changed++;
      console.log(
        `[fit-fonts] ${g.id || "(group)"}: "${text.slice(0, 28)}" (${nowrap ? "nowrap line" : "longest word"}) est ${Math.round(estW)}px > box ${Math.round(usable)}px → shrink to ${frac.toFixed(3)}·h (${Math.round(newPx)}px)`,
      );
    }
  }
  if (changed) {
    fs.writeFileSync(planPath, JSON.stringify(plan, null, 2));
    console.log(`[fit-fonts] shrank ${changed} group(s) to fit; wrote plan.json`);
  } else console.log("[fit-fonts] all groups fit their box — no change");
}
main();
scripts/gen-stroke-path.py
#!/usr/bin/env python3
"""gen-stroke-path.py — generalized draw-on path generator.

Lays out ANY word in a single-line (pen-path) SVG font — Hershey/EMS — and emits
one continuous-dash-revealable path `d`. The glyph data IS the pen path, so
stroke-order reveal is exact by construction for any text, no per-word tuning.

Usage: gen-stroke-path.py <font.svg> <text> <target_width_px> <baseline_y> <x0>
Prints: the path `d` string + layout info on stderr.
"""
import re, sys

# Windows sizes stdio to the ANSI code page (cp1252). These scripts emit UTF-8 on
# every platform; say so rather than depending on the console's code page. Carry
# `errors` across: reconfigure() resets it to "strict", and CPython deliberately gives
# stderr "backslashreplace" so the diagnostic path can never itself raise.
for _stream in (sys.stdout, sys.stderr):
    if hasattr(_stream, "reconfigure"):
        _stream.reconfigure(encoding="utf-8", errors=_stream.errors)

font_path, text, target_w, baseline_y, x0 = (
    sys.argv[1], sys.argv[2], float(sys.argv[3]), float(sys.argv[4]), float(sys.argv[5]))

# SVG fonts are UTF-8 and each glyph's `unicode="…"` attribute IS a literal character.
# Decoded with the platform default, a non-ASCII glyph key silently becomes the wrong
# character (or raises) on Windows, so that glyph never matches the requested text.
svg = open(font_path, encoding="utf-8").read()
glyphs = {}
for m in re.finditer(r'<glyph\s+unicode="(.)"[^>]*?horiz-adv-x="([\d.]+)"(?:[^>]*?d="([^"]*)")?', svg):
    ch, adv, d = m.group(1), float(m.group(2)), m.group(3) or ""
    glyphs[ch] = (adv, d)
# default advance for space
space_adv = glyphs.get(" ", (300, ""))[0]

# total advance width in font units
total = 0.0
for ch in text:
    total += glyphs.get(ch, (space_adv, ""))[0]
s = target_w / total                       # scale font-units → px

out = []
cursor = 0.0
NUM = re.compile(r"[-\d.]+")
for ch in text:
    adv, d = glyphs.get(ch, (space_adv, ""))
    if d:
        # polylines only (M/L): transform every coordinate pair
        toks = re.findall(r"([ML])\s+([-\d.]+)\s+([-\d.]+)", d)
        for cmd, xs, ys in toks:
            x = x0 + (cursor + float(xs)) * s
            y = baseline_y - float(ys) * s          # SVG-font y is UP; flip
            out.append(f"{cmd} {x:.1f} {y:.1f}")
    cursor += adv

print(" ".join(out))
print(f"[gen] chars={len(text)} scale={s:.3f} width={total*s:.0f}px "
      f"subpaths={sum(1 for o in out if o.startswith('M'))}", file=sys.stderr)
scripts/inject-fonts.cjs
#!/usr/bin/env node
/*
 * inject-fonts.cjs — inline the @font-face blocks each HTML actually uses.
 *
 *   node inject-fonts.cjs <project-dir> [file.html ...]
 *
 * hyperframes' renderer only auto-supplies its ~18 CANONICAL_FONTS. Every other
 * family (Anton, Bangers, VT323, Press Start 2P, …) falls back to a generic font
 * unless the HTML ships a local @font-face — even when it "looks fine" locally,
 * because that only works when the font happens to be installed as a system font.
 * On a clean/offline/CI machine it silently degrades. (hyperframes' own font
 * linter flags exactly this: font_family_without_font_face.)
 *
 * This step reads the bundled font library (modes/standard/fonts/fonts.css —
 * base64 woff2, no sub-resources), finds which of those families the HTML uses,
 * and inlines just those @font-face blocks into a <style id="hf-embedded-fonts">.
 * Idempotent (re-running replaces the block). Families already declared in the
 * HTML, hyperframes-canonical families, and CSS generics are left untouched.
 *
 * Runs BEFORE the layout gates + render so measure-layout and the capture both
 * see the real glyph metrics, never a fallback.
 */
const fs = require("fs");
const path = require("path");

const SKILL_ROOT = path.resolve(__dirname, "..");
const FONTS_CSS = path.join(SKILL_ROOT, "modes", "standard", "fonts", "fonts.css");
const MARKER = "hf-embedded-fonts";

const GENERIC = new Set([
  "serif",
  "sans-serif",
  "monospace",
  "cursive",
  "fantasy",
  "system-ui",
  "ui-serif",
  "ui-sans-serif",
  "ui-monospace",
  "ui-rounded",
  "math",
  "emoji",
  "fangsong",
  "inherit",
  "initial",
  "unset",
  "revert",
  "revert-layer",
]);

// Parse fonts.css → Map<lowercased family, [ @font-face block strings ]>.
function loadFontLibrary() {
  let css;
  try {
    css = fs.readFileSync(FONTS_CSS, "utf8");
  } catch {
    console.error(
      `[inject-fonts] missing ${FONTS_CSS} — run modes/standard/fonts/build-fonts-css.cjs`,
    );
    process.exit(2);
  }
  const lib = new Map();
  // base64 data-URIs contain no "}", so [^}]* safely bounds a block.
  const blockRe = /@font-face\s*\{[^}]*\}/gi;
  const famRe = /font-family\s*:\s*(['"]?)([^;'"]+)\1/i;
  let m;
  while ((m = blockRe.exec(css)) !== null) {
    const fam = m[0].match(famRe);
    if (!fam) continue;
    const key = fam[2].trim().toLowerCase();
    if (!lib.has(key)) lib.set(key, []);
    lib.get(key).push(m[0]);
  }
  return lib;
}

// Families referenced anywhere in the HTML: CSS `font-family:` (in <style> or
// inline style="") and the data-font-family attribute hyperframes also honors.
function usedFamilies(html) {
  const out = new Set();
  const add = (stack) => {
    for (const part of String(stack).split(",")) {
      const name = part
        .trim()
        .replace(/^['"]|['"]$/g, "")
        .trim()
        .toLowerCase();
      if (name && !GENERIC.has(name) && !/^var\(/.test(name)) out.add(name);
    }
  };
  // strip existing @font-face so we don't re-list the family it declares
  const body = html.replace(/@font-face\s*\{[^}]*\}/gi, "");
  for (const mm of body.matchAll(/font-family\s*:\s*([^;}{]+)/gi)) add(mm[1]);
  for (const mm of html.matchAll(/data-font-family\s*=\s*["']([^"']+)["']/gi)) add(mm[1]);
  return out;
}

// Families the HTML ALREADY declares via @font-face — leave those alone.
function declaredFamilies(html) {
  const out = new Set();
  const famRe = /font-family\s*:\s*(['"]?)([^;'"]+)\1/i;
  for (const mm of html.matchAll(/@font-face\s*\{[^}]*\}/gi)) {
    const f = mm[0].match(famRe);
    if (f) out.add(f[2].trim().toLowerCase());
  }
  return out;
}

function injectInto(file, lib) {
  let html;
  try {
    html = fs.readFileSync(file, "utf8");
  } catch {
    return null;
  }
  // drop any previous injection so this is idempotent
  html = html.replace(new RegExp(`\\s*<style id="${MARKER}">[\\s\\S]*?</style>`, "i"), "");

  const used = usedFamilies(html);
  const already = declaredFamilies(html);
  const blocks = [];
  const inlined = [];
  for (const fam of used) {
    if (already.has(fam)) continue;
    const lb = lib.get(fam);
    if (!lb) continue; // canonical / system / unknown — not ours to supply
    blocks.push(...lb);
    inlined.push(fam);
  }
  if (!blocks.length) {
    fs.writeFileSync(file, html);
    return { file: path.basename(file), inlined: [] };
  }
  const style = `<style id="${MARKER}">\n/* auto-embedded by inject-fonts.cjs — deterministic template fonts */\n${blocks.join("\n")}\n</style>`;
  // Prefer right after <head...>; fall back to before </head>, then <html>, then prepend.
  if (/<head[^>]*>/i.test(html)) html = html.replace(/<head[^>]*>/i, (m0) => `${m0}\n${style}`);
  else if (/<\/head>/i.test(html)) html = html.replace(/<\/head>/i, `${style}\n</head>`);
  else if (/<html[^>]*>/i.test(html))
    html = html.replace(/<html[^>]*>/i, (m0) => `${m0}\n${style}`);
  else html = style + "\n" + html;

  fs.writeFileSync(file, html);
  return { file: path.basename(file), inlined };
}

function main() {
  const project = path.resolve(process.argv[2] || "");
  if (!process.argv[2]) {
    console.error("usage: inject-fonts.cjs <project-dir> [file.html ...]");
    process.exit(1);
  }
  const lib = loadFontLibrary();
  let files = process.argv.slice(3);
  if (!files.length) files = ["index.html", "rail.html", "index_fg.html"];
  files = files.map((f) => (path.isAbsolute(f) ? f : path.join(project, f)));

  let touched = 0;
  for (const f of files) {
    const r = injectInto(f, lib);
    if (r == null) continue;
    if (r.inlined.length) {
      console.log(`[inject-fonts] ${r.file}: embedded ${r.inlined.join(", ")}`);
      touched++;
    } else console.log(`[inject-fonts] ${r.file}: no non-canonical fonts to embed`);
  }
  if (!touched)
    console.log(`[inject-fonts] nothing embedded (all fonts canonical/system or already declared)`);
}
main();
scripts/lib-dna.cjs
/*
 * lib-dna.cjs — DNA registry loader + scene-token resolver.
 *
 * A DNA is a complete visual language (type, palette logic, motion grammar, hero
 * orchestration) that PARAMETERIZES per scene instead of shipping fixed look files.
 * The resolver folds in safe-zones.json's v2 scene measurements (palette / optics /
 * lighting) and envelope.json's loudness so the compiled tokens fit THIS clip:
 *
 *   accent      — scene-sampled when the DNA says "scene" (clamped readable)
 *   text shadow — contact shadow cast along the measured light direction
 *   depth match — embed text blur follows the scene's depth-of-field
 *   hero amp    — entrance amplitude coupled to the spoken word's loudness (RMS)
 *
 * Everything is read from files written by deterministic scripts — no randomness.
 */
const path = require("path");
const fs = require("fs");

const SKILL_ROOT = path.resolve(__dirname, "..");
const DNA_DIR = path.join(SKILL_ROOT, "dna");

// legacy template names → DNA (back-compat for existing plan.json files)
const LEGACY = { "cinematic-cream": "cream" };

function list() {
  try {
    return fs
      .readdirSync(DNA_DIR)
      .filter((f) => f.endsWith(".json"))
      .map((f) => f.replace(/\.json$/, ""))
      .sort();
  } catch {
    return [];
  }
}

function load(name) {
  const p = path.join(DNA_DIR, `${name}.json`);
  if (!fs.existsSync(p)) {
    throw new Error(`unknown DNA "${name}". Available: ${list().join(", ")}`);
  }
  return JSON.parse(fs.readFileSync(p, "utf8"));
}

function readJson(p) {
  try {
    return JSON.parse(fs.readFileSync(p, "utf8"));
  } catch {
    return null;
  }
}

// ── audio impact: where does this window's loudness rank within the clip? ──────
// envelope.json = { hop, rms: [linear 0..1 per hop] } (audio-envelope.cjs)
function heroImpact(project, t0, t1) {
  const env = readJson(path.join(project, "envelope.json"));
  if (!env || !Array.isArray(env.rms) || !env.rms.length) return 0.6; // neutral default
  const hop = env.hop || 0.05;
  const i0 = Math.max(0, Math.floor(t0 / hop)),
    i1 = Math.min(env.rms.length, Math.ceil(t1 / hop));
  if (i1 <= i0) return 0.6;
  const span = i1 - i0;
  const mean = (arr, a, b) => {
    let s = 0;
    for (let i = a; i < b; i++) s += arr[i];
    return s / (b - a);
  };
  const target = mean(env.rms, i0, i1);
  // percentile of this window vs all same-length windows across the clip
  let below = 0,
    total = 0;
  for (let s = 0; s + span <= env.rms.length; s += Math.max(1, Math.floor(span / 2))) {
    total++;
    if (mean(env.rms, s, s + span) <= target) below++;
  }
  return total ? below / total : 0.6;
}

// ── scene-aware token resolution ───────────────────────────────────────────────
function resolveTokens(dna, project, opts) {
  const sz = readJson(path.join(project, "safe-zones.json")) || {};
  const palette = sz.palette || {};
  const optics = sz.optics || {};
  const lighting = sz.lighting || {};

  // accent: "scene" → sampled suggestion, else literal.
  // accentMode "counter" (loud registers): the accent must FIGHT the scene's
  // temperature, not harmonize with it — a sampled warm sienna inside a tungsten room
  // camouflages (the warm-on-warm failure a blind review caught across three DNAs).
  // Counter-pole = rotate the sampled hue 180° and push saturation/lightness hot.
  let accent =
    dna.palette.accent === "scene"
      ? palette.accentSuggestion || dna.palette.accent_fallback || "#e3c06a"
      : dna.palette.accent;
  if (
    dna.palette.accentMode === "counter" &&
    dna.palette.accent === "scene" &&
    /^#([0-9a-f]{6})$/i.test(accent || "")
  ) {
    const n = parseInt(accent.slice(1), 16);
    let r = (n >> 16) / 255,
      g2 = ((n >> 8) & 255) / 255,
      b = (n & 255) / 255;
    const mx = Math.max(r, g2, b),
      mn = Math.min(r, g2, b),
      d = mx - mn;
    let h = 0;
    if (d > 0) {
      h = mx === r ? ((g2 - b) / d) % 6 : mx === g2 ? (b - r) / d + 2 : (r - g2) / d + 4;
      h *= 60;
      if (h < 0) h += 360;
    }
    h = (h + 180) % 360;
    const s = 0.85,
      v = 0.92; // hot, acid, readable
    const c = v * s,
      x = c * (1 - Math.abs(((h / 60) % 2) - 1)),
      m = v - c;
    const [r2, g3, b2] =
      h < 60
        ? [c, x, 0]
        : h < 120
          ? [x, c, 0]
          : h < 180
            ? [0, c, x]
            : h < 240
              ? [0, x, c]
              : h < 300
                ? [x, 0, c]
                : [c, 0, x];
    const f = (q) =>
      Math.round((q + m) * 255)
        .toString(16)
        .padStart(2, "0");
    accent = `#${f(r2)}${f(g3)}${f(b2)}`;
  }

  const dark = dna.palette.scheme === "dark-on-light";

  // contact shadow along the measured light direction + a soft ambient.
  // light-on-dark also gets the warm bloom layer when the DNA asks for it.
  let shadowParts = [];
  if (dna.optics && dna.optics.contactShadow) {
    const sd = lighting.shadow || { dx: 0, dy: 3 };
    shadowParts.push(`${sd.dx}px ${sd.dy + 1}px 10px rgba(0,0,0,${dark ? 0.22 : 0.32})`);
  }
  shadowParts.push(dark ? "0 1px 14px rgba(0,0,0,0.10)" : "0 2px 22px rgba(0,0,0,0.28)");
  if (dna.optics && dna.optics.warmBloom) shadowParts.push("0 0 26px rgba(255,214,160,0.18)");
  const textShadow = shadowParts.join(", ");

  // depth match: embed (bg) text picks up the scene's bokeh; fg/rail stay sharp
  const blurPx = (dna.optics && dna.optics.depthMatch && optics.suggestedTextBlurPx) || 0;
  const baseFilter = dna.filter && dna.filter !== "none" ? dna.filter : "";
  const filterBg = `${baseFilter}${blurPx ? ` blur(${blurPx}px)` : ""}`.trim() || "none";
  const filterFg = baseFilter || "none";

  // hero orchestration + loudness coupling — one config PER hero (scarcity is per
  // beat/block, not per clip; each hero's amplitude follows ITS spoken loudness)
  const heroGroups = (opts && (opts.heroGroups || (opts.heroGroup ? [opts.heroGroup] : []))) || [];
  const heroes = heroGroups.map((hg) => {
    const impact = heroImpact(project, hg.in, Math.min(hg.in + 0.7, hg.out));
    const amp = +((0.75 + impact * 0.5) * (hg.minor ? 0.7 : 1)).toFixed(3); // 0.75…1.25, minors damped
    return {
      // spread the DNA's full hero block (entrance/perLetter/glow/breathe/ripple/loom/
      // letterBlur/sheen/echoes/… — new fx fields flow through with no plumbing),
      // then the per-hero computed values
      ...dna.hero,
      entrance: dna.hero.entrance || "emergence",
      dimOthers: dna.hero.dimOthers != null ? dna.hero.dimOthers : 0.45,
      id: hg.id,
      minor: hg.minor === true,
      in: hg.in,
      out: hg.out,
      amp,
      heroColor: dna.palette.heroColor === "accent" ? accent : null,
    };
  });
  const hero = heroes[0] || null; // back-compat single accessor

  return {
    fontFamily: dna.font.family,
    capColor: dna.palette.cap_color,
    blend: dna.palette.blend,
    accent,
    textShadow,
    filterBg,
    filterFg,
    blurPx,
    motion: dna.motion,
    hero,
    heroes,
    heroCss: (dna.hero && dna.hero.css) || "",
    heroCase: (dna.hero && dna.hero.case) || "none",
    heroTracking: (dna.hero && dna.hero.tracking) || "0",
    dnaCss: dna.css || "",
    register: dna.register,
    name: dna.name,
  };
}

module.exports = { LEGACY, list, load, resolveTokens, heroImpact };
scripts/make-cinematic.cjs
#!/usr/bin/env node
/*
 * make-cinematic.cjs — compile cinematic.json → plan.json (then make-composition.cjs).
 * CINEMATIC MODE, raised to BLOCK level. The agent authors thought-BLOCKS — lines of
 * words, which plane each block stacks in, at most one promoted HERO word — and the
 * compiler lowers that into the classic plan.json with everything deterministic
 * generated, so the Cinematic failure modes seen in the wild become impossible:
 *   - READING ORDER by construction: lines stack in declaration order inside a flex
 *     plane (spoken order = visual order; a hero can never sit below later words)
 *   - timings from the transcript BY SEQUENCE (no hand-copied times; duplicates by position)
 *   - accumulate-within-block / page-flip-between-blocks rhythm generated:
 *     lines of a block share the block's out (they accumulate, then clear together
 *     when the next block's first word lands); the NEXT block enters fresh
 *   - hero hand-off: the promoted word is lifted OUT of its line, enters as the hero
 *     (its own plane, BIG) when spoken, holds to the END of its block, exits cleanly
 *   - fg fallback: safe-zones verdict "fg" → caption_layer:"fg" automatically
 *
 *   node make-cinematic.cjs <project-dir>
 *
 * cinematic.json schema:
 * {
 *   "template": "cinematic-cream",
 *   "width": 1920, "height": 1080, "fps": 30,
 *   "planes": {                       // layout REGIONS — place them using safe-zones.json
 *     "narr": "top: 14%; left: 4%; width: 26%;",            // string or {css}
 *     "hero": null                    // null/absent → auto from safe-zones heroAnchor
 *   },
 *   "blocks": [
 *     { "plane": "narr", "lines": [
 *         { "words": ["You","need","to"],        "css": "font-size: calc(0.05*var(--h)); font-weight:600;" },
 *         { "words": ["judge","us","by","the"],  "css": "font-size: calc(0.062*var(--h)); font-weight:700;" },
 *         { "words": ["actions"],                "hero": true,
 *           "text": "ACTIONS", "css": "font-size: calc(0.24*var(--h)); font-weight:900; text-transform:uppercase;" },
 *         { "words": ["that","we","take."],      "css": "font-size: calc(0.05*var(--h));" },
 *         // per-line escape hatch: "layer":"fg" lifts a line ABOVE the subject matte
 *         // (use when the occlusion gate reports a context line eaten by the subject)
 *         { "words": ["over","the","subject"],     "layer": "fg" }
 *       ] },
 *     { "plane": "narr", "lines": [ ... next thought ... ] }
 *   ],
 *   "tones": { "default": "soft", "hero": "present" }       // optional
 * }
 * Rules: words must match the transcript verbatim in spoken order across all blocks.
 * Mark at most ONE line "hero": true per clip (scarcity); its words form the hero
 * (usually a single word; "text" overrides the display form).
 */
const path = require("path");
const fs = require("fs");

const norm = (s) =>
  String(s == null ? "" : s)
    .toLowerCase()
    .replace(/[^a-z0-9']/g, "");
const LOOKAHEAD = 40;

// Authored css WITHOUT a font-size renders at browser-default ~16px (an
// invisible ribbon) and every gate still passes — observed in the cold-start
// E2E. Guarantee the default is present whenever the author omitted it.
function ensureFontSize(css, defaultDecl) {
  if (!css) return defaultDecl;
  return /font-size\s*:/.test(css) ? css : defaultDecl + " " + css;
}

function die(m) {
  console.error(`[make-cinematic] ${m}`);
  process.exit(1);
}

function main() {
  const project = path.resolve(process.argv[2] || "");
  if (!process.argv[2]) die("usage: make-cinematic.cjs <project-dir>");
  const cj = path.join(project, "cinematic.json");
  const tj = path.join(project, "transcript.json");
  if (!fs.existsSync(cj)) die(`missing ${cj} — author it first (schema in this header)`);
  if (!fs.existsSync(tj)) die(`missing ${tj} — run prepare.sh first`);
  const C = JSON.parse(fs.readFileSync(cj, "utf8"));
  const tr = (JSON.parse(fs.readFileSync(tj, "utf8")).words || []).filter(
    (w) => w && "start" in w && "end" in w,
  );
  if (!tr.length) die("transcript has no word timings");

  // safe-zones: fg verdict + heroAnchor default
  let sz = null;
  try {
    sz = JSON.parse(fs.readFileSync(path.join(project, "safe-zones.json"), "utf8"));
  } catch {}
  // author override first (borderline scenes: coverage near the line, agitated subject),
  // else the safe-zones verdict.
  // narration legibility follows the verdict; the HERO is judged separately — it WANTS
  // occlusion (~30–55% is the product). fg for the hero is the LAST resort: only when no
  // height band achieves ≤62% predicted occlusion (safe-zones heroBands). An explicit
  // author caption_layer:"fg" still forces everything front.
  const authorFg = C.caption_layer === "fg";
  const narrFg = authorFg || (sz && sz.recommendation === "fg");
  const hb = sz && sz.heroBands;
  const heroFeasible = authorFg ? false : hb ? hb.feasible : !narrFg;
  const globalFg = narrFg; // narration layer (name kept for the lowering below)

  const W = C.width || 1920,
    H = C.height || 1080,
    FPS = C.fps || 24;
  const blocks = C.blocks || [];
  if (!blocks.length) die("blocks is empty");

  // ── sequence-match every word across blocks/lines ──────────────────────────
  let p = 0;
  const missing = [];
  for (const b of blocks)
    for (const ln of b.lines || []) {
      ln._w = (ln.words || []).map((text) => {
        const t = norm(text);
        let f = -1;
        for (let j = p; j < Math.min(tr.length, p + LOOKAHEAD); j++)
          if (norm(tr[j].text) === t) {
            f = j;
            break;
          }
        if (f < 0) {
          missing.push(text);
          return { text, start: null, end: null };
        }
        p = f + 1;
        return { text, start: tr[f].start, end: tr[f].end, ti: f };
      });
    }
  if (missing.length)
    die(
      `words not in transcript (in order): ${missing.join(" ")} — lines must match the transcript verbatim`,
    );

  // COMPLETENESS GATE — verbatim is a hard rule, so enforce it at compile time: the
  // sequence matcher's lookahead can silently SKIP transcript words the author forgot
  // (a real case shipped missing "in our newly released model."). Every transcript word
  // must be consumed; the only legitimate drops are filler (um/uh/stutters), declared in
  // cinematic.json "drops": ["um", ...].
  {
    const consumed = new Set();
    for (const b of blocks)
      for (const ln of b.lines || []) for (const w of ln._w) if (w.ti != null) consumed.add(w.ti);
    const dropOk = new Set((C.drops || []).map(norm));
    const FILLER = new Set(["um", "uh", "er", "ah", "hmm", "mm"]);
    const skipped = tr
      .map((w, i) => ({ w, i }))
      .filter(
        ({ w, i }) => !consumed.has(i) && !dropOk.has(norm(w.text)) && !FILLER.has(norm(w.text)),
      );
    if (skipped.length)
      die(
        `VERBATIM VIOLATION — ${skipped.length} transcript word(s) never authored (the matcher skipped them): ` +
          `${skipped.map(({ w }) => `"${w.text}"`).join(" ")} — add them to a line, or declare true filler in "drops": []`,
      );
  }

  // text override + multiple words double-renders (override replaces word 1, words
  // 2..n still draw) — caught cold by a clean-room agent; illegal for ANY hero line.
  for (const b of blocks)
    for (const ln of b.lines || []) {
      if (ln.hero && ln.text && (ln.words || []).length > 1)
        die(
          `hero line ["${(ln.words || []).join('" "')}"] has BOTH multiple words and a "text" override — drop "text" (words render verbatim; DNA case applies) or promote a single word.`,
        );
    }

  // ── block windows: in = first word −lead; out = page-flip = next block's first word −0.28 (or +1.2 hold) ──
  const LEAD = 0.18;
  const bw = blocks.map((b) => {
    const ws = (b.lines || []).flatMap((l) => l._w);
    return { first: Math.min(...ws.map((w) => w.start)), last: Math.max(...ws.map((w) => w.end)) };
  });
  let srcDur = null;
  try {
    const cp = require("child_process");
    for (const f of ["source.mp4"].concat(
      fs.readdirSync(project).filter((x) => /\.(mp4|mov|webm)$/i.test(x)),
    )) {
      const fp = path.join(project, f);
      if (!fs.existsSync(fp)) continue;
      const d = parseFloat(
        cp.execFileSync(
          "ffprobe",
          [
            "-v",
            "error",
            "-show_entries",
            "format=duration",
            "-of",
            "default=nokey=1:noprint_wrappers=1",
            "--",
            fp,
          ],
          { encoding: "utf8" },
        ),
      );
      if (d > 0) {
        srcDur = d;
        break;
      }
    }
  } catch {}
  const DUR = +(srcDur || bw[bw.length - 1].last + 0.6).toFixed(3);
  // ── SLOT layout + paging. Lines get FIXED absolute positions (no flex reflow — the
  // "lower line jumps up when the upper one clears" bug is impossible by construction).
  // Lines accumulate down the plane like a poem; the plane PAGE-FLIPS (all visible lines
  // clear together, the next line restarts at the top) only when (a) the plane is full,
  // (b) a block sets "flip": true, or (c) a block boundary coincides with a real speech
  // pause (≥0.6s). Block windows (bw) are kept only for the hero's hold.
  for (let i = 0; i < bw.length; i++) {
    bw[i].in = Math.max(0, bw[i].first - LEAD);
    bw[i].out = Math.min(DUR - 0.05, Math.min(bw[i].last, DUR - 0.05) + 0.9);
    // a block's hold (incl. a LOCKUP's) yields to the NEXT block's entrance — the same
    // page-flip semantics columns already have; floor: its own words must finish
    if (i + 1 < bw.length)
      bw[i].out = Math.max(
        Math.min(bw[i].last, DUR - 0.05) + 0.15,
        Math.min(bw[i].out, bw[i + 1].first - 0.28),
      );
  }
  const fracOfLine = (css) => {
    const m = (css || "").match(/font-size\s*:\s*calc\(\s*([\d.]+)\s*\*\s*var\(--h\)/);
    return m ? +m[1] : 0.05;
  };
  const planeBudgetPx = (pk) => {
    const css =
      (C.planes &&
        C.planes[pk] &&
        (typeof C.planes[pk] === "string" ? C.planes[pk] : C.planes[pk].css)) ||
      "";
    const m = css.match(/height\s*:\s*([\d.]+)%/);
    return (m ? +m[1] / 100 : 0.3) * H;
  };

  // ── planes: agent's + auto LOCKUP planes (one per hero block) ───────────────
  // The LOCKUP is the layout answer to "the sentence reads broken around the hero":
  // a hero block's lines do NOT scatter into the narration column — kicker, HERO and
  // tail compose as ONE bonded ORBIT (context anchored at the hero's edges, diagonal
  // read). DEPTH: context EMBEDS (bg) with the hero — one shared depth; per-line
  // `layer:"fg"` is the escape hatch when the matte eats a context line (gate tells you).
  const planes = {};
  for (const [k, v] of Object.entries(C.planes || {})) {
    if (v == null) continue;
    let css = typeof v === "string" ? v : v.css || "";
    if (!/text-align\s*:/.test(css)) css += " text-align:center;"; // slot children are absolute; only alignment carries
    planes[k] = { css };
  }
  // multiple heroes allowed — scarcity is per BLOCK (≤1 per thought), not per clip.
  // The LARGEST authored hero is the APEX: it gets the full lockup embed (centered on
  // the subject, matte showcase). Smaller heroes are MINOR peaks: they stay IN their
  // column's flow as oversized emphasis lines (fg, hero motion at reduced amplitude) —
  // not every beat needs the matte showcase; that's what keeps the apex an event.
  const allHeroBlocks = [];
  blocks.forEach((b, bi) => {
    const hs = (b.lines || []).filter((l) => l.hero === true);
    if (hs.length > 1)
      die(
        `block ${bi} has ${hs.length} hero lines — at most ONE hero per block (scarcity = per beat)`,
      );
    if (hs.length === 1)
      allHeroBlocks.push({ b, bi, ln: hs[0], frac: fracOfLine(hs[0].css) || 0.24 });
  });
  const apexFracAll = Math.max(...allHeroBlocks.map((h) => h.frac), 0);
  // placement: "column" keeps even an APEX in its column's flow (the hero bursts the
  // column instead of crossing the subject) — the sanctioned alternative to the
  // subject-anchored lockup. Author it per hero line: { "hero": true, "placement": "column" }.
  const heroBlocks = allHeroBlocks.filter(
    (h) => h.frac >= apexFracAll - 1e-6 && (h.ln.placement || "subject") !== "column",
  );
  const minorHeroBis = new Set(
    allHeroBlocks
      .filter((h) => h.frac < apexFracAll - 1e-6 || (h.ln.placement || "subject") === "column")
      .map((h) => h.bi),
  );
  const columnApexBis = new Set(
    allHeroBlocks
      .filter((h) => h.frac >= apexFracAll - 1e-6 && (h.ln.placement || "subject") === "column")
      .map((h) => h.bi),
  );
  if (minorHeroBis.size)
    console.log(
      `[make-cinematic] ${minorHeroBis.size} minor hero(es) stay in the column flow (apex gets the lockup embed)`,
    );
  const lockupName = (bi) => `lockup-b${bi}`;
  const lockupTemplate =
    C.planes && C.planes.lockup
      ? typeof C.planes.lockup === "string"
        ? C.planes.lockup
        : C.planes.lockup.css
      : null;
  delete planes.lockup; // template only — not a real plane
  for (const hRef of heroBlocks) {
    const a = sz && sz.heroAnchor && sz.heroAnchor.plane;
    // band pick: target ~40% occlusion AND a band the DNA's text can survive on
    // (skip bright bands when luma data exists) — heroAnchor.yPct is the fallback
    let heroTop = a ? a.yPct : 30;
    if (hb && hb.profile && hb.profile.length) {
      const ok = hb.profile.filter(
        (b2) => b2.occPct >= 12 && b2.occPct <= 62 && (b2.bgLuma == null || b2.bgLuma <= 165),
      );
      const pool = ok.length ? ok : hb.profile;
      const best = pool.reduce((x, y) =>
        Math.abs(y.occPct - 40) < Math.abs(x.occPct - 40) ? y : x,
      );
      heroTop = +(best.topPct + 6.5).toFixed(1);
    }
    const heroFrac = (hRef.ln.css || "").match(/calc\(\s*([\d.]+)\s*\*\s*var\(--h\)/);
    // the lockup is taller than the hero alone (context above+below) — anchor so the
    // HERO band (not the plane top) lands on the target, then clamp so the WHOLE
    // lockup (pre + hero + post) stays on frame
    const heroIdx2 = (hRef.b.lines || []).indexOf(hRef.ln);
    const preH = (hRef.b.lines || [])
      .filter((l, i) => l !== hRef.ln && i < heroIdx2)
      .reduce(
        (s, l) =>
          s + (fracOfLine(l.css) * 100 * 1.32 + Math.max(1.6, fracOfLine(l.css) * 100 * 0.38)),
        0,
      );
    const postH = (hRef.b.lines || [])
      .filter((l, i) => l !== hRef.ln && i > heroIdx2)
      .reduce(
        (s, l) =>
          s + (fracOfLine(l.css) * 100 * 1.32 + Math.max(1.6, fracOfLine(l.css) * 100 * 0.38)),
        0,
      );
    const heroHpct = (heroFrac ? +heroFrac[1] : 0.24) * 100 * 1.12;
    const totalH = preH + heroHpct + postH;
    let top = +Math.max(2, Math.min(96 - totalH, heroTop - heroHpct / 2 - preH)).toFixed(1);
    const css = lockupTemplate
      ? lockupTemplate.replace(/top:\s*[\d.]+%/, `top: ${top}%`)
      : a
        ? `top: ${top}%; left: ${a.xPct}%; width: ${a.wPct}%; text-align: center;`
        : `top: ${top}%; left: 6%; width: 88%; text-align: center;`;
    planes[lockupName(hRef.bi)] = { css };
    hRef.plane = lockupName(hRef.bi);
  }
  // legacy alias: an author-defined "hero" plane positions the FIRST lockup
  if (planes.hero) {
    if (heroBlocks.length) {
      planes[lockupName(heroBlocks[0].bi)] = planes.hero;
    }
    delete planes.hero;
  }

  // ── PER-PLANE LEGIBILITY COMPENSATION ────────────────────────────────────
  // The DNA fixes hue + blend; the SCENE decides whether that survives. Match each
  // plane to its best-overlapping measured zone and ladder the treatment:
  //   luma > 150 → opaque (blend:normal) + strong tight scrim — screen/blend washes out
  //   luma 95–150 → keep the DNA blend, strengthen the glyph scrim
  const planeComp = {};
  if (sz && sz.zones) {
    const zlist = Object.entries(sz.zones).filter(([, z]) => z && z.meanLuma != null);
    for (const [pk, pv] of Object.entries(C.planes || {})) {
      const css0 = typeof pv === "string" ? pv : (pv || {}).css || "";
      const lm = css0.match(/left:\s*([\d.]+)%/),
        wm = css0.match(/width:\s*([\d.]+)%/);
      if (!lm || !wm) continue;
      const a0 = +lm[1],
        a1 = a0 + +wm[1];
      let best = null,
        bestOv = 0;
      for (const [, z] of zlist) {
        const ov = Math.min(a1, z.xPct + z.wPct) - Math.max(a0, z.xPct);
        if (ov > bestOv) {
          bestOv = ov;
          best = z;
        }
      }
      if (!best) continue;
      // gate on the time PEAK too — a dark wall swept by a bright moving object
      // (handheld drift, screens) averages "clean" but peaks hot, and text there
      // washes out exactly when the bright thing passes (real cold-start case)
      const pkL = best.peakLuma != null ? best.peakLuma : best.meanLuma;
      if (best.meanLuma > 150 || pkL > 175)
        planeComp[pk] =
          " mix-blend-mode: normal; text-shadow: 0 1px 6px rgba(0,0,0,.65), 0 2px 16px rgba(0,0,0,.4);";
      else if (best.meanLuma > 95 || pkL > 135)
        planeComp[pk] = " text-shadow: 0 1px 5px rgba(0,0,0,.6), 0 2px 14px rgba(0,0,0,.35);";
      if (planeComp[pk])
        console.log(
          `[make-cinematic] plane "${pk}" zone luma ${best.meanLuma} (peak ${pkL}) → legibility compensation (${best.meanLuma > 150 || pkL > 175 ? "opaque+scrim" : "scrim"})`,
        );
    }
  }
  // lockup context shares the hero band's luma
  const bandLuma = sz && sz.heroAnchor && sz.heroAnchor.bandLuma;
  const lockupComp =
    bandLuma != null && bandLuma > 150
      ? " mix-blend-mode: normal; text-shadow: 0 1px 6px rgba(0,0,0,.65), 0 2px 16px rgba(0,0,0,.4);"
      : bandLuma != null && bandLuma > 95
        ? " text-shadow: 0 1px 5px rgba(0,0,0,.6), 0 2px 14px rgba(0,0,0,.35);"
        : "";

  // ── lower to classic plan.json groups (slot layout + per-plane paging) ─────
  const tones = C.tones || {};
  let dnaBodyLayer = null;
  try {
    const dnaLib0 = require("./lib-dna.cjs");
    const dn0 = C.dna || (C.template ? dnaLib0.LEGACY[C.template] : "cream");
    if (dn0) {
      const d0 = dnaLib0.load(dn0);
      dnaBodyLayer = d0.bodyLayer || null;
      // category lock (symmetric): column is every classic DNA's home today,
      // so this only fires if a future DNA declares a different home.
      if (d0.deliveries && d0.deliveries.home && d0.deliveries.home !== "column")
        console.warn(
          `[make-cinematic] WARN: DNA "${dn0}" declares home="${d0.deliveries.home}" — cinematic use is cross-category and unvalidated.`,
        );
    }
  } catch (e) {
    if (e.message && e.message.includes("does not support")) throw e;
  }
  const groups = [];
  const heroRefs = heroBlocks; // [{b, bi, ln, plane}]
  const isHeroBlock = new Set(heroBlocks.map((h) => h.bi));
  const flat = [];
  blocks.forEach((b, bi) => {
    const heroIdx = isHeroBlock.has(bi) ? (b.lines || []).findIndex((l) => l.hero === true) : -1;
    (b.lines || []).forEach((ln, li) => {
      if (ln.hero === true && isHeroBlock.has(bi)) return; // APEX heroes slot into their lockup below
      flat.push({
        b,
        bi,
        ln,
        plane: heroIdx >= 0 ? lockupName(bi) : b.plane,
        lockupPos: heroIdx >= 0 ? (li < heroIdx ? "pre" : "post") : null,
        isMinorHero: ln.hero === true && minorHeroBis.has(bi), // minor peak rides its column
        first: Math.min(...ln._w.map((w) => w.start)),
        lastEnd: Math.min(Math.max(...ln._w.map((w) => w.end)), DUR - 0.05),
        frac: fracOfLine(ln.css),
      });
    });
  });
  // size sanity: narration below 0.05·h is whisper-tier (36px at 720p) — warn loudly
  {
    const tiny = flat.filter((l) => !l.isMinorHero && l.frac > 0 && l.frac < 0.05);
    if (tiny.length)
      console.log(
        `[make-cinematic] ⚠ ${tiny.length} narration line(s) authored below 0.05·h (whisper tier) — body narration wants 0.055–0.07·h: ${tiny
          .slice(0, 3)
          .map((l) => `"${l.ln.words.slice(0, 3).join(" ")}…"`)
          .join(" ")}`,
      );
  }
  // RATIO LOCK — poster lockup typography: context is sized FROM the hero, the way a
  // title system locks its eyebrow/tagline (kicker & tail ≈ 0.26× the hero height,
  // clamped to [0.05, 0.085]·h). Hero dominance is structural (~4× height + weight
  // contrast), so the old mass-rule downscale is gone — whisper-small context was the
  // disease, not the cure. Too MUCH context is a line-count problem, never a size one.
  for (const hRef of heroRefs) {
    const ctx = flat.filter((l) => l.bi === hRef.bi);
    if (!ctx.length) continue;
    const heroFracV = fracOfLine(hRef.ln.css || "") || 0.24;
    const ratioFrac = +Math.min(0.085, Math.max(0.05, heroFracV * 0.26)).toFixed(4);
    if (ctx.length > 3)
      console.log(
        `[make-cinematic] ⚠ lockup-b${hRef.bi} has ${ctx.length} context lines — a lockup wants kicker + hero + tail; move narration to the column (a leading clause can be its own block)`,
      );
    const moved = ctx.filter((l) => Math.abs(l.frac - ratioFrac) > 0.002).length;
    ctx.forEach((l) => {
      l.ln.css = (l.ln.css || "").replace(
        /font-size\s*:\s*[^;]+/,
        `font-size: calc(${ratioFrac} * var(--h))`,
      );
      l.frac = ratioFrac;
    });
    if (moved)
      console.log(
        `[make-cinematic] lockup-b${hRef.bi}: context RATIO-LOCKED to 0.26×hero → ${ratioFrac}·h (${moved} line(s) re-sized)`,
      );
  }
  const byPlane = {};
  flat.forEach((l) => {
    (byPlane[l.plane] = byPlane[l.plane] || []).push(l);
  });
  // make sure a lockup whose block has NO context lines still exists in the loop
  for (const hRef of heroRefs) if (!byPlane[hRef.plane]) byPlane[hRef.plane] = [];
  for (const [pk, ls] of Object.entries(byPlane)) {
    const isLockup = /^lockup-b/.test(pk);
    const budget = isLockup ? H : planeBudgetPx(pk); // a lockup is ONE composition — never paginate inside it
    const hRef = isLockup ? heroRefs.find((h) => h.plane === pk) : null;
    const heroH = hRef ? (fracOfLine(hRef.ln.css || "") || 0.24) * H * 1.12 + 12 : 0;
    // ORBIT estimate: where do the hero's left/right edges land inside the (frame-wide,
    // centered) lockup plane — kicker hangs off the left edge, tail off the right
    let orbit = null;
    if (hRef) {
      const disp = String(hRef.ln.text || (hRef.ln.words || []).join(" "));
      const fpx = (fracOfLine(hRef.ln.css || "") || 0.24) * H;
      const estW = Math.min(
        W * 0.94,
        Math.max(fpx * 1.2, disp.replace(/\s/g, "").length * fpx * 0.6),
      );
      const planeCss0 = (planes[pk] && planes[pk].css) || "";
      const _pl = ((planeCss0.match(/left:\s*([\d.]+)%/) || [0, 0])[1] * W) / 100;
      const pw = ((planeCss0.match(/width:\s*([\d.]+)%/) || [0, 100])[1] * W) / 100;
      const heroLeft = Math.max(0, (pw - estW) / 2);
      // side-aware: anchor context to the CLEANER side of THIS hero's window — the
      // kicker/tail must land on scene, not on a large/moving subject
      let side = (sz && sz.subject && sz.subject.clearerSide) || "left";
      try {
        const hin = Math.min(...hRef.ln._w.map((w) => w.start));
        const win = (sz.windows || []).find((w2) => hin >= w2.in - 0.3 && hin <= w2.out + 0.3);
        if (win && win.clearerSide) side = win.clearerSide;
      } catch {}
      orbit = {
        left: Math.round(heroLeft),
        right: Math.round(Math.max(0, pw - heroLeft - estW)),
        maxW: Math.round(Math.min(estW * 0.75, pw * 0.55)),
        side,
      };
    }
    const pages = [];
    let page = [],
      slot = 0;
    for (const l of ls) {
      // the HERO occupies its slot between pre- and post-context, in spoken order
      if (hRef && hRef.slotPx == null && l.lockupPos === "post") {
        hRef.slotPx = Math.round(slot);
        slot += heroH;
      }
      const lineH = l.frac * H * 1.32 + Math.max(12, Math.round(l.frac * H * 0.38)); // ink overflows the 1.0 line-box (+shadows) — breathe in proportion to the type
      const prev = page[page.length - 1];
      const pauseGap = prev ? l.first - prev.lastEnd : 0;
      const boundary = prev && l.bi !== prev.bi;
      // a block carrying a COLUMN-PLACED apex is one composition — never split it on
      // budget (the oversized hero legitimately exceeds the column's page height)
      const unbreakable = columnApexBis.has(l.bi) && prev && prev.bi === l.bi;
      if (
        page.length &&
        !unbreakable &&
        (slot + lineH > budget || (boundary && l.b.flip === true) || (boundary && pauseGap >= 0.6))
      ) {
        pages.push(page);
        page = [];
        slot = 0;
      }
      l.slotPx = Math.round(slot);
      if (isLockup) l._orbit = orbit;
      slot += lineH;
      page.push(l);
    }
    if (hRef && hRef.slotPx == null) {
      hRef.slotPx = Math.round(slot);
    } // hero is the block's last line
    if (page.length) pages.push(page);
    pages.forEach((pg, pi) => {
      const nextFirst = pi + 1 < pages.length ? pages[pi + 1][0].first : null;
      const maxEnd = Math.max(...pg.map((l) => l.lastEnd));
      let out =
        nextFirst != null
          ? Math.max(maxEnd + 0.01, Math.min(DUR - 0.05, nextFirst - 0.28))
          : Math.min(DUR - 0.05, maxEnd + 1.2);
      // THE LOCKUP OWNS THE FRAME: a sidebar page must not linger into an apex
      // lockup's window (its kicker/tail can land in the same zone → text-on-text)
      if (!isLockup)
        for (const hRef2 of heroRefs) {
          const lkIn = bw[hRef2.bi].first - 0.18;
          if (lkIn > maxEnd - 0.2 && lkIn < out)
            out = Math.max(maxEnd + 0.01, Math.min(out, lkIn - 0.28));
        }
      pg.forEach((l, li) => {
        l.pageIdx = pi;
        if (!isLockup)
          for (const hRef2 of heroRefs) {
            const lkOut = bw[hRef2.bi].out;
            const lkIn2 = bw[hRef2.bi].first - 0.18;
            const enter0 = Math.max(0, l.first - LEAD);
            if (enter0 > lkIn2 && enter0 < lkOut)
              l._delayIn = +Math.min(l.first, Math.max(enter0, lkOut - 0.2)).toFixed(3);
          }
        // a LOCKUP fades as ONE unit: context lines exit with the hero (block window),
        // never linger on page timing after the hero has left
        l.out = l.lockupPos ? +Math.min(out, bw[l.bi].out).toFixed(3) : +out.toFixed(3);
        l.in = l._delayIn != null ? l._delayIn : +Math.max(0, l.first - LEAD).toFixed(3);
        // first line of a NEW page: don't enter before the old page has begun clearing
        if (li === 0 && pi > 0)
          l.in = +Math.min(l.first, Math.max(l.first - LEAD, pages[pi - 1][0].out - 0.02)).toFixed(
            3,
          );
      });
    });
  }
  let gid = 0;
  for (const l of flat) {
    l.gid = l.isMinorHero ? `h-${l.bi}` : `b${l.bi}-l${gid++}`;
    // depth model: lockup CONTEXT embeds (bg) WITH the hero — the orbit anchors it
    // beside the head/shoulder so it reads on the scene, one shared depth; narration
    // columns embed too unless the DNA declares bodyLayer:"fg" (loud's announce-style).
    // Minor heroes ride their column in FRONT. Per-line layer overrides any default.
    // ORBIT depth: lockup context lands BESIDE the subject (kicker/tail at the hero's
    // edges) — so it EMBEDS (bg) like the hero; the whole composition shares one depth.
    // fg remains for: minor heroes (column emphasis), a DNA's bodyLayer (loud announces),
    // scene verdict FG (globalFg), and explicit per-line "layer" overrides.
    const defLayer = l.isMinorHero ? "fg" : dnaBodyLayer || "bg";
    groups.push({
      id: l.gid,
      plane: l.plane,
      layer: globalFg ? "fg" : l.ln.layer || l.b.layer || defLayer,
      ...(l.isMinorHero ? { hero: true, ...(columnApexBis.has(l.bi) ? {} : { minor: true }) } : {}),
      tone: l.isMinorHero ? tones.hero || "present" : l.ln.tone || tones.default || "soft",
      allow_overlap: true,
      // a MINOR hero holds only to the end of ITS thought (+a breath) — lingering with
      // the page through a long pause leaves it co-visible with the next apex
      in: l.in,
      out: l.isMinorHero ? +Math.min(l.out, bw[l.bi].out + 0.3).toFixed(3) : l.out,
      // carry the PLANE's text-align onto each slotted line (absolute children don't
      // reliably inherit through the template's .cap centering — authored "right edge
      // hugs the silhouette" was silently centered)
      css:
        (l.lockupPos && l._orbit
          ? (l.ln.anchor || (l.lockupPos === "pre" ? l._orbit.side : l._orbit.side)) === "left"
            ? `position:absolute;top:${l.slotPx}px;left:${l._orbit.left}px;right:auto;max-width:${l._orbit.maxW}px;text-align:left; `
            : `position:absolute;top:${l.slotPx}px;right:${l._orbit.right}px;left:auto;max-width:${l._orbit.maxW}px;text-align:right; `
          : `position:absolute;left:0;right:0;top:${l.slotPx}px; ` +
            (() => {
              const pc =
                (C.planes &&
                  C.planes[l.plane] &&
                  (typeof C.planes[l.plane] === "string"
                    ? C.planes[l.plane]
                    : C.planes[l.plane].css)) ||
                "";
              const ta = pc.match(/text-align\s*:\s*(left|right|center)/);
              return ta ? `text-align:${ta[1]}; ` : "";
            })()) +
        ensureFontSize(l.ln.css, "font-size: calc(0.05 * var(--h)); font-weight: 600;") +
        (l.lockupPos ? lockupComp : planeComp[l.plane] || ""),
      words: l.ln._w.map((w, wi2) => ({
        // minor/column heroes honor the display-form override too (single-word lines)
        text: l.isMinorHero && l.ln.text && wi2 === 0 && l.ln._w.length === 1 ? l.ln.text : w.text,
        start: w.start,
        end: Math.min(w.end, DUR - 0.05),
        ti: w.ti,
      })),
    });
  }
  for (const hRef of heroRefs) {
    const { bi, ln } = hRef;
    const w0 = ln._w[0];
    groups.push({
      id: `h-${bi}`,
      hero: true,
      plane: hRef.plane,
      layer: ln.layer || (heroFeasible ? "bg" : "fg"),
      tone: tones.hero || "present",
      allow_overlap: true,
      in: +Math.max(0, w0.start - 0.02).toFixed(3),
      out: +bw[bi].out.toFixed(3),
      css:
        `position:absolute;left:0;right:0;top:${hRef.slotPx || 0}px; text-align:center; ` +
        ensureFontSize(
          ln.css,
          "font-size: calc(0.24 * var(--h)); font-weight: 900; white-space: nowrap;",
        ), // case/tracking come from the DNA's hero treatment
      words: ln._w.map((w, i) => ({
        text: i === 0 && ln.text ? ln.text : w.text,
        start: w.start,
        end: Math.min(w.end, DUR - 0.05),
        ti: w.ti,
      })),
    });
    if (!heroFeasible)
      console.log(
        "[make-cinematic] no hero band ≤62% predicted occlusion → hero rendered in FRONT (last resort)",
      );
  }
  // scarcity spacing: heroes must breathe apart (≥ a beat between windows)
  const heroGs = groups.filter((g) => g.hero === true).sort((a, b) => a.in - b.in);
  for (let i = 1; i < heroGs.length; i++) {
    const gap = heroGs[i].in - heroGs[i - 1].out;
    if (gap < 0.6)
      console.log(
        `[make-cinematic] ⚠ heroes "${heroGs[i - 1].words[0].text}" and "${heroGs[i].words[0].text}" are only ${gap.toFixed(2)}s apart — peaks need a beat of air between them (consider demoting one to an emphasis line)`,
      );
  }
  // proximity: narration planes should HUG the silhouette (the embed reads as in-scene
  // only when typography interacts with the subject) — warn on far-parked planes.
  if (sz && sz.subject) {
    for (const [pk2, pv] of Object.entries(C.planes || {})) {
      if (/^lockup/.test(pk2) || pk2 === "hero" || pv == null) continue;
      const css2 = typeof pv === "string" ? pv : pv.css || "";
      const lm = css2.match(/left\s*:\s*([\d.]+)%/),
        wm = css2.match(/width\s*:\s*([\d.]+)%/);
      if (!lm || !wm) continue;
      const pl = +lm[1],
        pr = +lm[1] + +wm[1];
      const gap =
        pr <= sz.subject.colMinPct
          ? sz.subject.colMinPct - pr
          : pl >= sz.subject.colMaxPct
            ? pl - sz.subject.colMaxPct
            : 0;
      if (gap > 12)
        console.log(
          `[make-cinematic] ⚠ plane "${pk2}" sits ${gap.toFixed(0)}% away from the silhouette — embeds read in-scene when text HUGS the subject (use safe-zones zones.hugLeft/hugRight, gap 2–6%)`,
        );
    }
  }
  const plan = {
    mode: "template",
    dna: C.dna || (C.template ? undefined : "cream"), // canonical: name a DNA; default cream
    template: C.template || "cinematic-cream",
    compiled_by: "make-cinematic.cjs",
    width: W,
    height: H,
    fps: FPS,
    duration: DUR,
    ...(globalFg && (!heroRefs.length || !heroFeasible) ? { caption_layer: "fg" } : {}), // mixed narration-fg + hero-bg → per-group layers (hybrid render)
    planes: Object.fromEntries(Object.entries(planes).map(([k, v]) => [k, { css: v.css }])),
    groups,
  };
  fs.writeFileSync(path.join(project, "plan.json"), JSON.stringify(plan, null, 2));
  console.log(
    `[make-cinematic] ${blocks.length} block(s) → ${groups.length} group(s)` +
      `${heroRefs.length ? `, ${heroRefs.length} hero(es): ${heroRefs.map((h) => `"${h.ln.text || h.ln.words[0]}"`).join(" · ")} (each holds to its block end, lockup-composed)` : ""}` +
      `${globalFg ? ", caption_layer=fg (safe-zones verdict)" : ""}, canvas ${DUR}s`,
  );
  // straight into the existing template compiler
  const cp2 = require("child_process");
  let r = cp2.spawnSync("node", [path.join(__dirname, "make-composition.cjs"), project], {
    stdio: "inherit",
  });
  if ((r.status || 0) !== 0) process.exit(r.status);

  // ── RE-SLOT pass (real measurement). Compile-time slots assume each line renders as
  // ONE visual line; a narrow plane WRAPS long lines and the next slot overlaps the
  // wrapped tail. Measure every page's REAL text heights in Chromium and re-stack.
  {
    const textBoxH = (c) => {
      const ws = (c.words || []).filter((w) => (w.opacity ?? 1) > 0.05 && w.w > 0);
      if (!ws.length) return c.cap_bbox ? c.cap_bbox.h : 0;
      return Math.max(...ws.map((w) => w.y + w.h)) - Math.min(...ws.map((w) => w.y));
    };
    const pagesByKey = {};
    for (const l of flat) {
      const key = l.plane + "|" + (l.pageIdx || 0);
      (pagesByKey[key] = pagesByKey[key] || { t: +(l.out - 0.1).toFixed(2), lines: [] }).lines.push(
        l,
      );
    }
    // the HERO is a real member of its lockup's stack — re-slot must move it (and the
    // post-context below it) from MEASURED heights, or a wrapped pre-line / raised hero
    // font reopens the very overlap this pass exists to prevent
    for (const hRef of heroRefs) {
      const key = hRef.plane + "|0";
      const hg2 = groups.find((g2) => g2.id === `h-${hRef.bi}`);
      if (!hg2) continue;
      (pagesByKey[key] = pagesByKey[key] || {
        t: +(hg2.out - 0.1).toFixed(2),
        lines: [],
      }).lines.push({
        gid: hg2.id,
        slotPx: hRef.slotPx || 0,
        frac: fracOfLine(hg2.css) || 0.24,
        _hero: true,
      });
    }
    const times = [...new Set(Object.values(pagesByKey).map((pg) => pg.t))];
    if (times.length) {
      cp2.spawnSync(
        "node",
        [path.join(__dirname, "measure-layout.cjs"), project, ...times.map(String)],
        { stdio: "ignore", timeout: 120000 },
      );
      let lay = null;
      try {
        lay = JSON.parse(fs.readFileSync(path.join(project, "_layout.json"), "utf8"));
      } catch {}
      if (lay && lay.samples) {
        let changes = 0;
        for (const pg of Object.values(pagesByKey)) {
          const sample = lay.samples.reduce((a, b) =>
            Math.abs(b.t - pg.t) < Math.abs(a.t - pg.t) ? b : a,
          );
          if (!sample || Math.abs(sample.t - pg.t) > 0.3) continue;
          const ordered = [...pg.lines].sort((a, b) => a.slotPx - b.slotPx);
          let top = 0;
          for (const l of ordered) {
            const cap = (sample.caps || []).find((c) => c.id === l.gid);
            const realH = cap
              ? (cap.cap_bbox && cap.cap_bbox.h) || textBoxH(cap)
              : l.frac * H * 1.25;
            if (Math.abs(top - l.slotPx) > 3) {
              const g = groups.find((g2) => g2.id === l.gid);
              g.css = g.css.replace(/top:\s*-?[\d.]+px/, "top:" + Math.round(top) + "px");
              if (l._hero) {
                const hr = heroRefs.find((h) => `h-${h.bi}` === l.gid);
                if (hr) hr.slotPx = Math.round(top);
              }
              l.slotPx = Math.round(top);
              changes++;
            }
            top +=
              (realH || l.frac * H * 1.25) + Math.max(12, Math.round((l.frac || 0.05) * H * 0.38)); // box is lh1.12 — contains its own ink
          }
        }
        if (changes) {
          console.log(
            `[make-cinematic] re-slot: ${changes} line(s) re-stacked from MEASURED heights (wrapped lines accounted)`,
          );
          fs.writeFileSync(path.join(project, "plan.json"), JSON.stringify(plan, null, 2));
          cp2.spawnSync("node", [path.join(__dirname, "make-composition.cjs"), project], {
            stdio: "ignore",
          });
          try {
            fs.unlinkSync(path.join(project, "_layout.json"));
          } catch {}
        }
      }
    }
  }

  // ── HERO post-pass (real measurement, up to 2 re-emits) — runs PER HERO ─────
  // (a) SIZE: a timid hero is the #1 quality kill. If the authored font leaves >25%
  //     of the usable width unused, raise it to the width-fit maximum (DNA sizeRange cap).
  // (b) CLEARANCE vs OTHER planes: the hero band must not overlap caps outside its own
  //     lockup (its own context is composed around it by the slot layout). On a hit,
  //     shift the whole LOCKUP plane vertically.
  // (c) LOCKUP RE-STACK: a size change moves the hero's height — restack the lockup's
  //     slots from measured heights so post-context stays bonded BELOW the hero.
  // HERO words never wrap — a mid-word break ("Watermelo|n") is wrong in every case,
  // and author css REPLACES the default (which silently lost white-space:nowrap).
  // Forced here so no authoring path can drop it; centered nowrap text overflows a
  // narrow plane SYMMETRICALLY, which also keeps width-fit honest (it measures the
  // true single-line width instead of a wrapped row).
  // narration lines whipped away by a mid-sentence page-flip are the same disease
  // in mild form — warn (a deliberate quick beat is legal, so no die)
  for (const g of groups)
    if (!g.hero && g.words && g.words.length && g.out - g.in < 0.6) {
      console.log(
        `[make-cinematic] ⚠ line "${g.words
          .map((w) => w.text)
          .join(" ")
          .slice(
            0,
            28,
          )}" visible only ${(g.out - g.in).toFixed(2)}s (page-flip) — merge micro-lines or give the plane more height so the page holds`,
      );
    }
  // MIN DWELL — a peak on screen <0.5s is unreadable ("stars" shipped at 0.47s and
  // the user couldn't read it). Merge the block with the next (one thought), promote
  // a word with room, or demote to an emphasized line. <0.8s gets a loud warning.
  for (const g of groups)
    if (g.hero) {
      const dwell = g.out - g.in;
      if (dwell < 0.5)
        die(
          `hero "${(g.words[0] || {}).text}" is on screen only ${dwell.toFixed(2)}s (<0.5s readability floor) — merge its block with the next, promote a word with more room, or demote it to an emphasized (non-hero) line.`,
        );
      if (dwell < 0.8)
        console.log(
          `[make-cinematic] ⚠ hero "${(g.words[0] || {}).text}" dwell ${dwell.toFixed(2)}s is tight (<0.8s) — consider merging blocks so the peak can breathe`,
        );
    }
  {
    let nwChanged = false;
    for (const g of groups)
      if (g.hero && !/white-space\s*:\s*nowrap/.test(g.css || "")) {
        g.css = (g.css || "") + " white-space: nowrap;";
        nwChanged = true;
      }
    if (nwChanged) {
      fs.writeFileSync(path.join(project, "plan.json"), JSON.stringify(plan, null, 2));
      require("child_process").spawnSync(
        "node",
        [path.join(__dirname, "make-composition.cjs"), project],
        { stdio: "ignore" },
      );
    }
  }
  const fracOf = (css) => {
    const m = (css || "").match(/font-size\s*:\s*calc\(\s*([\d.]+)\s*\*\s*var\(--h\)/);
    return m ? +m[1] : null;
  };
  const setFrac = (css, f) =>
    (css || "").replace(
      /font-size\s*:\s*[^;]+/,
      "font-size: calc(" + f.toFixed(3) + " * var(--h))",
    );
  const textBox = (c) => {
    const ws = (c.words || []).filter((w) => (w.opacity ?? 1) > 0.05 && w.w > 0);
    if (!ws.length) return c.cap_bbox;
    const x0 = Math.min(...ws.map((w) => w.x)),
      x1 = Math.max(...ws.map((w) => w.x + w.w));
    const y0 = Math.min(...ws.map((w) => w.y)),
      y1 = Math.max(...ws.map((w) => w.y + w.h));
    return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
  };
  let dnaSizeCap = 0.34,
    dnaHeroCase = "none";
  try {
    const dnaLib2 = require("./lib-dna.cjs");
    const dn = plan.dna || dnaLib2.LEGACY[plan.template];
    if (dn) {
      const dj = dnaLib2.load(dn);
      if (dj.hero && dj.hero.sizeRange) dnaSizeCap = dj.hero.sizeRange[1];
      if (dj.hero) dnaHeroCase = dj.hero.case || "none";
    }
  } catch {}
  // the APEX (largest authored hero) gets the width-fit raise; minor heroes keep their
  // authored size — inter-hero hierarchy is a deliberate choice, not timidity
  const apexFrac = Math.max(
    ...heroRefs.map((h) => fracOf((groups.find((g) => g.id === `h-${h.bi}`) || {}).css) || 0),
    0,
  );
  for (const hRef of heroRefs) {
    const heroG = groups.find((g) => g.id === `h-${hRef.bi}`);
    if (!heroG) continue;
    const isApex = (fracOf(heroG.css) || 0) >= apexFrac - 1e-6;
    const lockupMates = groups.filter((g) => g.plane === hRef.plane && g.id !== heroG.id);
    // hero size changed → context follows the 0.26 poster ratio, and its orbit
    // max-width scales WITH the font (a stale max-width wraps re-locked context
    // into multi-row towers that blow the stack past the frame)
    const relockMates = (hf) => {
      const rf = +Math.min(0.085, Math.max(0.05, hf * 0.26)).toFixed(4);
      for (const m of lockupMates) {
        const mf = fracOf(m.css);
        if (!mf || Math.abs(mf - rf) <= 0.002) continue;
        const scale = rf / mf;
        m.css = setFrac(m.css, rf).replace(
          /max-width:\s*([\d.]+)px/,
          (s2, px) => "max-width:" + Math.round(+px * scale) + "px",
        );
      }
    };
    const measure = () => {
      const midT = ((heroG.in + Math.min(heroG.out, heroG.in + 1.0)) / 2).toFixed(2);
      const endT = Math.max(+midT + 0.05, heroG.out - 0.12).toFixed(2);
      cp2.spawnSync(
        "node",
        [path.join(__dirname, "measure-layout.cjs"), project, String(midT), String(endT)],
        { stdio: "ignore", timeout: 90000 },
      );
      try {
        const ss = JSON.parse(fs.readFileSync(path.join(project, "_layout.json"), "utf8")).samples;
        const near = (t) =>
          ss.reduce((a, b) => (Math.abs(b.t - t) < Math.abs((a ? a.t : 1e9) - t) ? b : a), null);
        return { caps: (near(+midT) || {}).caps || [], capsEnd: (near(+endT) || {}).caps || [] };
      } catch {
        return null;
      }
    };
    let didRaise = false;
    for (let pass = 0; pass < 5; pass++) {
      const m0 = measure();
      if (!m0) break;
      const caps = m0.caps,
        capsEnd = m0.capsEnd && m0.capsEnd.length ? m0.capsEnd : m0.caps;
      const hc0 = caps.find((c) => c.id === heroG.id);
      if (!hc0 || !hc0.cap_bbox) break;
      const hc = { ...hc0, cap_bbox: textBox(hc0) };
      const mateIds = new Set(lockupMates.map((m) => m.id));
      const others = caps
        .filter(
          (c) =>
            c.id !== heroG.id &&
            !mateIds.has(c.id) &&
            !/-glow$/.test(c.id || "") &&
            c.cap_bbox &&
            c.opacity > 0.05,
        )
        .map((c) => ({ ...c, cap_bbox: textBox(c) }));
      const maxW = W * 0.94;
      const f0 = fracOf(heroG.css);
      let changed = false;
      if (f0 && hc.cap_bbox.w > 0) {
        if (hc.cap_bbox.w > maxW) {
          const nf = Math.max(0.06, (f0 * maxW) / hc.cap_bbox.w);
          console.log(
            `[make-cinematic] hero "${heroG.words[0].text}" too WIDE ${Math.round(hc.cap_bbox.w)}px → ${f0}h→${nf.toFixed(3)}h`,
          );
          heroG.css = setFrac(heroG.css, nf);
          changed = true;
        } else if (hc.cap_bbox.w >= maxW * 0.88) {
          if (pass === 0)
            console.log(
              `[make-cinematic] hero "${heroG.words[0].text}" at width CEILING (${Math.round(hc.cap_bbox.w)}/${Math.round(maxW)}px) — cannot enlarge.`,
            );
        } else if (!didRaise && hc.cap_bbox.w < maxW * 0.88 && isApex) {
          // NO DEAD ZONE: anything under 88% of usable width is raised toward a 93%
          // fill (the old <75% trigger let 75–88%-wide heroes stay merely "decent")
          const fit = (f0 * (maxW * 0.93)) / hc.cap_bbox.w;
          const nf = Math.min(dnaSizeCap, fit);
          if (nf > f0 * 1.05 || fit > dnaSizeCap * 1.02) {
            if (nf > f0 * 1.001)
              console.log(
                `[make-cinematic] hero "${heroG.words[0].text}" TIMID (${Math.round(hc.cap_bbox.w)}px of ${Math.round(maxW)}px usable) → ${f0}h→${nf.toFixed(3)}h (width-fit max)`,
              );
            // SHORT-WORD FILL: height cap bound before the width target → fill with
            // TRACKING (film-title craft — HER, DUNE), ≤0.32em so it stays a word.
            const wAtCap = (hc.cap_bbox.w * nf) / f0;
            const dispT = String(heroG.text || (heroG.words || []).map((w) => w.text).join(" "));
            // never letterspace lowercase (tracked lowercase falls apart — caps only)
            const capsy = dnaHeroCase === "uppercase" || dispT === dispT.toUpperCase();
            if (fit > dnaSizeCap && wAtCap < maxW * 0.85 && capsy) {
              const disp = dispT;
              // letter-spacing adds a gap after EVERY char (N gaps) and the centering
              // text-indent adds one more — budget N+1 gaps or the word leaves the frame
              const gaps = disp.length + 1;
              const tr = +Math.min(0.32, (maxW * 0.88 - wAtCap) / (gaps * nf * H)).toFixed(3);
              if (tr >= 0.04) {
                heroG.css =
                  heroG.css
                    .replace(/letter-spacing\s*:[^;]+;?\s*/g, "")
                    .replace(/text-indent\s*:[^;]+;?\s*/g, "") +
                  (heroG.css.trim().endsWith(";") || !heroG.css.trim() ? "" : ";") +
                  ` letter-spacing:${tr}em; text-indent:${tr}em;`;
                console.log(
                  `[make-cinematic] hero "${disp}" short word at height cap (${Math.round(wAtCap)}px of ${Math.round(maxW)}px) → tracked +${tr}em to fill`,
                );
              }
            }
            heroG.css = setFrac(heroG.css, nf);
            changed = true;
            didRaise = true;
            relockMates(nf);
          }
        }
      }
      const hbx = hc.cap_bbox;
      // colliders OUTSIDE the lockup only — the lockup composes around the hero by design
      const hits = others.filter((o) => {
        const b = o.cap_bbox;
        const ix = Math.min(hbx.x + hbx.w, b.x + b.w) - Math.max(hbx.x, b.x);
        const iy = Math.min(hbx.y + hbx.h, b.y + b.h) - Math.max(hbx.y, b.y);
        return ix > 8 && iy > 8;
      });
      if (hits.length) {
        const maxBottom = Math.max(...hits.map((o) => o.cap_bbox.y + o.cap_bbox.h));
        const minTop = Math.min(...hits.map((o) => o.cap_bbox.y));
        const downTo = ((maxBottom + 14) / H) * 100;
        const upTo = Math.max(2, ((minTop - hbx.h - 14) / H) * 100);
        const planeCss = plan.planes[hRef.plane].css;
        const curTop = +(planeCss.match(/top:\s*([\d.]+)%/) || [0, 30])[1];
        const newTop = downTo + (hbx.h / H) * 100 < 96 ? downTo : upTo;
        plan.planes[hRef.plane].css = planeCss.replace(
          /top:\s*[\d.]+%/,
          "top: " + newTop.toFixed(1) + "%",
        );
        console.log(
          `[make-cinematic] hero "${heroG.words[0].text}" OVERLAPS ${hits.map((o) => o.id).join(",")} — lockup top ${curTop}% → ${newTop.toFixed(1)}%`,
        );
        changed = true;
      }
      // ORBIT REFINE: anchor kicker/tail to the MEASURED hero edges (estimate → real)
      if (lockupMates.length && hc.cap_bbox && hc.cap_bbox.w > 0) {
        const planeCss2 = (plan.planes[hRef.plane] && plan.planes[hRef.plane].css) || "";
        const pl2 = ((planeCss2.match(/left:\s*([\d.]+)%/) || [0, 0])[1] * W) / 100;
        const relL = Math.max(0, Math.round(hc.cap_bbox.x - pl2));
        const pw2 = ((planeCss2.match(/width:\s*([\d.]+)%/) || [0, 100])[1] * W) / 100;
        const relR = Math.max(0, Math.round(pw2 - (hc.cap_bbox.x - pl2) - hc.cap_bbox.w));
        for (const m of lockupMates) {
          if (/left:\s*\d+px;right:auto/.test(m.css)) {
            const nc = m.css.replace(/left:\s*\d+px/, "left:" + relL + "px");
            if (nc !== m.css) {
              m.css = nc;
              changed = true;
            }
          } else if (/right:\s*\d+px;left:auto/.test(m.css)) {
            const nc = m.css.replace(/right:\s*\d+px/, "right:" + relR + "px");
            if (nc !== m.css) {
              m.css = nc;
              changed = true;
            }
          }
        }
      }
      // LOCKUP RE-STACK from measured heights — runs EVERY pass for lockups (orbit
      // max-widths wrap context lines, so the re-slot's estimates can leave the tail
      // displaced or the whole composition off frame)
      if (lockupMates.length) {
        const caps2 = capsEnd; // lockup-END sample: every context line is revealed there
        // TOWER CHECK — a context line wrapped to 3+ rows means its orbit max-width is
        // too narrow for the (re-locked) font; widen toward the plane before stacking,
        // or the tower blows the stack past the frame and the walk-back eats the hero
        for (const m of lockupMates) {
          const cm = caps2.find((x) => x.id === m.id);
          const frm = fracOf(m.css) || 0.05;
          const mwM = m.css.match(/max-width:\s*([\d.]+)px/);
          if (!cm || !cm.cap_bbox || !mwM) continue;
          const rows = Math.round(cm.cap_bbox.h / (frm * H * 1.12));
          if (rows >= 3) {
            const planeCssW2 = (plan.planes[hRef.plane] && plan.planes[hRef.plane].css) || "";
            const pwX = W * (+(planeCssW2.match(/width:\s*([\d.]+)%/) || [0, 92])[1] / 100);
            const nw = Math.round(pwX * 0.62);
            if (nw > +mwM[1] + 10) {
              m.css = m.css.replace(/max-width:\s*[\d.]+px/, "max-width:" + nw + "px");
              console.log(
                `[make-cinematic] context "${((m.words || [])[0] || {}).text}…" wrapped to ${rows} rows — orbit max-width ${Math.round(+mwM[1])}→${nw}px (towers break the stack)`,
              );
              changed = true;
            }
          }
        }
        const entries = [heroG, ...lockupMates]
          .map((g) => {
            const c = caps2.find((x) => x.id === g.id);
            const curTop = +(g.css.match(/top:\s*(-?[\d.]+)px/) || [0, 0])[1];
            const fr = fracOf(g.css) || 0.05;
            // SLOT HEIGHT = the LAYOUT box (cap_bbox), not the glyph box: words reveal
            // over time, so at the hero-mid sample a context line's later words are
            // still opacity-0 — glyph-box measured a wrapped line one row short and the
            // next line slotted INTO its wrap band (real text-on-text shipped).
            const measuredH = c && c.cap_bbox ? c.cap_bbox.h : null;
            const h2 = g.id === heroG.id && didRaise ? fr * H * 1.12 : measuredH || fr * H * 1.25;
            return { g, curTop, h: h2 };
          })
          .sort((a, b) => a.curTop - b.curTop);
        let top = 0;
        for (const e of entries) {
          if (Math.abs(top - e.curTop) > 3) {
            e.g.css = e.g.css.replace(/top:\s*-?[\d.]+px/, "top:" + Math.round(top) + "px");
            changed = true;
          }
          top += e.h + Math.max(12, Math.round((fracOf(e.g.css) || 0.05) * H * 0.38)); // box is lh1.12 — contains its own ink
        }
        // RE-CLAMP the plane after MEASURED growth — keep the whole composition on frame
        const planeCss3 = plan.planes[hRef.plane].css;
        const curTopPct = +(planeCss3.match(/top:\s*([\d.]+)%/) || [0, 30])[1];
        const maxTopPct = Math.max(2, ((H * 0.97 - top) / H) * 100);
        if (curTopPct > maxTopPct + 0.3) {
          plan.planes[hRef.plane].css = planeCss3.replace(
            /top:\s*[\d.]+%/,
            "top: " + maxTopPct.toFixed(1) + "%",
          );
          console.log(
            `[make-cinematic] lockup measured ${Math.round(top)}px tall — plane top ${curTopPct}% → ${maxTopPct.toFixed(1)}% (kept on frame)`,
          );
          changed = true;
        }
        // even at the 2% ceiling the stack can exceed the frame (TIMID raise + ratio
        // context on a tall lockup) — walk the hero back 8% and re-lock until it fits
        if (top > H * 0.95 && maxTopPct <= 2.01) {
          const f0c = fracOf(heroG.css);
          if (f0c && f0c > 0.14) {
            // decisive step: stack height scales ~linearly with the hero (context is
            // ratio-locked to it), so aim straight at a 93% fill instead of nibbling
            const nf2 = +Math.max(0.14, Math.min(f0c * 0.92, (f0c * (H * 0.93)) / top)).toFixed(3);
            console.log(
              `[make-cinematic] lockup stack ${Math.round(top)}px exceeds the frame even at top 2% — hero ${f0c}h → ${nf2}h (fit)`,
            );
            heroG.css = setFrac(heroG.css, nf2);
            relockMates(nf2);
            didRaise = true;
            changed = true;
          }
        }
      }
      if (!changed) break;
      fs.writeFileSync(path.join(project, "plan.json"), JSON.stringify(plan, null, 2));
      r = cp2.spawnSync("node", [path.join(__dirname, "make-composition.cjs"), project], {
        stdio: "ignore",
      });
      try {
        fs.unlinkSync(path.join(project, "_layout.json"));
      } catch {}
    }
  }
  // MINOR HERO measure pass — minors keep their authored size (hierarchy is a choice)
  // but never ship broken: wider than the frame's usable width shrinks to fit, and a
  // column overflow gets a loud note (centered spill is legal on a clear side only).
  {
    const minors = groups.filter((g) => g.hero && !heroRefs.some((h) => `h-${h.bi}` === g.id));
    let minorChanged = false;
    for (const mg of minors) {
      const midT = ((mg.in + Math.min(mg.out, mg.in + 1.0)) / 2).toFixed(2);
      cp2.spawnSync("node", [path.join(__dirname, "measure-layout.cjs"), project, String(midT)], {
        stdio: "ignore",
        timeout: 60000,
      });
      let mc = null;
      try {
        mc = (
          JSON.parse(fs.readFileSync(path.join(project, "_layout.json"), "utf8")).samples[0].caps ||
          []
        ).find((c) => c.id === mg.id);
      } catch {}
      if (!mc || !mc.cap_bbox) continue;
      const bb = textBox(mc),
        f0 = fracOf(mg.css);
      if (f0 && bb.w > W * 0.94) {
        const nf = Math.max(0.06, +((f0 * (W * 0.94)) / bb.w).toFixed(3));
        console.log(
          `[make-cinematic] minor hero "${(mg.words[0] || {}).text}" too WIDE ${Math.round(bb.w)}px → ${f0}h→${nf.toFixed(3)}h (shrink-to-fit)`,
        );
        mg.css = setFrac(mg.css, nf);
        minorChanged = true;
      } else {
        const planeCssM = (plan.planes[mg.plane] && plan.planes[mg.plane].css) || "";
        const pwPx = W * (+(planeCssM.match(/width:\s*([\d.]+)%/) || [0, 100])[1] / 100);
        if (bb.w > pwPx * 1.05)
          console.log(
            `[make-cinematic] note: minor hero "${(mg.words[0] || {}).text}" (${Math.round(bb.w)}px) spills its ${Math.round(pwPx)}px column — fine on a clear side; reduce size if it crosses the subject`,
          );
      }
      try {
        fs.unlinkSync(path.join(project, "_layout.json"));
      } catch {}
    }
    if (minorChanged) {
      fs.writeFileSync(path.join(project, "plan.json"), JSON.stringify(plan, null, 2));
      cp2.spawnSync("node", [path.join(__dirname, "make-composition.cjs"), project], {
        stdio: "ignore",
      });
    }
  }
  process.exit(0);
}
main();
scripts/make-composition.cjs
#!/usr/bin/env node
/*
 * make-composition.cjs — compile plan.json → index.html (CINEMATIC MODE).
 *
 * DNA path (canonical): plan.dna names a dna/<name>.json → modes/cinematic/engine.html
 * is compiled with scene-resolved tokens (lib-dna.cjs): scene-sampled accent, light-
 * direction contact shadow, depth-match blur, hero three-act orchestration with
 * RMS-coupled amplitude. plan.template="cinematic-cream" maps to dna "cream".
 *
 * Legacy path: plan.template naming an archived template dir still compiles 1:1
 * (memory-wall / champion / portrait-header in modes/cinematic/_archive are NOT
 * auto-discovered — restore them to modes/cinematic/ to use).
 *
 *   node make-composition.cjs <project-dir>
 */
const path = require("path");
const fs = require("fs");
const cp = require("child_process");
const dnaLib = require("./lib-dna.cjs");

const SKILL_ROOT = path.resolve(__dirname, "..");
const TEMPLATES = path.join(SKILL_ROOT, "modes", "cinematic");

// Source clip duration (seconds) via ffprobe. The COMPOSITION/background length must
// follow the SOURCE, not the last caption — see the Bug-1 note in main().
function sourceDurationSec(project) {
  let cands = ["source.mp4"];
  try {
    cands = cands.concat(
      fs
        .readdirSync(project)
        .filter(
          (f) =>
            /\.(mp4|mov|webm|mkv|m4v)$/i.test(f) &&
            !/^(final|bg_plus_caps|fg_caps|rail|index)/.test(f),
        ),
    );
  } catch {}
  for (const c of cands) {
    const p = path.isAbsolute(c) ? c : path.join(project, c);
    if (!fs.existsSync(p)) continue;
    try {
      const out = cp
        .execFileSync(
          "ffprobe",
          [
            "-v",
            "error",
            "-show_entries",
            "format=duration",
            "-of",
            "default=nokey=1:noprint_wrappers=1",
            "--",
            p,
          ],
          { encoding: "utf8" },
        )
        .trim();
      const d = parseFloat(out);
      if (d > 0) return d;
    } catch {}
  }
  return null;
}

function hexLum(hex) {
  let h = String(hex).replace("#", "");
  if (h.length === 3) h = [...h].map((c) => c + c).join("");
  const r = parseInt(h.slice(0, 2), 16) / 255,
    g = parseInt(h.slice(2, 4), 16) / 255,
    b = parseInt(h.slice(4, 6), 16) / 255;
  if ([r, g, b].some(Number.isNaN)) return 0.9;
  return 0.299 * r + 0.587 * g + 0.114 * b;
}
const defaultTextShadow = (c) =>
  hexLum(c) < 0.45
    ? "0 2px 6px rgba(0, 0, 0, 0.28)"
    : "0 0 18px rgba(255, 220, 170, 0.55), 0 3px 8px rgba(0, 0, 0, 0.85)";
const defaultTextFilter = (c) =>
  hexLum(c) < 0.45 ? "contrast(1.08)" : "brightness(1.1) contrast(1.05)";

function findTemplate(name) {
  const p = path.join(TEMPLATES, name, "template.html");
  if (fs.existsSync(p)) return p;
  const avail = fs
    .readdirSync(TEMPLATES)
    .filter((d) => fs.existsSync(path.join(TEMPLATES, d, "template.html")))
    .sort();
  console.error(`[compile] unknown template: ${name}. Available: ${avail.join(", ")}`);
  process.exit(1);
}
function escBr(t) {
  const e = String(t)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
  return e
    .replace(/&lt;br&gt;/g, "<br>")
    .replace(/&lt;br\/&gt;/g, "<br>")
    .replace(/&lt;br \/&gt;/g, "<br>");
}
// hero words get per-letter inner spans (<span class="l">) so the engine can stagger
// letters; .w keeps wrapping the word so every gate (occlusion / overflow / layout)
// measures exactly what it measured before.
function wordSpan(w, i, perLetter) {
  if (!perLetter) return `<span class="w" data-i="${i}">${escBr(w.text)}</span>`;
  const t = String(w.text);
  if (/<br\s*\/?>/i.test(t)) return `<span class="w" data-i="${i}">${escBr(t)}</span>`; // explicit <br> words stay whole
  const letters = [...t]
    .map((ch) => (ch === " " ? " " : `<span class="l">${escBr(ch)}</span>`))
    .join("");
  return `<span class="w" data-i="${i}">${letters}</span>`;
}
function renderCap(g, heroCfg) {
  const slot = g.slot ?? g.style ?? "";
  const layerAttr = g.layer ? ` data-layer="${g.layer}"` : "";
  const isHero = heroCfg && g.hero === true;
  const perLetter = isHero && heroCfg.perLetter;
  const cls = slot ? `cap cap-${slot}` : "cap";
  const spans = g.words.map((w, i) => wordSpan(w, i, perLetter)).join("\n            ");
  const capDiv = `<div id="${g.id}" class="${cls}"${layerAttr}>\n            ${spans}\n          </div>`;
  if (!isHero) return capDiv;
  // fx siblings render UNDER the hero (earlier sibling = beneath): glow first, then
  // echoes, then the hero itself. Plain .w spans; none are in plan.groups → gates skip.
  const plainSpans = g.words
    .map((w, i) => `<span class="w" data-i="${i}">${escBr(w.text)}</span>`)
    .join("\n            ");
  const parts = [];
  if (heroCfg.glow)
    parts.push(
      `<div id="${g.id}-glow" class="${cls} hero-glow" aria-hidden="true"${layerAttr}>\n            ${plainSpans}\n          </div>`,
    );
  (heroCfg.echoes || []).forEach((ec, k) => {
    parts.push(
      `<div id="${g.id}-echo-${k}" class="${cls} hero-echo" aria-hidden="true"${layerAttr}>\n            ${plainSpans}\n          </div>`,
    );
  });
  parts.push(capDiv);
  return parts.join("\n          ");
}
function buildGroupsHtml(groups, planes, heroCfg) {
  if (!planes) return groups.map((g) => renderCap(g, heroCfg)).join("\n        ");
  const order = Object.keys(planes);
  const grouped = {};
  order.forEach((p) => (grouped[p] = []));
  const free = [];
  for (const g of groups) {
    const pid = g.plane;
    if (pid && pid in grouped) grouped[pid].push(g);
    else free.push(g);
  }
  const parts = [];
  for (const pid of order)
    parts.push(
      `<div id="plane-${pid}" class="plane plane-${pid}">\n          ${grouped[pid].map((g) => renderCap(g, heroCfg)).join("\n          ")}\n        </div>`,
    );
  for (const g of free) parts.push(renderCap(g, heroCfg));
  return parts.join("\n        ");
}
function buildPlanesCss(planes) {
  if (!planes) return "";
  return Object.entries(planes)
    .map(([pid, p]) => {
      // Accept BOTH plane shapes: { body: { css: "..." } } and the bare-string
      // { body: "..." } (matches how groups write css). Reading only p.css silently
      // dropped the string form → empty CSS → planes collapsed to (0,0).
      let css = (typeof p === "string" ? p : (p || {}).css || "").trim();
      if (!css) return null;
      if (!css.endsWith(";")) css += ";";
      return `      .plane-${pid} { ${css} }`;
    })
    .filter(Boolean)
    .join("\n");
}
function buildPerGroupCss(groups, tokens) {
  return groups
    .map((g) => {
      const parts = [];
      if (g.scale != null) parts.push(`--s: ${g.scale};`);
      let css = (g.css || "").trim();
      if (css) parts.push(css.endsWith(";") ? css : css + ";");
      const isHero = tokens && g.hero === true;
      if (isHero) {
        // DNA hero treatment (case / tracking / extra css) — authored css still wins (it comes first? no: later rules win; DNA goes first so the author can override)
        const dnaHero = [];
        if (tokens.heroCase && tokens.heroCase !== "none")
          dnaHero.push(`text-transform: ${tokens.heroCase};`);
        if (tokens.heroTracking && tokens.heroTracking !== "0")
          dnaHero.push(`letter-spacing: ${tokens.heroTracking};`);
        if (tokens.heroCss)
          dnaHero.push(tokens.heroCss.endsWith(";") ? tokens.heroCss : tokens.heroCss + ";");
        if (tokens.hero && tokens.hero.heroColor) dnaHero.push(`color: ${tokens.hero.heroColor};`);
        parts.unshift(...dnaHero);
      }
      const rules = [];
      if (parts.length) rules.push(`      #${g.id} { ${parts.join(" ")} }`);
      // fx duplicates mirror the hero's geometry exactly (same slot/absolute CSS) so a
      // flex/slot plane can't displace them; glow blur comes from .hero-glow (em-based),
      // echo colors from their own rules (they must NOT inherit a transparent text fill).
      if (isHero && tokens.hero) {
        // word-level hero treatment (e.g. chrome's gradient clip — background-clip:text
        // can't clip through composited children, so it must live ON the .w span)
        if (tokens.hero.wordCss) rules.push(`      #${g.id} .w { ${tokens.hero.wordCss} }`);
        if (tokens.hero.glow > 0 && parts.length)
          rules.push(`      #${g.id}-glow { ${parts.join(" ")} }`);
        (tokens.hero.echoes || []).forEach((ec, k) => {
          const extra =
            `color: ${ec.color}; -webkit-text-fill-color: ${ec.color}; background: none;` +
            (ec.blur ? ` filter: blur(${ec.blur}px);` : "");
          rules.push(`      #${g.id}-echo-${k} { ${parts.join(" ")} ${extra} }`);
        });
      }
      return rules.length ? rules.join("\n") : null;
    })
    .filter(Boolean)
    .join("\n");
}
function buildGroupsJson(groups) {
  return JSON.stringify(
    groups.map((g) => ({
      id: g.id,
      in: g.in,
      out: g.out,
      tone: g.tone ?? "soft",
      ...(g.hero === true ? { hero: true } : {}),
      ...(g.minor === true ? { minor: true } : {}),
      ...(g.plane ? { plane: g.plane } : {}),
      words: g.words.map((w) => ({ text: w.text, start: w.start, end: w.end })),
    })),
    null,
    10,
  );
}

function main() {
  const project = path.resolve(process.argv[2] || "");
  if (!process.argv[2]) {
    console.error("usage: make-composition.cjs <project-dir>");
    process.exit(1);
  }
  const planPath = path.join(project, "plan.json");
  if (!fs.existsSync(planPath)) {
    console.error(`[compile] missing ${planPath}`);
    process.exit(1);
  }
  const plan = JSON.parse(fs.readFileSync(planPath, "utf8"));
  if (plan.mode === "custom") {
    console.error("[compile] mode=custom — skip this script and hand-write index.html.");
    process.exit(1);
  }
  if (plan.mode === "standard") {
    console.error(
      "[compile] mode=standard — this plan.json is DERIVED by make-standard.cjs; compile Standard projects with make-standard.cjs (from standard.json), not this script.",
    );
    process.exit(1);
  }
  // ── DNA resolution: plan.dna (canonical) or a legacy template name ──────────
  const dnaName = plan.dna || dnaLib.LEGACY[plan.template] || null;
  let dna = null,
    tokens = null;
  if (dnaName) {
    try {
      dna = dnaLib.load(dnaName);
    } catch (e) {
      console.error(`[compile] ${e.message}`);
      process.exit(1);
    }
  }
  const heroGroups = (plan.groups || []).filter((x) => x.hero === true);
  if (dna) {
    tokens = dnaLib.resolveTokens(dna, project, { heroGroups });
    console.log(
      `[compile] DNA "${dna.name}" (${tokens.register}) — accent ${tokens.accent}` +
        `${tokens.blurPx ? ` · depth-match blur ${tokens.blurPx}px` : ""}` +
        `${tokens.heroes.length ? ` · hero amp ${tokens.heroes.map((h) => h.amp).join("/")} (RMS-coupled)` : ""}`,
    );
  }
  let src = dna
    ? fs.readFileSync(path.join(TEMPLATES, "engine.html"), "utf8")
    : fs.readFileSync(findTemplate(plan.template), "utf8");
  // Bug-1: the canvas/background length = SOURCE clip length, NOT the last-caption time.
  // If plan.duration < source, the bg video ends before the matte → the tail shows only
  // the foreground subject on black. Caption groups keep their own in/out (they may end
  // earlier); only the composition duration follows the source.
  const srcDur = sourceDurationSec(project);
  const renderDur = srcDur && srcDur > 0 ? srcDur : plan.duration;
  if (srcDur && Math.abs(srcDur - (plan.duration || 0)) > 0.2)
    console.log(
      `[compile] canvas duration → source length ${renderDur.toFixed(2)}s (plan.duration=${plan.duration}); captions keep their own times.`,
    );
  const plane = plan.plane || {},
    header = plan.header || {},
    crown = plan.crown || {};
  // LOCKED DNA — the plan can NOT override colour / blend / shadow / filter. Picking a
  // DNA commits to its visual identity; only layout + per-group typography stay
  // agent-authored. If a scene's luminance fights the look, pick a DIFFERENT DNA
  // (bright scene → "ink") — never recolour this one. (plan.cap_color / blend_mode /
  // text_shadow / text_filter are intentionally ignored.)
  const capColor = tokens ? tokens.capColor : "#fff5df";
  const g = (o, k, d) => (o && o[k] != null ? o[k] : d);
  const heroCfg =
    tokens && tokens.heroes && tokens.heroes.length
      ? {
          perLetter: tokens.heroes[0].perLetter,
          glow: tokens.heroes[0].glow,
          echoes: tokens.heroes[0].echoes || [],
        }
      : null;
  const subs = {
    DURATION: `${renderDur}`,
    FPS: `${plan.fps ?? 24}`,
    WIDTH: `${plan.width}`,
    HEIGHT: `${plan.height}`,
    FONT_SCALE: `${plan.font_scale ?? 1.0}`,
    PLANE_TOP: `${g(plane, "top", 0)}`,
    PLANE_LEFT: `${g(plane, "left", "")}`,
    PLANE_RIGHT: `${g(plane, "right", "")}`,
    PLANE_WIDTH: `${g(plane, "width", 0)}`,
    PLANE_HEIGHT: `${g(plane, "height", 0)}`,
    ROTATE_Y: `${g(plane, "rotateY", 0)}`,
    ROTATE_X: `${g(plane, "rotateX", 0)}`,
    HEADER_TOP: `${g(header, "top", 0)}`,
    HEADER_HEIGHT: `${g(header, "height", 0)}`,
    CROWN_TOP: `${g(crown, "top", plan.crown_top ?? g(plan.crown_position || {}, "top", 440))}`,
    CROWN_LEFT: `${g(crown, "left", 0)}`,
    CROWN_RIGHT: `${g(crown, "right", 0)}`,
    CROWN_ALIGN: `${g(crown, "align", "center")}`,
    CROWN_SCALE: `${g(crown, "scale", 1.0)}`,
    BLEND_MODE: tokens ? tokens.blend : "screen",
    CAP_COLOR: capColor,
    TEXT_SHADOW: tokens ? tokens.textShadow : defaultTextShadow(capColor),
    TEXT_FILTER: tokens ? tokens.filterBg : defaultTextFilter(capColor),
    // engine-only placeholders (legacy templates simply don't contain them)
    FONT_FAMILY: tokens ? tokens.fontFamily : "Inter",
    ACCENT: tokens ? tokens.accent : "#e3c06a",
    TEXT_FILTER_BG: tokens ? tokens.filterBg : defaultTextFilter(capColor),
    TEXT_FILTER_FG: tokens ? tokens.filterFg : defaultTextFilter(capColor),
    DNA_CSS: tokens && tokens.dnaCss ? tokens.dnaCss : "",
    MOTION_JSON: JSON.stringify(
      tokens
        ? tokens.motion
        : {
            soft: { y: 8, dur: 0.45, ease: "power2.out" },
            present: { y: 6, scale: 1.04, dur: 0.22, ease: "power3.out" },
          },
    ),
    HEROES_JSON: tokens && tokens.heroes ? JSON.stringify(tokens.heroes) : "[]",
    HERO_JSON: tokens && tokens.hero ? JSON.stringify(tokens.hero) : "null",
    GROUPS_HTML: buildGroupsHtml(plan.groups, plan.planes, heroCfg),
    PLANES_CSS: buildPlanesCss(plan.planes),
    CUSTOM_CSS: buildPerGroupCss(plan.groups, tokens),
    GROUPS_JSON: buildGroupsJson(plan.groups),
  };
  const cg = plan.crown_group;
  if (cg) {
    const layerAttr = cg.layer ? ` data-layer="${cg.layer}"` : "";
    const spans = cg.words
      .map((w, i) => `<span class="w" data-i="${i}">${escBr(w.text)}</span>`)
      .join("\n          ");
    subs.CROWN_HTML = `<div id="${cg.id}" class="cap cap-crown"${layerAttr}>\n          ${spans}\n        </div>`;
    subs.CROWN_JSON = JSON.stringify(
      {
        id: cg.id,
        in: cg.in,
        out: cg.out,
        tone: cg.tone ?? "present",
        words: cg.words.map((w) => ({ text: w.text, start: w.start, end: w.end })),
      },
      null,
      10,
    );
  } else {
    subs.CROWN_HTML = "";
    subs.CROWN_JSON = "null";
  }

  for (const [k, v] of Object.entries(subs)) src = src.split(`{{${k}}}`).join(v);
  fs.writeFileSync(path.join(project, "index.html"), src);
  console.log(
    `[compile] ${dna ? `dna=${dna.name} (engine)` : `template=${plan.template}`} → ${path.join(project, "index.html")}`,
  );

  // per-group FG → index_fg.html (same strategy as the Python version)
  const fgGroups = plan.groups.filter((x) => x.layer === "fg");
  if (fgGroups.length || (plan.crown_group || {}).layer === "fg") {
    const fgCss = `
    <style>
      html.fg-only body { background: #000 !important; }
      html.fg-only #fg-cover { position: absolute; inset: 0; background: #000; z-index: 1; pointer-events: none; }
      /* visibility (not display:none): GSAP's transform parser needs a laid-out box at
         construction time — display:none here crashed timeline build in headless preview */
      html.fg-only .cap:not([data-layer="fg"]) { visibility: hidden !important; }
    </style>
`;
    let fg = src.replace("<html ", '<html class="fg-only" ');
    fg = fg.replace("</head>", fgCss + "  </head>");
    fg = fg.replace('<div id="stage"', '<div id="fg-cover"></div>\n      <div id="stage"');
    fs.writeFileSync(path.join(project, "index_fg.html"), fg);
    console.log(
      `[compile] ${fgGroups.length} fg group(s) → ${path.join(project, "index_fg.html")}`,
    );
  }
}
main();
scripts/make-theme.cjs
#!/usr/bin/env node
/*
 * make-theme.cjs — THEME mode compiler (third compiler, beside standard/cinematic).
 *
 * A theme DNA (themes/<name>.json) is a complete visual constitution composed
 * from two registries implemented HERE, once, for all DNAs:
 *
 *   BODY PARADIGMS  how the transcript surface lives
 *     rail      lower-third lines that replace each other (fg alpha)
 *     panel     console log accumulating in a docked glass panel (fg alpha)
 *     poem      lines accumulate in open space, stanza by stanza (fg alpha)
 *     takeover  every beat owns the full frame as a hard-cut card (bg)
 *
 *   HERO SETPIECES  the climax choreography
 *     detonation  sliced stencil stamp: shear-in, snap, squash, cool, furniture
 *     decode      slot-machine glyph reels lock left→right, CRT-off exit
 *     drawon      single-line-font stroke-order writing (sequential dash reveal)
 *     assembly    inline word assembles from inbound particles (poem apex)
 *     colorflip   inline accent-color crush card (takeover apex)
 *
 * plus LINKAGES (theme interactions: redact-until-hero, disperse-on-last-word,
 * corrupt-on-last-word) and a PLATE budget compiled to _postfx.sh.
 *
 * Inputs : <project>/theme.json  { dna, lines:[[w,...],...], hero:{match,text?},
 *           minors?:[...], width?, height?, fps? }
 *          <project>/transcript.json (verbatim, word timings)
 *          themes/<dna>.json
 * Outputs: index.html (bg: plate reaction + embedded setpiece)
 *          rail.html  (fg alpha: body paradigm + front fx)   [unless body.layer=bg]
 *          _postfx.sh (plate reaction: punch/shake/grain after composite)
 *
 * Render: scripts/render-theme.sh <project>   (render-and-composite + postfx)
 *
 * Determinism contract: paused GSAP on window.__timelines["main"], seeded PRNG
 * only (mulberry32), set-chains / pure-f(t) keyframes, no Math.random/Date.now.
 */
const fs = require("fs");
const path = require("path");
const { execFileSync } = require("child_process");

const PROJECT = path.resolve(process.argv[2] || ".");
const SKILL = path.resolve(__dirname, "..");
const F = 1 / 24;

// ---------- load inputs ----------
const theme = JSON.parse(fs.readFileSync(path.join(PROJECT, "theme.json"), "utf8"));
const dna = JSON.parse(fs.readFileSync(path.join(SKILL, "themes", theme.dna + ".json"), "utf8"));
const transcript = JSON.parse(fs.readFileSync(path.join(PROJECT, "transcript.json"), "utf8"));
const W = theme.width || 1280,
  H = theme.height || 720;

// FPS: matte.fps is AUTHORITATIVE (written by matte.cjs at the source's native
// rate; the matte overlay + postfx zoompan must run at this rate or the output
// retimes). theme.fps only overrides when matte.fps is absent.
function readMatteFps() {
  try {
    const v = parseInt(
      fs.readFileSync(path.join(PROJECT, "matte.fps"), "utf8").replace(/\D/g, ""),
      10,
    );
    return v > 0 ? v : null;
  } catch {
    return null;
  }
}
const FPS = readMatteFps() || theme.fps || 24;

function probeDuration() {
  try {
    const out = execFileSync(
      "ffprobe",
      [
        "-v",
        "error",
        "-show_entries",
        "format=duration",
        "-of",
        "csv=p=0",
        path.join(PROJECT, "source.mp4"),
      ],
      { encoding: "utf8" },
    );
    return parseFloat(out.trim());
  } catch {
    const n = fs.readdirSync(path.join(PROJECT, "frames_fg")).length;
    return n / FPS;
  }
}
const DUR = +(theme.duration || probeDuration()).toFixed(6);

// ---------- scene awareness (safe-zones.json): inherit the typography system ----------
// The existing modes' scene intelligence applies here too: hero placement comes
// from the measured occlusion bands + subject anchor, panel/poem dock on the
// clearer side, and hero font size is width-fit to the actual word. Explicit
// theme.json values > scene auto > DNA fallback.
function readJson2(p) {
  try {
    return JSON.parse(fs.readFileSync(p, "utf8"));
  } catch {
    return null;
  }
}
const sz = readJson2(path.join(PROJECT, "safe-zones.json"));

// per-setpiece preferred band (topPct of the hero's TOP edge)
const SETPIECE_PREF_TOP = {
  detonation: 28,
  decode: 26,
  drawon: 18,
  cpslam: 27,
  settle: 27,
  flapboard: 25,
  ledwipe: 52,
  vhsosd: 26,
  bossintro: 30,
  rubberstamp: 29,
  lasercage: 33,
  boltstrike: 24,
  holoboot: 28,
  biobloom: 33,
  silkribbon: 18,
  scopetrace: 25,
  papermat: 26,
  centerfold: 21,
  chalkwrite: 44,
  spraytag: 26,
  brushwrite: 19,
  inkbloom: 25,
  ransomnote: 31,
};

function sceneHeroXY(setpiece, fontPx) {
  const exX = theme.hero && theme.hero.x,
    exY = theme.hero && theme.hero.y;
  let x = exX ?? null,
    y = exY ?? null;
  if (x == null && sz && sz.heroAnchor && sz.heroAnchor.centerXPct != null)
    x = (W * sz.heroAnchor.centerXPct) / 100;
  if (x == null) x = dna.hero.x ?? W / 2;
  if (y == null && sz && sz.heroBands && Array.isArray(sz.heroBands.profile)) {
    const pref = SETPIECE_PREF_TOP[setpiece] ?? 28;
    const ok = sz.heroBands.profile.filter((b) => b.occPct >= 18 && b.occPct <= 55);
    const pool = ok.length ? ok : sz.heroBands.profile;
    const best = pool.reduce((a, b) =>
      Math.abs(b.topPct - pref) < Math.abs(a.topPct - pref) ? b : a,
    );
    y = (H * best.topPct) / 100 + fontPx * 0.55; // band top → glyph center
  }
  if (y == null) y = dna.hero.y ?? H * 0.37;
  return { x: Math.round(x), y: Math.round(y) };
}

// width-fit: hero font size from the actual word (never overflow, never timid)
function fitHeroPx(text, basePx, emPerChar, maxFrac) {
  const est = (px) => text.length * emPerChar * px;
  let px = basePx;
  const maxW = W * (maxFrac || 0.92);
  if (est(px) > maxW) px = Math.floor(maxW / (text.length * emPerChar));
  // short words: allow growth toward the poster fill, capped — except in the
  // calm register, where the DNA base size IS the ceiling (quiet briefs got a
  // 188px hero from this growth in the cold-start E2E)
  else if (est(px) < W * 0.55 && dna.register !== "calm")
    px = Math.min(Math.floor(maxW / (text.length * emPerChar)), Math.round(basePx * 1.25));
  return Math.max(64, px);
}

// clearer side for docked furniture (panel / poem): away from the subject
const CLEARER = (sz && sz.subject && sz.subject.clearerSide) || "left";

// ---------- measured glyph metrics (assets/fonts/char-widths.json) ----------
// Per-char advance widths (em) + fontBoundingBox ascent/descent for every
// bundled family, measured once in headless Chrome over the SAME woff2 faces
// the renderer embeds. Lets a paradigm BAKE pixel-exact word geometry at
// compile time (beam aim, flare centers) so font-load timing in the render
// browser can never skew it (kerning ignored: ≤3px/word vs ground truth).
const CHARW = (() => {
  try {
    return JSON.parse(fs.readFileSync(path.join(SKILL, "assets/fonts/char-widths.json"), "utf8"));
  } catch {
    return null;
  }
})();
function famMetrics(fam) {
  return (CHARW && CHARW[fam]) || null;
}
function wordPx(text, fam, px, lsEm) {
  const m = famMetrics(fam);
  if (!m) console.error(`[make-theme] WARN no measured widths for "${fam}" -> 0.6em/char estimate`);
  const adv = m
    ? [...text].reduce((a, c) => a + (m.widths[c] ?? m.widths["0"] ?? 0.6), 0)
    : text.length * 0.6;
  return adv * px + (lsEm || 0) * px * text.length;
}

// ---------- word timing: sequential matcher (verbatim completeness) ----------
const norm = (s) => s.toLowerCase().replace(/[^\p{L}\p{N}']/gu, "");
const tWords = transcript.words.map((w) => ({
  text: w.text ?? w.word,
  start: w.start,
  end: w.end,
  used: false,
}));
let cursor = 0;
function takeWord(authored) {
  const target = norm(authored);
  for (let i = cursor; i < Math.min(cursor + 3, tWords.length); i++) {
    if (norm(tWords[i].text) === target) {
      if (tWords[i].used)
        throw new Error(
          `[make-theme] "${authored}" is already claimed by hero.match — embed setpieces own the hero word: leave it OUT of lines (rail↔climax hand-off)`,
        );
      tWords[i].used = true;
      cursor = i + 1;
      return tWords[i];
    }
  }
  throw new Error(
    `[make-theme] cannot match authored word "${authored}" near transcript position ${cursor} ("${(tWords[cursor] || {}).text}") — lines must follow transcript order verbatim`,
  );
}

// hero phrase: located independently in the transcript (it may sit between lines)
function findPhrase(phrase) {
  const parts = phrase.trim().split(/\s+/).map(norm);
  for (let i = 0; i <= tWords.length - parts.length; i++) {
    if (parts.every((p, k) => norm(tWords[i + k].text) === p)) {
      return {
        start: tWords[i].start,
        end: tWords[i + parts.length - 1].end,
        idx: i,
        len: parts.length,
      };
    }
  }
  throw new Error(`[make-theme] hero phrase "${phrase}" not found in transcript`);
}
const heroInline = !!dna.hero.inline;
// theme.hero is OPTIONAL: a heroless theme runs pure-body (the right authoring
// for quiet briefs — "安静/每个词都要读到" wants no setpiece at all).
const HEROLESS = !theme.hero;
if (HEROLESS) theme.hero = {};
const hero = theme.hero.match ? findPhrase(theme.hero.match) : null;
if (!hero && !HEROLESS)
  throw new Error(
    "[make-theme] theme.json requires hero:{match} (omit hero entirely for a quiet pure-body run)",
  );
const heroText = (theme.hero.text || theme.hero.match || "").toUpperCase();
const heroDisplay = theme.hero.text || theme.hero.match; // case preserved for drawon/assembly

// mark hero transcript words as consumed when the setpiece is EMBED (not inline):
// the rail/panel/poem must NOT contain them (rail↔climax hand-off), except panel
// with redact linkage (panel shows them redacted — author includes them).
const redactLinkage = (dna.linkages || []).includes("redact-until-hero");
if (!heroInline && !HEROLESS && !redactLinkage) {
  for (let k = 0; k < hero.len; k++) tWords[hero.idx + k].used = true;
}

// resolve lines: every entry gets word objects with timings + role flags
const minors = new Set((theme.minors || []).map(norm));
const LINES = (theme.lines || []).map((arr, li) => {
  const words = arr.map((a) => {
    const tw = takeWord(a);
    return {
      display: a,
      start: tw.start,
      end: tw.end,
      minor: minors.has(norm(a)),
      isHero:
        !!hero &&
        norm(a) === norm((theme.hero.match || "").split(/\s+/)[0]) &&
        tw.start >= hero.start - 0.01 &&
        tw.start <= hero.end + 0.01,
    };
  });
  return { id: li, words };
});
// completeness gate
const unused = tWords.filter((w) => !w.used);
if (unused.length)
  throw new Error(
    `[make-theme] transcript words not covered by lines/hero: ${unused.map((w) => w.text).join(" ")}`,
  );

// per-line windows
LINES.forEach((L, i) => {
  L.in = L.words[0].start - 0.02;
  L.out = i + 1 < LINES.length ? LINES[i + 1].words[0].start - 0.02 : DUR - 0.1;
});
const LASTWORD = LINES[LINES.length - 1].words[LINES[LINES.length - 1].words.length - 1];

// hero window: onset → bounded hold (maxHold; 0 = to clip end — neonsign's
// lit-sign design). Unbounded default holds turned short clips into wallpaper.
const heroIn = hero ? hero.start : -999;
const MAXHOLD = dna.hero.maxHold ?? 3.2;
const heroOut = hero
  ? Math.min(DUR - 0.06, theme.hero.out ?? (MAXHOLD > 0 ? heroIn + MAXHOLD : DUR - 0.1))
  : -999;

// ---------- hero geometry: scene-aware position + width-fit size (computed ONCE,
// shared by the setpiece and the front fx so flash/rings/sparks stay centered) ----
const HG = { x: dna.hero.x ?? W / 2, y: dna.hero.y ?? H * 0.37, fontPx: dna.hero.fontPx || 178 };
if (!heroInline && !HEROLESS) {
  if (dna.hero.setpiece === "detonation") {
    HG.fontPx = fitHeroPx(heroText, dna.hero.fontPx || 178, 0.56, 0.92);
    Object.assign(HG, sceneHeroXY("detonation", HG.fontPx));
    HG.halfW = (heroText.length * 0.56 * HG.fontPx) / 2;
  } else if (dna.hero.setpiece === "decode") {
    const units = [...heroText].reduce((a, c) => a + (c === " " ? 0.38 : 0.82), 0);
    HG.fontPx = Math.min(dna.hero.fontPx || 112, Math.floor((W * 0.94) / units));
    Object.assign(HG, sceneHeroXY("decode", HG.fontPx));
    HG.halfW = (units * HG.fontPx) / 2;
  } else if (dna.hero.setpiece === "drawon") {
    Object.assign(HG, sceneHeroXY("drawon", 130));
    HG.halfW = Math.min(dna.hero.params.targetWidth || 640, W - 200) / 2;
  } else if (dna.hero.setpiece === "cpslam") {
    HG.fontPx = fitHeroPx(heroText, dna.hero.fontPx || 168, 0.56, 0.9);
    Object.assign(HG, sceneHeroXY("cpslam", HG.fontPx));
    HG.halfW = (heroText.length * 0.56 * HG.fontPx) / 2;
  } else if (dna.hero.setpiece === "settle") {
    HG.fontPx = fitHeroPx(heroText, dna.hero.fontPx || 150, 0.58, 0.9);
    Object.assign(HG, sceneHeroXY("settle", HG.fontPx));
    HG.halfW = (heroText.length * 0.58 * HG.fontPx) / 2;
  } else if (dna.hero.setpiece === "flapboard") {
    // one flap tile per char; tile geometry derives from fontPx (demo ratio
    // 100px tile @ 138px Anton), width-fit shrinks both together
    const gap = dna.hero.params.gap || 7;
    const n = heroText.length;
    let fpx = dna.hero.fontPx || 138;
    let tw = Math.round(fpx * 0.725);
    const fitW = W * 0.94 - 40 - (n - 1) * gap;
    if (n * tw > fitW) {
      tw = Math.floor(fitW / n);
      fpx = Math.round(tw / 0.725);
    }
    HG.fontPx = fpx;
    Object.assign(HG, sceneHeroXY("flapboard", fpx));
    HG.tileW = tw;
    HG.tileH = Math.round(tw * 1.48);
    HG.halfW = (n * tw + (n - 1) * gap + 40) / 2;
  } else if (dna.hero.setpiece === "ledwipe") {
    // LED transit panel: the word lights inside a fixed departure-board panel
    // (demo 1080×232 @ 120px VT323 ≈ 0.50em/char incl tracking) — width-fit
    // shrinks the type, never the panel; panel height derives from the type
    const panelW = Math.min(dna.hero.params.panelW || 1080, W - 120);
    const units = heroText.length * 0.5;
    HG.fontPx = Math.min(dna.hero.fontPx || 120, Math.floor((panelW - 80) / units));
    Object.assign(HG, sceneHeroXY("ledwipe", HG.fontPx));
    HG.panelW = panelW;
    HG.panelH = 44 + Math.round(HG.fontPx * 1.42) + 18;
    HG.halfW = panelW / 2;
  } else if (dna.hero.setpiece === "vhsosd") {
    // camcorder OSD readout (demo: VT323 150px ≈ 0.47em/char incl 0.02em tracking)
    HG.fontPx = fitHeroPx(heroText, dna.hero.fontPx || 150, 0.47, 0.92);
    Object.assign(HG, sceneHeroXY("vhsosd", HG.fontPx));
    HG.halfW = (heroText.length * 0.47 * HG.fontPx) / 2;
  } else if (dna.hero.setpiece === "bossintro") {
    // 8-bit boss word (demo: Press Start 2P 84px ≈ 1.0em/char); the pixel-block
    // matrix derives from the word rect (cell 0.7×/0.5× fontPx, ~50px side pad)
    HG.fontPx = fitHeroPx(heroText, dna.hero.fontPx || 84, 1.0, 0.92);
    Object.assign(HG, sceneHeroXY("bossintro", HG.fontPx));
    HG.halfW = (heroText.length * 1.0 * HG.fontPx) / 2;
  } else if (dna.hero.setpiece === "rubberstamp") {
    // bordered rubber-stamp face (demo: Special Elite 120px REMARKABLE ≈
    // 0.60em/char + 0.015em tracking); the face adds padding + border that
    // scale with fontPx (80px + 12px at the demo size)
    HG.fontPx = fitHeroPx(heroText, dna.hero.fontPx || 120, 0.615, 0.86);
    Object.assign(HG, sceneHeroXY("rubberstamp", HG.fontPx));
    HG.halfW = (heroText.length * 0.615 * HG.fontPx + (92 * HG.fontPx) / 120) / 2;
  } else if (dna.hero.setpiece === "lasercage") {
    // beam-cage word (demo: Audiowide 100px): exact em width from the measured
    // char table — the converging beams, cage lines and glow stack all derive
    // from the word rect, so the fit must not guess
    const em1 = wordPx(heroText, dna.fonts.hero, 1, 0.02) || heroText.length * 0.8;
    HG.fontPx = Math.min(dna.hero.fontPx || 100, Math.floor((W * 0.9) / em1));
    Object.assign(HG, sceneHeroXY("lasercage", HG.fontPx));
    HG.halfW = (em1 * HG.fontPx) / 2;
  } else if (dna.hero.setpiece === "boltstrike") {
    // storm slam (demo: Anton 145px REMARKABLE): exact em width from the
    // measured char table — the bolt offsets, word scrim and ozone glow all
    // derive from the word rect (demo rect: 720×145 → halfW 360)
    const em1 = wordPx(heroText, dna.fonts.hero, 1, 0.012) || heroText.length * 0.56;
    HG.fontPx = Math.min(dna.hero.fontPx || 145, Math.floor((W * 0.92) / em1));
    Object.assign(HG, sceneHeroXY("boltstrike", HG.fontPx));
    HG.halfW = (em1 * HG.fontPx) / 2;
  } else if (dna.hero.setpiece === "holoboot") {
    // volumetric projection word (demo: Orbitron 700 118px, letter-spacing 0):
    // exact em width from the measured char table — the projection cone, seed
    // beam, emitter dot and scrim all derive from the word rect
    const em1 = wordPx(heroText, dna.fonts.hero, 1, 0) || heroText.length * 0.8;
    HG.fontPx = Math.min(dna.hero.fontPx || 118, Math.floor((W * 0.9) / em1));
    Object.assign(HG, sceneHeroXY("holoboot", HG.fontPx));
    HG.halfW = (em1 * HG.fontPx) / 2;
  } else if (dna.hero.setpiece === "biobloom") {
    // abyss bloom word (demo: Fredoka 700 120px RECOVER, 0.01em tracking):
    // exact em width from the measured char table — the tendril anchors,
    // pocket scrim, pre-glow and spill all derive from the word rect
    const em1 = wordPx(heroText, dna.fonts.hero, 1, 0.01) || heroText.length * 0.66;
    HG.fontPx = Math.min(dna.hero.fontPx || 120, Math.floor((W * 0.9) / em1));
    Object.assign(HG, sceneHeroXY("biobloom", HG.fontPx));
    HG.halfW = (em1 * HG.fontPx) / 2;
  } else if (dna.hero.setpiece === "silkribbon") {
    // silk ribbon draw-on (demo: HersheyScript "recover" w=880 on a 1010×280
    // canvas, ink center ≈ canvas center): the canvas, aurora-gradient span
    // and spill all derive from targetWidth; 115 ≈ the ribbon's ink height
    // (sky band top → ribbon center, same registration as the demo)
    Object.assign(HG, sceneHeroXY("silkribbon", 115));
    HG.ribbonW = Math.min(dna.hero.params.targetWidth || 880, W - 240);
    HG.halfW = (HG.ribbonW + 130) / 2;
  } else if (dna.hero.setpiece === "scopetrace") {
    // phosphor trace word (demo: HersheyScript "pixel size" ≈705px ink, ink
    // center ≈ baseline − 28): the surge column, flatline flash, spill and
    // write window all derive from targetWidth; 115 ≈ the trace's ink height
    Object.assign(HG, sceneHeroXY("scopetrace", 115));
    HG.traceW = Math.min(dna.hero.params.targetWidth || 720, W - 200);
    HG.halfW = (HG.traceW + 50) / 2;
  } else if (dna.hero.setpiece === "papermat") {
    // torn-paper letter chips glued on a kraft mat (demo: Anton 130px ink on
    // 118×170 chips, pitch tileW+14, mat N*pitch+80 wide × 240 tall): chip
    // geometry derives from fontPx (demo ratios), width-fit shrinks both
    const n = heroText.length;
    let fpx = dna.hero.fontPx || 130;
    let tw = Math.round(fpx * 0.91);
    const fitW = W * 0.94 - 80 - n * 14;
    if (n * tw > fitW) {
      tw = Math.floor(fitW / n);
      fpx = Math.round(tw / 0.91);
    }
    HG.fontPx = fpx;
    Object.assign(HG, sceneHeroXY("papermat", fpx));
    HG.tileW = tw;
    HG.tileH = Math.round(fpx * 1.31);
    HG.pitch = tw + 14;
    HG.matW = n * HG.pitch + 80;
    HG.matH = Math.round(fpx * 1.846);
    HG.halfW = HG.matW / 2;
  } else if (dna.hero.setpiece === "centerfold") {
    // pop-up book centerfold (demo: Baloo 2 800 120px STARS over a 12-point
    // paper starburst, outer R 220 ≈ 0.59×wordW, box 620×560 = 2R+180 × 2R+fontPx):
    // the bursts, blob shadow, struts and the confetti hinge all derive from
    // the word rect; width-fit shrinks the type, the burst follows the word
    // but is capped so the unfold stays inside the frame
    const em1 = wordPx(heroText, dna.fonts.hero, 1, 0.02) || heroText.length * 0.62;
    HG.fontPx = Math.min(dna.hero.fontPx || 120, Math.floor((W * 0.5) / em1));
    Object.assign(HG, sceneHeroXY("centerfold", HG.fontPx));
    HG.wordW = Math.round(em1 * HG.fontPx);
    HG.burstR = Math.round(Math.min(Math.max(1.83 * HG.fontPx, 0.59 * HG.wordW), H * 0.42));
    HG.boxW = Math.max(2 * HG.burstR + 180, HG.wordW + 24);
    HG.boxH = 2 * HG.burstR + HG.fontPx;
    HG.halfW = HG.boxW / 2;
  } else if (dna.hero.setpiece === "chalkwrite") {
    // hand-written chalk word (demo: HersheyScript "remarkable" ≈860px ink,
    // ink 130 tall, ink CENTER on HG.y): the writing starts at the board's
    // LEFT margin — a hand writes from the left, never centered on the
    // subject — so x derives from the body strip, not the hero anchor; the
    // stroke path is parsed at compile time inside the setpiece and the
    // board rect / underlines / fg dust clap all register on the measured
    // ink box. 130 ≈ the chalk ink height (band top → ink center).
    const stripW9 = (dna.body.strip && dna.body.strip.w) || 1200;
    const SX9 = (dna.body.strip && dna.body.strip.x) ?? Math.round((W - stripW9) / 2);
    HG.chalkW = Math.min(dna.hero.params.targetWidth || 860, stripW9 - 28);
    HG.fontPx = 130;
    Object.assign(HG, sceneHeroXY("chalkwrite", 130));
    if ((theme.hero && theme.hero.x) == null) HG.x = SX9 + 12 + Math.round(HG.chalkW / 2);
    HG.halfW = Math.round(HG.chalkW / 2);
  } else if (dna.hero.setpiece === "spraytag") {
    // spray-painted tag (demo: HersheyScript "recover" w=1000 → ink ≈1010×120,
    // ink center (646,252)): the tag CENTERS on the hero anchor in the sky
    // band — crossing the subject silhouette is the point; the stroke path is
    // parsed at compile time inside the setpiece, which registers the
    // pen-finish point on HG for the fg paint splat (fx.paintsplat). 120 ≈ the
    // tag's ink height (band top → ink center); halfW adds the halo overspray.
    HG.sprayW = Math.min(dna.hero.params.targetWidth || 1000, W - 140);
    HG.fontPx = 120;
    Object.assign(HG, sceneHeroXY("spraytag", 120));
    HG.halfW = Math.round(HG.sprayW / 2) + 30;
  } else if (dna.hero.setpiece === "brushwrite") {
    // sumi-e gesture (demo: HersheyScript "remarkable" w=760 → ink ≈760×115,
    // ink center (863,202)): the gesture CENTERS on the hero anchor in the sky
    // band — crossing the subject silhouette is the point; the stroke path is
    // parsed at compile time inside the setpiece, which registers the
    // pen-START point on HG for the fg ink flecks (fx.brushflecks). 120 ≈ the
    // gesture's ink height (band top → ink center); halfW adds spatter margin.
    HG.brushW = Math.min(dna.hero.params.targetWidth || 760, W - 240);
    HG.fontPx = 120;
    Object.assign(HG, sceneHeroXY("brushwrite", 120));
    HG.halfW = Math.round(HG.brushW / 2) + 40;
  } else if (dna.hero.setpiece === "inkbloom") {
    // sumi drop in still water (demo: Shippori Mincho 700 132px "pixel size.",
    // 0.02em tracking, case preserved): exact em width from the measured char
    // table — the blob stack, ripple rings and wisp anchors all derive from
    // the word rect (kx/ky scale units inside the setpiece)
    const em1 = wordPx(heroDisplay, dna.fonts.hero, 1, 0.02) || heroDisplay.length * 0.5;
    HG.fontPx = Math.min(dna.hero.fontPx || 132, Math.floor((W * 0.9) / em1));
    Object.assign(HG, sceneHeroXY("inkbloom", HG.fontPx));
    HG.halfW = (em1 * HG.fontPx) / 2;
  } else if (dna.hero.setpiece === "ransomnote") {
    // ransom-note letter chips (demo: 10 chips, 4 recipes/fonts cycled, base
    // 130px with seeded ±13% sizes): width-fit uses the measured advance of
    // each glyph IN ITS OWN recipe font + 0.20em chip padding + 6px margins,
    // so the crooked collage never overflows the frame
    const fams4 = [dna.fonts.body, dna.fonts.dark, dna.fonts.news, dna.fonts.accent];
    let k4 = 0;
    const em1 = [...heroText].reduce((a, c) => {
      if (c === " ") return a + 0.3;
      const fam = fams4[k4++ % 4];
      return a + (famMetrics(fam) ? wordPx(c, fam, 1, 0) : 0.62) + 0.2;
    }, 0);
    const n = heroText.length;
    HG.fontPx = Math.min(dna.hero.fontPx || 130, Math.floor((W * 0.92 - n * 6) / em1));
    Object.assign(HG, sceneHeroXY("ransomnote", HG.fontPx));
    HG.halfW = (em1 * HG.fontPx + n * 6) / 2;
  }
  // keep the word on frame (when wider than the frame, CENTER it — an inverted
  // Math.max/Math.min clamp would silently pin to the lower bound off-center)
  if (HG.halfW)
    HG.x =
      2 * HG.halfW + 24 > W ? W / 2 : Math.max(HG.halfW + 12, Math.min(W - HG.halfW - 12, HG.x));
  HG.y = Math.max(HG.fontPx * 0.55 + 8, Math.min(H * 0.62, HG.y));
}

// ---------- shared emit helpers ----------
const MULBERRY = `function mulberry32(a){return function(){a|=0;a=(a+0x6D2B79F5)|0;let t=Math.imul(a^(a>>>15),1|a);t=(t+Math.imul(t^(t>>>7),61|t))^t;return((t^(t>>>14))>>>0)/4294967296;}}`;

// bundled @font-face subset for this DNA's families — the renderer only
// auto-supplies its canonical fonts; anything else silently falls back unless
// the page ships a local @font-face (same determinism contract as Standard).
const FONT_FACES = (() => {
  let css = "";
  try {
    css = fs.readFileSync(path.join(SKILL, "modes/standard/fonts/fonts.css"), "utf8");
  } catch {
    return null;
  }
  return css
    .split("@font-face")
    .slice(1)
    .map((b) => "@font-face" + b.slice(0, b.indexOf("}") + 1));
})();
// per-page: embed only families this page's markup actually references
function fontCssFor(pageParts) {
  if (!FONT_FACES) return "";
  const fams = [...new Set(Object.values(dna.fonts || {}))].filter((f) => !/^Hiragino/.test(f));
  const out = [];
  for (const fam of fams) {
    if (!pageParts.includes(fam)) continue;
    // quote-agnostic: fonts.css has shipped with both 'single' and "double"
    // quoted family names (formatters flip them); a literal match silently
    // skipped EVERY embed and warned on bundled fonts.
    const famRe = new RegExp(
      `font-family\\s*:\\s*['"]` + fam.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + `['"]`,
    );
    const hits = FONT_FACES.filter((b) => famRe.test(b));
    if (!hits.length)
      console.error(
        `[make-theme] WARN font "${fam}" not in bundled fonts.css -> renderer fallback risk`,
      );
    out.push(...hits);
  }
  return out.join("\n");
}
const GSAP = `<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js" integrity="sha384-sG0Hv1tP1lZCk9KQmrIbY/XNwi+OY84GQqhMscbnsoBFqAz8KNCil1kvfL3Hbbk2" crossorigin="anonymous"></script>`;

function bgSkeleton(stageHtml, css, js) {
  const FONT_CSS = fontCssFor(stageHtml + css);
  return `<!doctype html>
<!-- generated by make-theme.cjs — theme DNA "${dna.name}" (bg layer) -->
<html lang="en">
<head>
<meta charset="utf-8">
${GSAP}
<style>
${FONT_CSS}
  html, body { margin:0; padding:0; width:${W}px; height:${H}px; overflow:hidden; background:#000; }
  #root { position:relative; width:${W}px; height:${H}px; }
  #a-roll { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:1; }
  #stage { position:absolute; inset:0; z-index:2; }
${css}
</style>
</head>
<body>
  <div id="root" data-composition-id="main" data-start="0" data-duration="${DUR}"
       data-width="${W}" data-height="${H}">
    <video id="a-roll" src="source.mp4" muted playsinline
           data-duration="${DUR}" data-track-index="0"></video>
    <div id="stage">
${stageHtml}
    </div>
    <audio id="a-roll-audio" src="source.mp4" data-start="0" data-duration="${DUR}"
           data-track-index="3" data-volume="1"></audio>
  </div>
<script>
  window.__timelines = window.__timelines || {};
  const tl = gsap.timeline({ paused: true });
  const F = ${F};
  ${MULBERRY}
${js}
  tl.seek(0);
  window.__timelines["main"] = tl;
</script>
</body>
</html>`;
}

function fgSkeleton(stageHtml, css, js) {
  const FONT_CSS = fontCssFor(stageHtml + css);
  return `<!doctype html>
<!-- generated by make-theme.cjs — theme DNA "${dna.name}" (fg alpha layer) -->
<html lang="en">
<head>
<meta charset="utf-8">
${GSAP}
<style>
${FONT_CSS}
  html, body { margin:0; padding:0; width:${W}px; height:${H}px; overflow:hidden; background:transparent; }
  #root { position:relative; width:${W}px; height:${H}px; }
  #stage { position:absolute; inset:0; }
${css}
</style>
</head>
<body>
  <div id="root" data-composition-id="main" data-start="0" data-duration="${DUR}"
       data-width="${W}" data-height="${H}">
    <div id="stage">
${stageHtml}
    </div>
  </div>
<script>
  window.__timelines = window.__timelines || {};
  const tl = gsap.timeline({ paused: true });
  const F = ${F};
  ${MULBERRY}
${js}
  tl.seek(0);
  window.__timelines["main"] = tl;
</script>
</body>
</html>`;
}

const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;");
const J = JSON.stringify;

/* =====================================================================
 * BODY PARADIGMS — each returns { css, html, js } for the fg (or bg) file
 * ===================================================================== */

function paradigmRail() {
  const b = dna.body;
  const lineData = LINES.map((L) => ({
    id: "r" + L.id,
    in: +L.in.toFixed(3),
    out: +L.out.toFixed(3),
    words: L.words.map((w) => [w.display, +w.start.toFixed(3), w.minor ? 1 : 0]),
  }));
  const css = `
  .rail { position:absolute; left:${W / 2}px; top:${H - b.bottomPx}px; opacity:0; white-space:nowrap;
          font-family:'${dna.fonts.body}', sans-serif; font-size:${b.fontPx}px; line-height:1;
          letter-spacing:${b.letterSpacing || "0.02em"}; color:${dna.palette.body};
          ${b.textTransform ? "text-transform:" + b.textTransform + ";" : ""}
          text-shadow: ${b.glow || "0 3px 14px rgba(0,0,0,0.65), 0 1px 3px rgba(0,0,0,0.5)"}; }
  .rail .w { display:inline-block; opacity:0; margin:0 0.14em; }
  .rail .w.minor { font-size:${Math.round(b.fontPx * (b.minorScale || 1.4))}px; }
  .rail .w.em { color:${dna.palette.em || dna.palette.accent}; font-weight:700; }
  ${
    b.rule
      ? `#rrule { position:absolute; left:${W / 2}px; top:${H - b.bottomPx + 38}px; width:0; height:2px;
          background:${b.rule.color}; opacity:0.5; transform:translateX(-50%);
          box-shadow: 0 0 12px ${dna.palette.accent}; }`
      : ""
  }`;
  const html =
    lineData.map((L) => `      <div class="rail" id="${L.id}"></div>`).join("\n") +
    (b.rule ? `\n      <div id="rrule"></div>` : "");
  const stamp = b.entrance === "stamp";
  const boot = b.entrance === "bootflick";
  const fade = b.entrance === "fade";
  const js = `
  // ---- body paradigm: RAIL (${b.entrance} in / ${b.exit} out) ----
  const HOT = ${J(dna.palette.hot || dna.palette.accent)}, BONE = ${J(dna.palette.body)};
  const RAIL = ${J(lineData)};
  const rrnd = mulberry32(2026);
  RAIL.forEach((L) => {
    const line = document.getElementById(L.id);
    L.words.forEach(([txt,,minor]) => {
      const s = document.createElement("span");
      s.className = "w" + (minor ? (${J(stamp)} || ${J(boot)} ? " minor" : " em") : "");
      s.textContent = txt; line.appendChild(s);
    });
    gsap.set(line, { xPercent: -50, yPercent: -100 });
    tl.set(line, { opacity: 1 }, L.in);
    const XO = L.out - ${b.exit === "drop" ? "0.18" : "0.17"};   // exit start (single source)
    let lastB = -1;                                              // line-y bounce ownership guard
    L.words.forEach(([txt, st, minor], wi) => {
      const el = line.children[wi];
${
  boot
    ? `      // BOOTFLICK: corrupted-cyan boot 1-2f -> settles to UI color
      tl.set(el, { opacity: 0.6, color: "#00F0FF", x: 2 }, st);
      if (rrnd() < 0.45) { tl.set(el, { opacity: 0.15, x: -2 }, st + F); tl.set(el, { opacity: 1, color: BONE, x: 0 }, st + 2 * F); }
      else { tl.set(el, { opacity: 1, color: BONE, x: 0 }, st + F); }
      if (minor) {
        tl.fromTo(el, { scale: 1.18 }, { scale: 1, duration: 0.25, ease: "power2.out" }, st + F);
        tl.set(el, { textShadow: "2px 0 0 #00F0FF, -2px 0 0 #FF003C, 0 2px 10px rgba(0,0,0,0.75)" }, st + 2 * F);
      }`
    : fade
      ? `      // FADE: 2-frame fade-up — the word simply reads (anchor register)
      tl.fromTo(el, { opacity: 0, y: 4 }, { opacity: 1, y: 0, duration: 0.12, ease: "power2.out" }, st);
      if (minor) tl.fromTo(el, { scale: 1.07 }, { scale: 1, duration: 0.3, ease: "power2.out" }, st);`
      : stamp
        ? `      // STAMP: 1f appear oversized+hot, crush, recoil, cool to bone
      tl.set(el, { opacity: 1, scale: minor ? 2.1 : 1.28, color: HOT, transformOrigin: "50% 80%" }, st);
      tl.to(el, { scale: 1, duration: 0.11, ease: "power3.in" }, st + 0.01);
      tl.set(el, { scaleX: 1.05, scaleY: 0.95 }, st + 0.12);
      tl.to(el, { scaleX: 1, scaleY: 1, duration: 0.22, ease: "elastic.out(1, 0.45)" }, st + 0.16);
      tl.to(el, { color: minor ? ${J(dna.palette.minorCool || "#F2CFA0")} : BONE, duration: minor ? 0.6 : 0.32, ease: "power1.in" }, st + 0.15);
      if (st + 0.02 >= lastB && st + 0.24 <= XO) {   // one bounce owner at a time; none during exit
        tl.set(line, { y: minor ? 4 : 2 }, st + 0.02);
        tl.to(line, { y: 0, duration: 0.18, ease: "power2.out" }, st + 0.06);
        lastB = st + 0.24;
      }`
        : `      // FLICK: tube lights with seeded double-flicks
      tl.set(el, { opacity: 0.35 }, st);
      if (rrnd() < 0.35) { tl.set(el, { opacity: 0.08 }, st + F); tl.set(el, { opacity: 1 }, st + 2 * F); }
      else { tl.set(el, { opacity: 1 }, st + F); }
      if (minor) tl.fromTo(el, { scale: ${dna.body.emScale || 1.18} }, { scale: 1, duration: 0.3, ease: "power2.out" }, st + F);`
}
    });
${
  b.exit === "drop"
    ? `    // DROP exit (completes before the next line stamps)
    tl.to(line, { y: 34, opacity: 0, duration: 0.16, ease: "power2.in" }, XO);
    tl.set(line, { display: "none" }, XO + 0.18);`
    : b.exit === "fade"
      ? `    // FADE exit (quiet settle-down)
    tl.to(line, { opacity: 0, y: 6, duration: 0.15, ease: "power1.in" }, XO);
    tl.set(line, { display: "none" }, XO + 0.17);`
      : `    // POWERCUT exit (completes before the next line)
    tl.set(line, { opacity: 0.5 }, XO); tl.set(line, { opacity: 0.12 }, XO + F);
    tl.set(line, { opacity: 0 }, XO + 3 * F); tl.set(line, { display: "none" }, XO + 4 * F);`
}
  });
${
  b.rule
    ? `  tl.fromTo("#rrule", { width: 0, opacity: 0 }, { width: ${b.rule.width}, opacity: 0.5, duration: 0.4, ease: "power2.out" }, 0.25);
  tl.to("#rrule", { opacity: 0, duration: 0.3 }, ${(DUR - 0.2).toFixed(2)});`
    : ""
}
${
  b.yield
    ? `  // rail yields while the apex lands (furniture never contests the hero).
  // Overlap guards: dim starts after the line is IN, never runs into the exit;
  // restore is emitted only with clear runway before the exit (else the line
  // simply exits dimmed — never two owners of one opacity).
  RAIL.forEach((L) => {
    if (L.in < ${heroIn.toFixed(3)} + 0.9 && L.out > ${heroIn.toFixed(3)} - 0.3) {
      const XO2 = L.out - ${b.exit === "drop" ? "0.18" : "0.17"};
      const dimT = Math.max(${(heroIn - (b.yield.pre || 0.2)).toFixed(3)}, L.in + 0.05);
      if (XO2 > dimT + 0.25) {
        tl.to("#" + L.id, { opacity: ${b.yield.dim}, duration: 0.2 }, dimT);
        const resT = ${(heroIn + (b.yield.post || 0.9)).toFixed(3)};
        if (resT + 0.3 < XO2) tl.to("#" + L.id, { opacity: 1, duration: 0.25 }, resT);
      }
    }
  });`
    : ""
}`;
  return { css, html, js };
}

function paradigmPanel() {
  const b = dna.body;
  const lockT =
    heroIn + 0.3 + (dna.hero.params.lockStagger || 0.045) * (heroText.length - 1) + 0.083;
  const lineData = LINES.map((L) => ({
    id: "ln" + L.id,
    words: L.words.map((w) => [
      w.display,
      +w.start.toFixed(3),
      // redaction: words inside the hero phrase window get blocks until lock
      redactLinkage && w.start >= hero.start - 0.01 && w.start <= hero.end + 0.01
        ? +lockT.toFixed(3)
        : 0,
    ]),
  }));
  const lineH = Math.round(b.fontPx * 1.35);
  const maxVis = Math.max(3, Math.floor((H * 0.48 - 60) / lineH));
  const dockCss = CLEARER === "right" ? `right:${b.panel.left}px;` : `left:${b.panel.left}px;`;
  const css = `
  #panel { position:absolute; ${dockCss} bottom:${b.panel.bottom}px; width:${b.panel.width}px;
           padding:18px 22px 22px; background:${dna.palette.panelBg};
           border:1px solid ${dna.palette.accent}59; border-radius:4px; opacity:0;
           box-shadow: 0 0 24px rgba(0,0,0,0.35), inset 0 0 60px ${dna.palette.accent}0a; }
  #panel .hd { font-family:'${dna.fonts.tag}', monospace; font-size:22px; color:${dna.palette.prompt};
               letter-spacing:2px; border-bottom:1px solid ${dna.palette.accent}40;
               padding-bottom:8px; margin-bottom:10px; }
  .ln { font-family:'${dna.fonts.body}', monospace; font-size:${b.fontPx}px; line-height:1.35;
        color:${dna.palette.body}; text-shadow: 0 0 8px ${dna.palette.accent}59;
        white-space:nowrap; opacity:0; }
  .ln .pr { color:${dna.palette.prompt}; margin-right:8px; }
  .ln .w { display:inline-block; overflow:hidden; vertical-align:bottom; white-space:nowrap;
           margin-right:0.3em; }
  .ln .red { color:#7adcff; }
  .caret { display:inline-block; width:14px; height:26px; background:${dna.palette.body};
           vertical-align:baseline; opacity:0; margin-left:4px; }
  #logclip { max-height:${maxVis * lineH}px; overflow:hidden; }
  #logwrap { position:relative; }`;
  const html = `      <div id="panel">
        <div class="hd">${esc(theme.panelHeader || "OBS-01 // " + (b.panel.header || "LOG_"))}</div>
        <div id="logclip"><div id="logwrap">
${lineData.map((L) => `        <div class="ln" id="${L.id}"></div>`).join("\n")}
        </div></div>
      </div>`;
  const js = `
  // ---- body paradigm: PANEL (typed console log, accumulate) ----
  tl.fromTo("#panel", { opacity: 0, x: -16 }, { opacity: 1, x: 0, duration: 0.3, ease: "power2.out" }, 0.05);
${
  b.yield
    ? `  tl.to("#panel", { opacity: ${b.yield.dim}, duration: 0.25, ease: "power1.in" }, ${(heroIn - 0.08).toFixed(3)});
  tl.to("#panel", { opacity: 1, duration: 0.3, ease: "power1.out" }, ${(lockT + 0.15).toFixed(3)});`
    : ""
}
  const LOG = ${J(lineData)};
  const MAXVIS = ${maxVis}, LINEH = ${lineH};
  LOG.forEach((L, li) => {
    if (li >= MAXVIS) {  // terminal scroll: older rows slide up out of the clip
      tl.to("#logwrap", { y: -(li - MAXVIS + 1) * LINEH, duration: 0.28, ease: "power2.out" },
            L.words[0][1] - 0.10);
    }
    const line = document.getElementById(L.id);
    const p = document.createElement("span"); p.className = "pr"; p.textContent = "$";
    line.appendChild(p);
    tl.set(line, { opacity: 1 }, L.words[0][1] - 0.03);
    L.words.forEach(([txt, st, redUntil]) => {
      const wrap = document.createElement("span"); wrap.className = "w";
      const inner = document.createElement("span");
      inner.textContent = redUntil ? "█".repeat(Math.max(3, txt.length - 1)) : txt;
      wrap.appendChild(inner); line.appendChild(wrap);
      tl.fromTo(wrap, { width: 0 }, { width: "auto", duration: 0.12,
                ease: "steps(" + Math.max(2, txt.length) + ")" }, st);
      if (redUntil) {
        const real = document.createElement("span");
        real.textContent = txt; real.style.display = "none"; real.className = "red";
        wrap.appendChild(real);
        tl.set(inner, { display: "none" }, redUntil);
        tl.set(real, { display: "inline" }, redUntil);
      }
    });
${
  b.caret
    ? `    const c = document.createElement("span"); c.className = "caret"; line.appendChild(c);
    const lastEnd = L.words[L.words.length - 1][1] + 0.25;
    for (let bk = 0; bk < 4; bk++) tl.set(c, { opacity: bk % 2 === 0 ? 1 : 0 }, lastEnd + bk * 0.22);
    tl.set(c, { opacity: 0 }, lastEnd + 0.9);`
    : ""
}
  });
${
  (dna.linkages || []).includes("corrupt-on-last-word")
    ? `  // the feed corrupts on the final word
  const NZ = ${(LASTWORD.start + 0.04).toFixed(3)};
  [[3,0],[-4,1],[3,2],[-2,3],[0,4]].forEach(([dx, k]) => {
    tl.set("#panel", { x: dx, filter: dx ? "hue-rotate(" + dx * 8 + "deg)" : "none" }, NZ + k * F);
  });
  tl.set("#panel", { x: 0, filter: "none" }, NZ + 5 * F);`
    : ""
}`;
  return { css, html, js };
}

function paradigmPoem() {
  const b = dna.body;
  const colLeft = CLEARER === "right" ? Math.round(W * 0.52) : b.left;
  const scrimSide = CLEARER === "right" ? "right" : "left";
  // stanza split: break after a line whose last word ends a sentence
  const stanzas = [[]];
  LINES.forEach((L) => {
    stanzas[stanzas.length - 1].push(L);
    const last = L.words[L.words.length - 1].display;
    if (/[.!?]$/.test(last) && L !== LINES[LINES.length - 1]) stanzas.push([]);
  });
  if (stanzas.some((s) => s.length > b.stanzaTops.length))
    console.warn(
      "[make-theme] poem stanza exceeds available slots — extra lines share the last slot",
    );
  const stanzaData = stanzas.map((s, si) =>
    s.map((L, li) => ({
      id: "s" + si + "l" + L.id,
      top: b.stanzaTops[Math.min(li, b.stanzaTops.length - 1)] + si * 16,
      words: L.words.map((w) => [
        w.display,
        +w.start.toFixed(3),
        w.isHero && heroInline ? "big" : w.minor ? "em" : "",
      ]),
    })),
  );
  const lastStanzaIdx = stanzaData.length - 1;
  const css = `
  ${
    b.scrim
      ? `#pscrim { position:absolute; inset:0; opacity:0;
            background: linear-gradient(${scrimSide === "left" ? "100deg" : "260deg"},
              rgba(4,8,16,${b.scrim.opacity}) 0%, rgba(4,8,16,${b.scrim.opacity * 0.6}) 38%, rgba(4,8,16,0) 62%); }`
      : ""
  }
  .pline { position:absolute; left:${colLeft}px; white-space:nowrap;
           font-family:'${dna.fonts.body}', serif; font-weight:600; font-size:${b.fontPx}px;
           line-height:1; color:${dna.palette.body};
           text-shadow: 0 0 14px rgba(255,244,214,0.5), 0 2px 12px rgba(0,0,0,0.6); }
  .pline .l { display:inline-block; opacity:0; }
  .pline .sp { display:inline-block; width:0.28em; }
  .pline .big { font-size:${Math.round(b.fontPx * (b.bigScale || 2))}px; font-style:italic; font-weight:700;
                text-shadow: 0 0 24px rgba(255,244,214,0.7), 0 2px 14px rgba(0,0,0,0.6); }
  .pline .em  { font-size:${Math.round(b.fontPx * (b.emScale || 1.38))}px; font-style:italic; font-weight:700; }
  .star { position:absolute; border-radius:50%; background:#fff7e0; opacity:0;
          box-shadow: 0 0 8px rgba(255,247,224,0.9); }`;
  const html =
    (b.scrim ? `      <div id="pscrim"></div>\n` : "") +
    stanzaData
      .flat()
      .map((L) => `      <div class="pline" id="${L.id}" style="top:${L.top}px"></div>`)
      .join("\n");
  const disperse = (dna.linkages || []).includes("disperse-on-last-word");
  const js = `
  // ---- body paradigm: POEM (condense, accumulate, ${disperse ? "disperse" : "hold"}) ----
  const prnd = mulberry32(${b.seed || 777});
  const stage = document.getElementById("stage");
${
  b.scrim
    ? `  tl.fromTo("#pscrim", { opacity: 0 }, { opacity: 1, duration: 0.6, ease: "power1.out" }, 0.15);
  tl.to("#pscrim", { opacity: 0, duration: 0.4, ease: "power1.in" }, ${(LASTWORD.start + 0.15).toFixed(3)});`
    : ""
}
  const STANZAS = ${J(stanzaData)};
  const lastLetters = [];
  STANZAS.forEach((stanza, si) => {
    stanza.forEach((L) => {
      const line = document.getElementById(L.id);
      L.words.forEach(([txt, st, cls], wi) => {
        if (wi > 0) { const g = document.createElement("span"); g.className = "sp"; line.appendChild(g); }
        const letters = [];
        for (const ch of txt) {
          const l = document.createElement("span");
          l.className = "l" + (cls ? " " + cls : ""); l.textContent = ch;
          line.appendChild(l); letters.push(l);
        }
        const spread = cls === "big" ? ${dna.hero.params.spread || 150} : 46;
        letters.forEach((l, li) => {
          const sx = (prnd() - 0.5) * 2 * spread, sy = (prnd() - 0.5) * 2 * spread * 0.7;
          tl.fromTo(l, { x: sx, y: sy, opacity: 0, filter: "blur(6px)" },
            { x: 0, y: 0, opacity: 1, filter: "blur(0px)",
              duration: cls === "big" ? 0.7 : 0.5, ease: "power2.out" },
            st - 0.04 + li * (cls === "big" ? 0.03 : 0.016));
          if (si === ${lastStanzaIdx}) lastLetters.push(l);
        });
        if (cls === "big") {
          // SETPIECE assembly: star particles fly INTO the apex as it forms
          const lt = line.style.top ? parseFloat(line.style.top) : 110;
          for (let i = 0; i < ${dna.hero.params.particles || 22}; i++) {
            const p = document.createElement("div");
            p.className = "star"; stage.appendChild(p);
            const s = 2 + Math.round(prnd() * 3);
            const tx = ${colLeft} + 90 + prnd() * 240, ty = lt - 20 + prnd() * 70;
            const fx = tx + (prnd() - 0.5) * 560, fy = ty + (prnd() - 0.5) * 380;
            gsap.set(p, { width: s, height: s, left: fx, top: fy });
            tl.to(p, { keyframes: { x: [0, (tx - fx) * 0.6, tx - fx], y: [0, (ty - fy) * 0.6, ty - fy],
                                    opacity: [0, 0.95, 0] },
                       duration: 0.5 + prnd() * 0.2, ease: "power2.in" }, st - 0.05 + prnd() * 0.3);
          }
          tl.fromTo(letters, { textShadow: "0 0 30px rgba(255,244,214,0.9), 0 2px 12px rgba(0,0,0,0.5)" },
            { textShadow: "0 0 18px rgba(255,244,214,0.55), 0 2px 12px rgba(0,0,0,0.5)",
              duration: 0.8, ease: "power1.out" }, st + 0.55);
        }
      });
    });
    // earlier stanzas drift out before the next stanza begins
    if (si < STANZAS.length - 1) {
      const nextIn = STANZAS[si + 1][0].words[0][1];
      stanza.forEach((L, k) => {
        tl.to("#" + L.id + " .l", { y: -10, opacity: 0, filter: "blur(4px)",
          duration: 0.5, ease: "power1.in", stagger: 0.008 }, nextIn - 0.62 + k * 0.12);
        tl.set("#" + L.id, { display: "none" }, nextIn + 0.1);
      });
    }
  });
${
  disperse
    ? `  // SEMANTIC EXIT: the whole visible poem disperses on the final word.
  // Clamped so the scatter COMPLETES before the clip ends (stagger included).
  const NZ = ${Math.min(LASTWORD.start + 0.06, DUR - 0.58).toFixed(3)};
  const DDUR = ${Math.min(0.42, Math.max(0.24, DUR - Math.min(LASTWORD.start + 0.06, DUR - 0.58) - 0.14)).toFixed(2)};
  lastLetters.forEach((l) => {
    const dx = (prnd() - 0.5) * 260, dy = (prnd() - 0.5) * 200 - 40;
    tl.to(l, { x: dx, y: dy, opacity: 0, filter: "blur(7px)",
               duration: DDUR, ease: "power2.in" }, NZ + prnd() * 0.05);
  });`
    : ""
}`;
  return { css, html, js };
}

function paradigmTakeover() {
  const b = dna.body;
  // each LINE is a card; minors hot; hero = colorflip apex inline
  const cards = LINES.map((L, i) => {
    const txt = L.words
      .map((w) => w.display)
      .join(" ")
      .toUpperCase();
    const isHero = L.words.some((w) => w.isHero);
    const hot = isHero || L.words.some((w) => w.minor);
    const _contentish = txt.replace(/[^A-Z]/g, "").length;
    const px = isHero
      ? dna.hero.fontPx || 196
      : Math.min(
          b.sizes.minor,
          Math.round(Math.min(b.sizes.content, (W * 0.86) / (0.55 * Math.max(3, txt.length)))),
        );
    return {
      id: "c" + i,
      txt,
      in: +L.in.toFixed(3),
      out: +L.out.toFixed(3),
      px,
      hot: hot ? 1 : 0,
      hero: isHero ? 1 : 0,
      last: i === LINES.length - 1 ? 1 : 0,
    };
  });
  // a held card before a silence ≥0.5s creeps, then VOID until the next card
  for (let i = 0; i + 1 < cards.length; i++) {
    const gap = cards[i + 1].in - LINES[i].words[LINES[i].words.length - 1].end;
    if (gap > 0.5) {
      cards[i].creep = 1;
      cards[i].out = +(cards[i + 1].in - Math.min(0.6, gap * 0.45)).toFixed(3);
    }
  }
  const css = `
  #dim { position:absolute; inset:0; opacity:0; background:#05070c; }
  .card { position:absolute; opacity:0; white-space:nowrap;
          font-family:'${dna.fonts.body}', sans-serif; line-height:1; letter-spacing:0.015em;
          color:${dna.palette.body}; text-shadow: 0 4px 26px rgba(0,0,0,0.65); }
  .card.hot { color:${dna.palette.accent};
              text-shadow: 0 0 34px ${dna.palette.accent}73, 0 4px 26px rgba(0,0,0,0.65); }`;
  const html = `      <div id="dim"></div>`;
  const js = `
  // ---- body paradigm: TAKEOVER (hard-cut full-frame cards) + colorflip apex ----
  const stage = document.getElementById("stage");
  const ANCH = ${J(b.anchors)};
  tl.fromTo("#dim", { opacity: 0 }, { opacity: ${b.baseDim}, duration: 0.25, ease: "power2.in" }, 0.05);
  const CARDS = ${J(cards)};
  CARDS.forEach((c, ci) => {
    const el = document.createElement("div");
    el.className = "card" + (c.hot ? " hot" : "");
    el.textContent = c.txt; el.style.fontSize = c.px + "px";
    stage.appendChild(el);
    const [ax, ay] = ANCH[ci % ANCH.length];
    gsap.set(el, { left: ax, top: ay, xPercent: -50, yPercent: -50,
                   rotation: (ci % 2 === 0 ? 1 : -1) * (0.8 + (ci % 3) * 0.7) });
    if (c.hero) {
      // SETPIECE colorflip: crush-in, squash, settle; plate dim deepens
      tl.set("#dim", { opacity: ${dna.hero.params.dimKick} }, c.in);
      tl.to("#dim", { opacity: ${b.baseDim}, duration: 0.5, ease: "power2.out" }, c.in + 0.12);
      tl.set(el, { opacity: 1, scale: ${dna.hero.params.crushFrom}, filter: "blur(10px) brightness(2.6)" }, c.in - 0.04);
      tl.to(el, { scale: 1, filter: "blur(0px) brightness(1)", duration: 0.11, ease: "power4.in" }, c.in - 0.04);
      tl.set(el, { scaleX: 1.10, scaleY: 0.92 }, c.in + 0.07);
      tl.to(el, { scaleX: 1, scaleY: 1, duration: 0.45, ease: "elastic.out(1.05, 0.36)" }, c.in + 0.15);
      tl.to(el, { scale: 1.05, duration: Math.max(0.2, c.out - c.in - 0.3), ease: "power1.inOut" }, c.in + 0.25);
    } else if (c.hot) {
      tl.set(el, { opacity: 1, scale: ${b.hotSnap} }, c.in);
      tl.to(el, { scale: 1, duration: 0.22, ease: "back.out(1.8)" }, c.in + 0.02);
    } else {
      tl.set(el, { opacity: 1, scale: 0.94 }, c.in);
      tl.to(el, { scale: c.creep ? ${b.creepScale} : 1.0,
                  duration: Math.max(0.08, c.out - c.in - 0.02),
                  ease: c.creep ? "power1.in" : "power2.out" }, c.in + 0.01);
    }
    if (c.last && ${J(!!dna.hero.params.dissolveLast)}) {
      tl.to(el, { filter: "blur(9px)", opacity: 0, duration: 0.32, ease: "power2.in" }, c.out - 0.23);
      tl.set(el, { display: "none" }, c.out + 0.1);
    } else {
      tl.set(el, { opacity: 0, display: "none" }, c.out);
    }
  });
  tl.to("#dim", { opacity: 0, duration: 0.3, ease: "power1.in" }, ${(DUR - 0.27).toFixed(2)});`;
  return { css, html, js };
}

/* =====================================================================
 * HERO SETPIECES (embedded, bg layer) — { css, html, js } for index.html
 * ===================================================================== */

function setpieceDetonation() {
  const h = dna.hero,
    p = h.params,
    I = heroIn;
  const bw = Math.min(p.barWidth, W - 100, HG.halfW * 2 + 140);
  const css = `
  #scrim { position:absolute; inset:0; opacity:0;
           background: radial-gradient(120% 105% at 50% 38%, rgba(0,0,0,0) 26%, rgba(0,0,0,0.8) 100%); }
  #dimP { position:absolute; inset:0; opacity:0; background:#000; }
  #det { position:absolute; left:${HG.x}px; top:${HG.y}px; opacity:0; }
  .band, #heat { position:absolute; left:0; top:0; transform:translate(-50%,-50%);
          font-family:'${dna.fonts.hero}', sans-serif; font-size:${HG.fontPx}px; line-height:1;
          letter-spacing:0.012em; white-space:nowrap; color:${dna.palette.body};
          text-shadow: 0 3px 18px rgba(0,0,0,0.55), 0 1px 3px rgba(0,0,0,0.4); }
  ${p.slices.map((s, i) => `#b${i + 1} { clip-path: inset(${s[0]}% 0 ${s[1]}% 0); }`).join("\n  ")}
  #heat { top:6px; color:#ff7a28; filter:blur(24px); opacity:0; text-shadow:none; }
  .bar { position:absolute; left:50%; width:${bw}px; height:3px; margin-left:-${bw / 2}px;
         background:${dna.palette.body}; opacity:0.85; transform-origin:50% 50%; }
  #barT { top:-${Math.round(HG.fontPx * 0.7)}px; } #barB { top:${Math.round(HG.fontPx * 0.66)}px; }
  .tick { position:absolute; width:3px; height:14px; background:${dna.palette.hot}; top:-${Math.round(HG.fontPx * 0.7)}px; opacity:0; }
  #tickL { left:calc(50% - ${bw / 2}px); } #tickR { left:calc(50% + ${bw / 2 - 3}px); }
  #tagwrap { position:absolute; left:50%; transform:translateX(-50%); top:${Math.round(HG.fontPx * 0.7)}px;
             overflow:hidden; white-space:nowrap; }
  #tag { font-family:'${dna.fonts.tag}', monospace; font-size:21px; letter-spacing:0.34em;
         color:${dna.palette.hot}; white-space:nowrap; text-shadow: 0 2px 8px rgba(0,0,0,0.6); }`;
  const html = `      <div id="scrim"></div><div id="dimP"></div>
      <div id="det">
        <div id="heat">${esc(heroText)}</div>
        ${p.slices.map((_, i) => `<div class="band" id="b${i + 1}">${esc(heroText)}</div>`).join("\n        ")}
        ${p.bars ? `<div class="bar" id="barT"></div><div class="bar" id="barB"></div>` : ""}
        ${p.ticks ? `<div class="tick" id="tickL"></div><div class="tick" id="tickR"></div>` : ""}
        ${p.tag ? `<div id="tagwrap"><div id="tag">${esc(p.tag)}&nbsp;//&nbsp;T+${heroIn.toFixed(2)}S</div></div>` : ""}
      </div>`;
  const bandIds = p.slices.map((_, i) => "#b" + (i + 1));
  const js = `
  // ---- setpiece: DETONATION (charge → sheared slices snap → cool → furniture) ----
  const I = ${I.toFixed(3)}, HOTC = ${J(dna.palette.hot)}, BONEC = ${J(dna.palette.body)};
  tl.fromTo("#scrim", { opacity: 0 }, { opacity: ${dna.plate.charge}, duration: 0.40, ease: "power2.in" }, I - 0.42);
  tl.set("#dimP", { opacity: ${dna.plate.dim} }, I);
  tl.to("#dimP",  { opacity: ${(dna.plate.dim * 0.47).toFixed(2)}, duration: 0.9, ease: "power2.out" }, I + 0.10);
  tl.to("#scrim", { opacity: 0.30, duration: 0.9, ease: "power2.out" }, I + 0.10);
  tl.to(["#scrim","#dimP"], { opacity: 0, duration: 0.45, ease: "power1.in" }, ${(heroOut - 0.25).toFixed(3)});
  tl.set("#det", { opacity: 1 }, I - 0.05);
  tl.fromTo("#det", { scale: 3.4 }, { scale: 1.0, duration: 0.10, ease: "power4.in" }, I - 0.05);
  const BANDS = ${J(bandIds)};
  const SHEAR = ${J(p.sliceShear)};
  BANDS.forEach((sel, i) => {
    tl.fromTo(sel, { x: SHEAR[i], filter: "blur(14px) brightness(3.4)" },
      { x: SHEAR[i] * 0.26, filter: "blur(0px) brightness(1.5)", duration: 0.10, ease: "power4.in" }, I - 0.05);
  });
  tl.set(BANDS, { x: 0, filter: "brightness(1.9)" }, I + 0.05);
  tl.set("#det", { scaleX: 1.12, scaleY: 0.90 }, I + 0.05);
  tl.to("#det",  { scaleX: 1, scaleY: 1, duration: 0.55, ease: "elastic.out(1.05, 0.34)" }, I + 0.13);
  tl.to(BANDS, { filter: "brightness(1)", duration: 0.5, ease: "power2.out" }, I + 0.15);
  tl.set(BANDS, { color: HOTC }, I + 0.05);
  tl.to(BANDS, { color: BONEC, duration: 0.9, ease: "power1.in" }, I + 0.22);
${p.bars ? `  tl.fromTo(["#barT","#barB"], { scaleX: 0, opacity: 0.85 }, { scaleX: 1, duration: 0.34, ease: "expo.out" }, I + 0.16);` : ""}
${
  p.ticks
    ? `  tl.set(["#tickL","#tickR"], { opacity: 1 }, I + 0.42);
  tl.fromTo(["#tickL","#tickR"], { scaleY: 0 }, { scaleY: 1, duration: 0.14, ease: "back.out(2)" }, I + 0.42);`
    : ""
}
${p.tag ? `  tl.fromTo("#tagwrap", { width: 0 }, { width: 340, duration: 0.45, ease: "steps(16)" }, I + 0.5);` : ""}
  tl.fromTo("#heat", { opacity: 0 }, { opacity: 0.75, duration: 0.3 }, I + 0.06);
  tl.to("#heat", { keyframes: { opacity: [0.75, 0.5, 0.6, 0.4, 0.46, 0.30] }, duration: 1.4, ease: "none" }, I + 0.5);
  tl.to("#det", { scale: 1.035, duration: 1.2, ease: "power1.inOut" }, I + 0.75);
  // EXIT: the stamp shears apart
  BANDS.forEach((sel, i) => {
    tl.to(sel, { x: -SHEAR[i] * 1.5, opacity: 0, duration: 0.16, ease: "power2.in" }, ${(heroOut - 0.18).toFixed(3)} + i * 0.01);
  });
  tl.to(["#barT","#barB","#tickL","#tickR","#tagwrap","#heat"], { opacity: 0, duration: 0.13 }, ${(heroOut - 0.18).toFixed(3)});
  tl.set("#det", { display: "none" }, ${(heroOut + 0.02).toFixed(3)});`;
  return { css, html, js };
}

function setpieceDecode() {
  const h = dna.hero,
    p = h.params,
    I = heroIn;
  const lockStagger = p.lockStagger || 0.045;
  const E = theme.hero.exitAt ?? Math.min(heroOut, I + 1.75);
  const broom = Math.min(HG.x, W - HG.x) - HG.halfW; // room for brackets
  const useBrackets = !!p.brackets && broom > 22;
  const boff = Math.max(14, Math.min(40, Math.floor(broom - 8)));
  const css = `
  #dimP { position:absolute; inset:0; opacity:0; background:#021018; }
  #blk { position:absolute; left:${HG.x}px; top:${HG.y}px; transform:translate(-50%,-50%); opacity:0; }
  #word { display:flex; align-items:flex-start; justify-content:center;
          font-family:'${dna.fonts.hero}', sans-serif; font-weight:800; font-size:${HG.fontPx}px;
          line-height:${HG.fontPx}px; color:#eafaff;
          text-shadow: 0 0 14px ${dna.palette.accent}8c, 0 3px 16px rgba(0,0,0,0.6); }
  .reel { display:block; height:${HG.fontPx}px; width:0.82em; overflow:hidden; text-align:center; }
  .reel.sp { width:0.38em; }
  .col { display:block; } .col span { display:block; height:${HG.fontPx}px; }
  .br { position:absolute; width:26px; height:26px; opacity:0; }
  .br.tl { left:-${boff}px; top:-30px; border-left:3px solid ${dna.palette.accent}; border-top:3px solid ${dna.palette.accent}; }
  .br.tr { right:-${boff}px; top:-30px; border-right:3px solid ${dna.palette.accent}; border-top:3px solid ${dna.palette.accent}; }
  .br.bl { left:-${boff}px; bottom:-34px; border-left:3px solid ${dna.palette.accent}; border-bottom:3px solid ${dna.palette.accent}; }
  .br.brr{ right:-${boff}px; bottom:-34px; border-right:3px solid ${dna.palette.accent}; border-bottom:3px solid ${dna.palette.accent}; }`;
  const html = `      <div id="dimP"></div>
      <div id="blk">
        <div id="word"></div>
        ${useBrackets ? `<div class="br tl"></div><div class="br tr"></div><div class="br bl"></div><div class="br brr"></div>` : ""}
      </div>`;
  const js = `
  // ---- setpiece: DECODE (glyph reels lock left→right, CRT-off exit) ----
  const I = ${I.toFixed(3)}, TEXT = ${J(heroText)}, GLYPHS = ${J(p.glyphs)};
  const drnd = mulberry32(${p.seed || 99});
  tl.fromTo("#dimP", { opacity: 0 }, { opacity: ${dna.plate.dim}, duration: 0.3, ease: "power2.in" }, I - 0.25);
  tl.to("#dimP", { opacity: 0, duration: 0.4, ease: "power1.in" }, ${(E + 0.05).toFixed(3)});
  const word = document.getElementById("word");
  const reels = [];
  for (let i = 0; i < TEXT.length; i++) {
    const ch = TEXT[i];
    const reel = document.createElement("span");
    reel.className = "reel" + (ch === " " ? " sp" : "");
    const col = document.createElement("span"); col.className = "col";
    if (ch !== " ") {
      const n = 5 + Math.floor(drnd() * 4);
      for (let k = 0; k < n; k++) {
        const s = document.createElement("span");
        s.textContent = GLYPHS[Math.floor(drnd() * GLYPHS.length)]; col.appendChild(s);
      }
      const fin = document.createElement("span"); fin.textContent = ch; col.appendChild(fin);
      reel.dataset.steps = n;
    }
    reel.appendChild(col); word.appendChild(reel); reels.push({ el: reel, col, ch, i });
  }
  tl.set("#blk", { opacity: 1 }, I - 0.02);
  reels.forEach((r) => {
    if (r.ch === " ") return;
    const lock = I + 0.30 + ${lockStagger} * r.i;
    const steps = parseInt(r.el.dataset.steps, 10);
    tl.fromTo(r.col, { y: 0 }, { y: -${HG.fontPx} * steps, duration: lock - I, ease: "steps(" + steps + ")" }, I);
    tl.set(r.el, { color: "#bfe9ff",
      textShadow: "3px 0 10px ${dna.palette.accent}cc, -3px 0 10px ${dna.palette.magenta}cc" }, I);
    tl.set(r.el, { textShadow: "-3px 0 10px ${dna.palette.accent}cc, 3px 0 10px ${dna.palette.magenta}cc" }, I + 0.12 + 0.02 * r.i);
    tl.set(r.el, { color: "#ffffff", textShadow: "0 0 22px rgba(255,255,255,0.95)" }, lock);
    tl.set(r.el, { color: "#eafaff",
      textShadow: "0 0 14px ${dna.palette.accent}8c, 0 3px 16px rgba(0,0,0,0.6)" }, lock + 0.083);
  });
  const LOCKED = I + 0.30 + ${lockStagger} * (TEXT.length - 1) + 0.083;
  tl.set("#blk", { scale: 1.04 }, LOCKED - 0.083);
  tl.to("#blk", { scale: 1, duration: 0.3, ease: "elastic.out(1, 0.4)" }, LOCKED);
${
  useBrackets
    ? `  [".br.tl",".br.tr",".br.bl",".br.brr"].forEach((sel, k) => {
    tl.fromTo(sel, { opacity: 0, scale: 0.3 }, { opacity: 0.95, scale: 1, duration: 0.22, ease: "expo.out" }, I - 0.12 + k * 0.05);
  });`
    : ""
}
  // EXIT: CRT power-off
  tl.to("#blk", { filter: "brightness(2.6)", duration: 0.07, ease: "power2.in" }, ${E.toFixed(3)});
  tl.to("#blk", { scaleY: 0.012, duration: 0.13, ease: "power4.in" }, ${(E + 0.06).toFixed(3)});
  tl.set("#blk", { opacity: 0, display: "none" }, ${(E + 0.2).toFixed(3)});`;
  return { css, html, js };
}

function setpieceDrawon() {
  const h = dna.hero,
    p = h.params,
    I = heroIn;
  // generate the stroke path at COMPILE time — any word, zero tuning
  const fontPath = path.join(SKILL, "assets/strokefonts", dna.fonts.strokeFont);
  const gen = path.join(SKILL, "scripts/gen-stroke-path.py");
  const tw = Math.min(p.targetWidth || 640, W - 200);
  const D = execFileSync(
    "python3",
    [gen, fontPath, heroDisplay.toLowerCase(), String(tw), "185", String(Math.round(380 - tw / 2))],
    { encoding: "utf8" },
  ).trim();
  const WIN = p.window || 0.78;
  const css = `
  #spill { position:absolute; left:${HG.x}px; top:${HG.y}px; width:820px; height:440px;
           margin-left:-410px; margin-top:-220px; border-radius:50%; opacity:0;
           background: radial-gradient(50% 50% at 50% 50%, ${dna.palette.accent}57 0%, ${dna.palette.accent}00 70%); }
  #sign { position:absolute; left:${HG.x}px; top:${HG.y}px; }
  #neonsvg { position:absolute; left:0; top:0; margin-left:-380px; margin-top:-150px;
             transform:rotate(${p.rotate || -3}deg); overflow:visible; }
  #coreG { filter: drop-shadow(0 0 5px #fff) drop-shadow(0 0 16px ${dna.palette.accent}); }
  #pen  { filter: drop-shadow(0 0 8px #fff) drop-shadow(0 0 22px ${dna.palette.accent}); }`;
  const html = `      <div id="spill"></div>
      <div id="sign">
        <svg id="neonsvg" width="760" height="300" viewBox="0 0 760 300">
          <defs><filter id="haloblur" x="-60%" y="-60%" width="220%" height="220%">
            <feGaussianBlur stdDeviation="10"/></filter></defs>
          <path id="tubeP" fill="none" stroke="rgba(255,235,245,0.16)" stroke-width="${p.tubeWidth + 1}"
                stroke-linecap="round" stroke-linejoin="round" opacity="0"/>
          <g id="haloG" filter="url(#haloblur)" opacity="0.9"></g>
          <g id="coreG"></g>
          <circle id="pen" r="6" fill="#fff" opacity="0"/>
        </svg>
      </div>`;
  const js = `
  // ---- setpiece: DRAWON (single-line font, SEQUENTIAL stroke reveal) ----
  // SVG dash restarts per subpath → one <path> per pen stroke, revealed in
  // writing order at constant pen speed; nib rides each stroke, hops at lifts.
  const I = ${I.toFixed(3)}, WIN = ${WIN}, DRAWN = I + WIN, ENDT = ${heroOut.toFixed(3)};
  const D = ${J(D)};
  document.getElementById("tubeP").setAttribute("d", D);
  const subs = D.split(/(?=M )/).map(s => s.trim()).filter(Boolean);
  const SVGNS = "http://www.w3.org/2000/svg";
  function mk(parent, d, stroke, w) {
    const el = document.createElementNS(SVGNS, "path");
    el.setAttribute("d", d); el.setAttribute("fill", "none");
    el.setAttribute("stroke", stroke); el.setAttribute("stroke-width", w);
    el.setAttribute("stroke-linecap", "round"); el.setAttribute("stroke-linejoin", "round");
    el.setAttribute("opacity", "0");  // hidden until its turn (kills round-cap dot)
    document.getElementById(parent).appendChild(el);
    return el;
  }
  const strokes = subs.map((d) => ({ halo: mk("haloG", d, ${J(dna.palette.accent)}, ${p.haloWidth}),
                                     core: mk("coreG", d, "#fff", ${p.tubeWidth}) }));
  let total = 0;
  strokes.forEach((s) => { s.len = s.core.getTotalLength(); total += s.len; });
  tl.fromTo("#tubeP", { opacity: 0 }, { opacity: 1, duration: 0.35 }, I - 0.6);
  tl.fromTo("#spill", { opacity: 0 }, { opacity: ${p.spill || 0.38}, duration: WIN + 0.2, ease: "power1.in" }, I);
  const pen = document.getElementById("pen");
  let t = I;
  strokes.forEach((s) => {
    const d = WIN * s.len / total;
    gsap.set([s.core, s.halo], { strokeDasharray: s.len, strokeDashoffset: s.len });
    tl.set([s.core, s.halo], { opacity: 1 }, t);
    tl.fromTo([s.core, s.halo], { strokeDashoffset: s.len }, { strokeDashoffset: 0, duration: d, ease: "none" }, t);
    const n = Math.max(4, Math.round(s.len / 14)), xs = [], ys = [];
    for (let k = 0; k <= n; k++) {
      const pt = s.core.getPointAtLength(s.len * k / n); xs.push(pt.x); ys.push(pt.y);
    }
    tl.set(pen, { x: xs[0], y: ys[0] }, t);
    tl.to(pen, { keyframes: { x: xs, y: ys }, duration: d, ease: "none" }, t);
    t += d;
  });
  gsap.set(pen, { attr: { cx: 0, cy: 0 } });
  tl.set(pen, { opacity: 1 }, I - 0.01);
  tl.to(pen, { scale: 2.2, opacity: 0, duration: 0.12, ease: "power2.in", transformOrigin: "50% 50%" }, DRAWN);
${
  p.hum
    ? `  const humStart = DRAWN + 0.08, humDur = Math.max(0.2, ENDT - humStart);
  const NN = Math.max(6, Math.round(humDur / (2 * F)));
  const humVals = [];
  for (let k = 0; k <= NN; k++) {
    const tt = k / NN * humDur;
    humVals.push(0.78 + 0.06 * Math.sin(2 * Math.PI * 9 * tt) + 0.05 * Math.sin(2 * Math.PI * 13 * tt + 1.1));
  }
  tl.to("#haloG", { keyframes: { opacity: humVals }, duration: humDur, ease: "none" }, humStart);
  tl.set("#coreG", { opacity: 0.45 }, ENDT + (${p.buzzDipAt || -0.18}));
  tl.set("#coreG", { opacity: 1 }, ENDT + (${p.buzzDipAt || -0.18}) + F);`
    : ""
}`;
  return { css, html, js };
}

/* =====================================================================
 * FRONT FX (fg layer additions) — flash / rings / sparks / scanband
 * ===================================================================== */
function frontFx() {
  const fx = dna.fx || {};
  let css = "",
    html = "",
    js = "";
  const I = heroIn;
  if (fx.flash) {
    css += `
  #fxflash { position:absolute; inset:0; opacity:0;
           background: radial-gradient(95% 80% at 50% 37%, rgba(255,250,240,0.95) 0%, rgba(255,235,210,0.7) 38%, rgba(120,90,60,0) 100%); }`;
    html += `      <div id="fxflash"></div>\n`;
    js += `
  tl.set("#fxflash", { opacity: ${fx.flash} }, ${(I + 0.045).toFixed(3)});
  tl.to("#fxflash", { opacity: 0, duration: 0.17, ease: "expo.out" }, ${(I + 0.085).toFixed(3)});`;
  }
  if (fx.rings) {
    css += `
  .fxring { position:absolute; left:${HG.x}px; top:${HG.y}px; width:90px; height:32px;
          margin-left:-45px; margin-top:-16px; border-radius:50%; opacity:0; }
  #fxr1 { border:4px solid rgba(255,228,196,0.95); filter:blur(1.5px); }
  #fxr2 { border:3px solid rgba(255,150,70,0.8); filter:blur(3px); }`;
    html += `      <div class="fxring" id="fxr1"></div>\n      <div class="fxring" id="fxr2"></div>\n`;
    js += `
  tl.set("#fxr1", { opacity: 1, scale: 1 }, ${(I + 0.05).toFixed(3)});
  tl.to("#fxr1", { scale: 16, opacity: 0, duration: 0.58, ease: "expo.out" }, ${(I + 0.05).toFixed(3)});
  tl.to("#fxr1", { filter: "blur(7px)", duration: 0.58, ease: "power1.in" }, ${(I + 0.05).toFixed(3)});
  tl.set("#fxr2", { opacity: 0.55, scale: 1 }, ${(I + 0.13).toFixed(3)});
  tl.to("#fxr2", { scale: 11, opacity: 0, duration: 0.72, ease: "expo.out" }, ${(I + 0.13).toFixed(3)});`;
  }
  if (fx.sparks) {
    css += `
  .fxspark { position:absolute; left:${HG.x}px; top:${HG.y}px; border-radius:2px;
           background:#ffd9a8; opacity:0; }
  .fxember { position:absolute; border-radius:50%; background:#ff9544; filter:blur(1px); opacity:0; }`;
    js += `
  { const frnd = mulberry32(${fx.seed || 7});
    const stg = document.getElementById("stage");
    for (let i = 0; i < ${fx.sparks}; i++) {
      const el = document.createElement("div"); el.className = "fxspark"; stg.appendChild(el);
      const ang = frnd() * Math.PI * 2, dist = 130 + frnd() * 330;
      const dx = Math.cos(ang) * dist, dy = Math.sin(ang) * dist * 0.55;
      const grav = 40 + frnd() * 90, s = 3 + Math.round(frnd() * 5), d = 0.6 + frnd() * 0.45;
      gsap.set(el, { width: s, height: s, x: 0, y: 0 });
      tl.set(el, { opacity: 1 }, ${(I + 0.05).toFixed(3)});
      tl.to(el, { keyframes: { x: [0, dx*0.55, dx*0.85, dx],
                               y: [0, dy*0.55 + grav*0.15, dy*0.85 + grav*0.55, dy + grav],
                               opacity: [1, 1, 0.65, 0] }, duration: d, ease: "power2.out" }, ${(I + 0.05).toFixed(3)});
    }
    for (let i = 0; i < ${fx.embers || 0}; i++) {
      const el = document.createElement("div"); el.className = "fxember"; stg.appendChild(el);
      const s = 3 + Math.round(frnd() * 3);
      gsap.set(el, { width: s, height: s, left: ${HG.x - 220} + frnd() * 440, top: ${HG.y + 32} + frnd() * 60 });
      const t0 = ${(I + 0.6).toFixed(3)} + frnd() * 0.9, dr = 1.0 + frnd() * 0.7;
      const yA = -40 - frnd() * 50, yB = -80 - frnd() * 60;
      const xA = (frnd() - 0.5) * 30, xB = (frnd() - 0.5) * 50;
      tl.to(el, { keyframes: { opacity: [0, 0.75, 0.4, 0.8, 0],
                               y: [0, yA*0.5, yA, (yA+yB)/2, yB],
                               x: [0, xA*0.5, xA, (xA+xB)/2, xB] }, duration: dr, ease: "power1.out" }, t0);
    }
  }`;
  }
  if (fx.crowdflash) {
    // two stadium camera-flash pops: lock start / lock end (flapboard rhythm)
    const [fa, fb] = fx.crowdflash;
    const lockEnd = I + ((dna.hero.params || {}).lockWindow || 0.34);
    css += `
  #fxcrowd { position:absolute; inset:0; opacity:0;
            background: radial-gradient(85% 70% at 50% 34%, rgba(255,250,238,0.85) 0%,
                        rgba(255,238,210,0.45) 45%, rgba(60,50,35,0) 100%); }`;
    html += `      <div id="fxcrowd"></div>\n`;
    js += `
  tl.set("#fxcrowd", { opacity: ${fa} }, ${I.toFixed(3)});
  tl.to("#fxcrowd", { opacity: 0, duration: 0.10, ease: "expo.out" }, ${(I + 0.042).toFixed(3)});
  tl.set("#fxcrowd", { opacity: ${fb} }, ${lockEnd.toFixed(3)});
  tl.to("#fxcrowd", { opacity: 0, duration: 0.12, ease: "expo.out" }, ${(lockEnd + 0.042).toFixed(3)});`;
  }
  if (fx.paflash) {
    // PA-system flash over the board glow at apex contact (ledwipe completes)
    const C2 = I + ((dna.hero.params || {}).wipeDur || 0.3);
    const pw = (HG.panelW || 900) - 180,
      ph = Math.round((HG.panelH || 232) * 1.9);
    css += `
  #fxpa { position:absolute; left:${Math.round(HG.x - pw / 2)}px; top:${Math.round(HG.y - ph / 2)}px;
          width:${pw}px; height:${ph}px; opacity:0;
          background: radial-gradient(closest-side, rgba(255,200,100,0.55), ${dna.palette.accent}00 70%); }`;
    html += `      <div id="fxpa"></div>\n`;
    js += `
  tl.set("#fxpa", { opacity: ${fx.paflash} }, ${C2.toFixed(3)});
  tl.to("#fxpa", { opacity: 0, duration: 0.22, ease: "expo.out" }, ${(C2 + 0.042).toFixed(3)});`;
  }
  if (fx.coinflash) {
    // 1-frame arcade impact flash at the boss-word strike (the coin blip at
    // max amplitude) — vertical center rides the hero band
    const FT = I + ((dna.hero.params || {}).flashDelay ?? 0.14);
    const cyP = Math.round((HG.y / H) * 100);
    css += `
  #fxcoin { position:absolute; inset:0; opacity:0; z-index:16;
            background: radial-gradient(58% 38% at 50% ${cyP}%, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0) 70%); }`;
    html += `      <div id="fxcoin"></div>\n`;
    js += `
  tl.set("#fxcoin", { opacity: ${fx.coinflash} }, ${FT.toFixed(3)});
  tl.to("#fxcoin", { opacity: 0, duration: 0.12, ease: "expo.out" }, ${(FT + 0.042).toFixed(3)});`;
  }
  if (fx.inkflecks) {
    // red ink flecks spatter off the rubber-stamp contact (fg layer, ballistic
    // with gravity) — fires at C = heroIn + params.crush, the punch anchor
    const C4 = I + ((dna.hero.params || {}).crush ?? 0.074);
    css += `
  .fxfleck { position:absolute; left:${HG.x}px; top:${HG.y}px; border-radius:50%;
           background:${dna.palette.fleck || dna.palette.accent}; opacity:0; }`;
    js += `
  { const frnd = mulberry32(${fx.seed || 18});
    const stg = document.getElementById("stage");
    for (let i = 0; i < ${fx.inkflecks}; i++) {
      const el = document.createElement("div"); el.className = "fxfleck"; stg.appendChild(el);
      const ang = frnd() * Math.PI * 2, dist = 70 + frnd() * 200;
      const dx = Math.cos(ang) * dist, dy = Math.sin(ang) * dist * 0.6;
      const grav = 30 + frnd() * 60, sz2 = 2 + Math.round(frnd() * 3), d = 0.32 + frnd() * 0.2;
      gsap.set(el, { width: sz2, height: sz2, x: 0, y: 0 });
      tl.set(el, { opacity: 1 }, ${C4.toFixed(3)});
      tl.to(el, { keyframes: { x: [0, dx * 0.6, dx],
                               y: [0, dy * 0.6 + grav * 0.2, dy + grav],
                               opacity: [1, 0.85, 0] },
                 duration: d, ease: "power2.out" }, ${(C4 + 0.01).toFixed(3)});
    }
  }`;
  }
  if (fx.laserflash) {
    // apex contact flash in FRONT of everything (the laser ignite pop, 4
    // frames) — sized from the hero rect, tinted with the accent
    const fw = Math.round(2 * (HG.halfW || 330) + 80),
      fh = Math.round((HG.fontPx || 100) * 4.8);
    css += `
  #fxlaser { position:absolute; left:${HG.x - Math.round(fw / 2)}px; top:${HG.y - Math.round(fh / 2)}px;
           width:${fw}px; height:${fh}px; opacity:0;
           background: radial-gradient(50% 50% at 50% 50%, rgba(235,255,244,0.9) 0%, ${dna.palette.accent}73 38%, rgba(0,0,0,0) 72%); }`;
    html += `      <div id="fxlaser"></div>\n`;
    js += `
  tl.set("#fxlaser", { opacity: ${fx.laserflash} }, ${(I + 0.03).toFixed(3)});
  tl.to("#fxlaser",  { opacity: 0, duration: 0.16, ease: "expo.out" }, ${(I + 0.075).toFixed(3)});`;
  }
  if (fx.strikeflash) {
    // full-frame lightning flash in FRONT of everything: leader blink 2 frames
    // before contact (bright / dim alternation), return stroke at contact + 1f,
    // expo-out decay — same flicker grammar as the bg bolt
    css += `
  #fxstrike { position:absolute; inset:0; opacity:0;
           background: radial-gradient(80% 65% at 50% 38%, rgba(245,250,255,0.9) 0%, rgba(205,228,252,0.5) 40%, rgba(110,140,180,0) 100%); }`;
    html += `      <div id="fxstrike"></div>\n`;
    js += `
  tl.set("#fxstrike", { opacity: ${(fx.strikeflash * 0.45).toFixed(3)} }, ${(I - 2 * F).toFixed(3)});
  tl.set("#fxstrike", { opacity: ${(fx.strikeflash * 0.18).toFixed(3)} }, ${(I - F).toFixed(3)});
  tl.set("#fxstrike", { opacity: ${fx.strikeflash} }, ${(I + 0.02).toFixed(3)});
  tl.to("#fxstrike",  { opacity: 0, duration: 0.16, ease: "expo.out" }, ${(I + 0.06).toFixed(3)});`;
  }
  if (fx.holoflash) {
    // cyan LOCK flash in front of everything when the projection boots to
    // full brightness — a radial centered on the hero (frame-fraction anchor)
    const hxP = ((HG.x / W) * 100).toFixed(1),
      hyP = ((HG.y / H) * 100).toFixed(1);
    css += `
  #fxholo { position:absolute; inset:0; opacity:0;
           background: radial-gradient(58% 44% at ${hxP}% ${hyP}%, ${dna.palette.accent}8c 0%, rgba(80,160,200,0.18) 45%, rgba(40,80,110,0) 72%); }`;
    html += `      <div id="fxholo"></div>\n`;
    js += `
  tl.set("#fxholo", { opacity: ${fx.holoflash} }, ${(I + 0.075).toFixed(3)});
  tl.to("#fxholo", { opacity: 0, duration: 0.22, ease: "expo.out" }, ${(I + 0.105).toFixed(3)});`;
  }
  if (fx.sporeburst) {
    // bioluminescent spores burst PAST the bloom at contact (fg, in front of
    // the subject) — seeded radial puffs from the hero center, slight sink
    const C6 = I + ((dna.hero.params || {}).crush ?? 0.11);
    css += `
  .fxspore { position:absolute; left:${HG.x}px; top:${HG.y}px; border-radius:50%;
           background:${dna.palette.spore || dna.palette.accent};
           box-shadow: 0 0 6px 1px ${dna.palette.accent}cc; opacity:0; }`;
    js += `
  { const frnd = mulberry32(${fx.seed || 12});
    const stg = document.getElementById("stage");
    for (let i = 0; i < ${fx.sporeburst}; i++) {
      const el = document.createElement("div"); el.className = "fxspore"; stg.appendChild(el);
      const s = 2 + Math.round(frnd());
      gsap.set(el, { width: s, height: s });
      const ang = frnd() * Math.PI * 2, dist = 70 + frnd() * 110;
      const dx = Math.cos(ang) * dist * 1.25, dy = Math.sin(ang) * dist * 0.7;
      tl.to(el, { keyframes: { x: [0, dx * 0.6, dx], y: [0, dy * 0.6, dy + 10],
                               opacity: [0.7, 0.5, 0] },
                 duration: 0.45 + frnd() * 0.15, ease: "power2.out" }, ${(C6 + 0.01).toFixed(3)} + i * 0.012);
    }
  }`;
  }
  if (fx.silkmotes) {
    // gentle aurora light motes drifting up near the ribbon during its hold
    // (fg layer) — seeded along the ribbon span at the demo's spread fractions
    // (the gap around 0.4 keeps them off the subject's face)
    const halfW = HG.halfW || 440;
    css += `
  .fxmote { position:absolute; border-radius:50%; opacity:0; filter:blur(1.5px); }`;
    js += `
  { const frnd = mulberry32(${fx.seed || 9});
    const stg9 = document.getElementById("stage");
    const PALM = [${J(dna.palette.rose)}, ${J(dna.palette.accent)}, ${J(dna.palette.violet)}];
    const FRX = [-0.88, -0.56, -0.27, 0.05, 0.72, 0.84];
    for (let i = 0; i < ${fx.silkmotes}; i++) {
      const m = document.createElement("div"); m.className = "fxmote"; stg9.appendChild(m);
      const s = 3 + Math.round(frnd() * 3);
      const c = PALM[i % 3];
      const mx = ${HG.x} + FRX[i % 6] * ${Math.round(halfW)} + (frnd() - 0.5) * 40;
      const my = ${HG.y} - 5 + frnd() * 110;
      gsap.set(m, { width: s, height: s, left: mx, top: my,
                    background: c, boxShadow: "0 0 6px " + c });
      const t0 = ${(I + 0.13).toFixed(3)} + i * 0.09 + frnd() * 0.08;
      tl.fromTo(m, { y: 10 },
        { keyframes: { opacity: [0, 0.45, 0], y: [10, -16, -44] },
          duration: 0.75, ease: "sine.out" }, Math.min(t0, ${(DUR - 0.79).toFixed(3)}));
    }
  }`;
  }
  if (fx.paperscraps) {
    // torn paper scraps puff PAST the subject at each apex chip contact (fg,
    // in front) — ballistic steps(4) arcs, one accent scrap per puff
    // (the papermat landing rhythm); centers baked from the chip geometry
    const N9 = heroText.length;
    const pitch9 = HG.pitch || 132,
      step9 = (dna.hero.params || {}).step ?? 0.09;
    const y9 = Math.round(HG.y + (HG.tileH || 170) / 2 - 9);
    const puffs = [];
    for (let i = 0; i < N9; i++)
      if (heroText[i] !== " ")
        puffs.push([
          Math.round(HG.x + (i - (N9 - 1) / 2) * pitch9),
          +(I + i * step9).toFixed(3),
          +((i - (N9 - 1) / 2) * 22).toFixed(1),
        ]);
    css += `
  .fxscrap { position:absolute; opacity:0; border-radius:1px; }`;
    js += `
  { const frnd = mulberry32(${fx.seed || 150372});
    const stg = document.getElementById("stage");
    const PUFFS = ${J(puffs)};
    PUFFS.forEach(([px, pc, drift]) => {
      for (let k = 0; k < ${fx.paperscraps}; k++) {
        const el = document.createElement("div"); el.className = "fxscrap"; stg.appendChild(el);
        const s = 7 + Math.round(frnd()*6);
        el.style.width = s+"px"; el.style.height = Math.max(4, s-3)+"px";
        el.style.background = (k === 1) ? ${J(dna.palette.accent)} : ${J(dna.palette.chip || "#f6edd8")};
        el.style.left = (px + (frnd()-0.5)*40)+"px";
        el.style.top  = (${y9} + (frnd()-0.5)*14)+"px";
        const dx  = (frnd()-0.5)*180 + drift;
        const dyU = -(35 + frnd()*55);
        const dyF = dyU + 80 + frnd()*55;
        const rot = (frnd()-0.5)*240;
        tl.set(el, { opacity: 1 }, pc);
        tl.to(el, { keyframes: { x: [0, dx*0.6, dx], y: [0, dyU, dyF],
                                 rotation: [0, rot*0.6, rot], opacity: [1, 1, 0] },
                    duration: 0.42, ease: "steps(4)" }, pc);
      }
    });
  }`;
  }
  if (fx.confetti) {
    // paper confetti flicks up PAST the subject when the apex word pops (fg,
    // in front) — seeded ballistic arcs with card tumble; launch line baked
    // from the centerfold hinge (90% of the burst box, where the paper folds)
    const u0 = (HG.burstR || 220) / 220;
    const boxH0 = HG.boxH || 560;
    const hingeY = Math.round(HG.y + 24 * u0 - boxH0 / 2 + 0.9 * boxH0);
    const CC = dna.palette.conf || [
      dna.palette.cream || "#f6ecd4",
      dna.palette.accent,
      dna.palette.body,
    ];
    css += `
  .fxconf { position:absolute; opacity:0; border-radius:1px; }`;
    js += `
  { const frnd = mulberry32(${fx.seed || 360372});
    const stg = document.getElementById("stage");
    const CCOL = ${J(CC)};
    for (let i = 0; i < ${fx.confetti}; i++) {
      const el = document.createElement("div"); el.className = "fxconf";
      el.style.background = CCOL[i % CCOL.length]; stg.appendChild(el);
      gsap.set(el, { width: 5 + Math.round(frnd() * 5), height: 4 + Math.round(frnd() * 4),
                     left: ${HG.x - 30} + frnd() * 70, top: ${hingeY - 2} + frnd() * 16 });
      const dx = (frnd() - 0.5) * 320, up = -90 - frnd() * 140, grav = 150 + frnd() * 120;
      const rot = (frnd() - 0.5) * 520, d = 0.55 + frnd() * 0.3, t0 = ${(I + 0.128).toFixed(3)} + frnd() * 0.08;
      tl.set(el, { opacity: 1 }, t0);
      tl.to(el, { keyframes: { x: [0, dx * 0.5, dx * 0.85, dx],
                               y: [0, up, up * 0.55 + grav * 0.35, up * 0.1 + grav],
                               rotation: [0, rot * 0.4, rot * 0.75, rot],
                               opacity: [1, 1, 0.8, 0] },
                  duration: d, ease: "power1.out" }, t0);
    }
  }`;
  }
  if (fx.chalkclap) {
    // hand-slap dust CLAP in FRONT of the subject the instant the chalk word
    // completes (the bg board shakes the same frame) — seeded radial specks +
    // a soft blurred puff, centered on the pen's final contact point (baked
    // by setpieceChalkwrite into HG.clapX/Y/T)
    const cx9 = Math.round(HG.clapX ?? HG.x + (HG.halfW || 80)),
      cy9 = Math.round(HG.clapY ?? HG.y),
      ct9 = +(HG.clapT ?? I + 0.5).toFixed(3);
    const DUST9 = dna.palette.dust || "#f7f5ea";
    css += `
  #fxclap { position:absolute; left:${cx9}px; top:${cy9}px; width:0; height:0; }
  #fxclap .cd { position:absolute; left:0; top:0; border-radius:50%; background:${DUST9}; opacity:0; }
  #fxpuff { position:absolute; left:-48px; top:-48px; width:96px; height:96px; border-radius:50%; opacity:0;
            background: radial-gradient(50% 50% at 50% 50%, ${DUST9}80 0%, ${DUST9}00 70%);
            filter: blur(4px); }`;
    html += `      <div id="fxclap"><div id="fxpuff"></div></div>\n`;
    js += `
  { const frnd = mulberry32(${fx.seed || 13});
    const clap = document.getElementById("fxclap");
    for (let i = 0; i < ${fx.chalkclap}; i++) {
      const s = document.createElement("div"); s.className = "cd";
      const sz = 2.5 + frnd() * 3.2;
      s.style.width = sz + "px"; s.style.height = sz + "px";
      s.style.marginLeft = (-sz / 2) + "px"; s.style.marginTop = (-sz / 2) + "px";
      clap.appendChild(s);
      const a = (i / ${fx.chalkclap}) * 6.2832 + (frnd() - 0.5) * 0.5;
      const dist = 42 + frnd() * 55;
      tl.set(s, { opacity: 1 }, ${ct9});
      tl.to(s, { x: Math.cos(a) * dist, y: Math.sin(a) * dist * 0.8 + 26,
                 duration: 0.5 + frnd() * 0.15, ease: "power2.out" }, ${ct9});
      tl.to(s, { opacity: 0, duration: 0.4, ease: "power1.in" }, ${(ct9 + 0.1).toFixed(3)});
    }
    tl.fromTo("#fxpuff", { opacity: 0.55, scale: 0.35 },
              { opacity: 0, scale: 1.8, duration: 0.5, ease: "power2.out" }, ${ct9});
  }`;
  }
  if (fx.paintsplat) {
    // paint SPLAT in FRONT of the subject the instant the spray tag completes
    // — a hot magenta ring + a soft paint-color ring + seeded droplets with
    // gravity, centered on the pen-finish point (baked by setpieceSpraytag
    // into HG.splatX/Y/T)
    const sx = Math.max(60, Math.min(W - 60, Math.round(HG.splatX ?? HG.x + (HG.halfW || 80))));
    const sy = Math.round(HG.splatY ?? HG.y);
    const st9 = +Math.min(HG.splatT ?? I + 0.5, DUR - 0.5).toFixed(3);
    const HOT9 = dna.palette.em2 || dna.palette.hot || "#ff2e88";
    const PAINT9 = dna.palette.accent || "#ffd23f";
    css += `
  .fxsplat { position:absolute; left:${sx}px; top:${sy}px; border-radius:50%; opacity:0; }
  #fxsp1 { width:40px; height:30px; border:5px solid ${HOT9}; margin-left:-20px; margin-top:-15px; }
  #fxsp2 { width:26px; height:20px; border:4px solid ${PAINT9}; margin-left:-13px; margin-top:-10px;
           filter: blur(1.5px); }
  .fxsdrop { position:absolute; left:${sx}px; top:${sy}px; background:${HOT9};
             border-radius:50%; opacity:0; }`;
    html += `      <div class="fxsplat" id="fxsp1"></div>\n      <div class="fxsplat" id="fxsp2"></div>\n`;
    js += `
  tl.set("#fxsp1", { opacity: 1, scale: 0.3 }, ${st9});
  tl.to("#fxsp1", { scale: 4.6, opacity: 0, duration: 0.42, ease: "expo.out" }, ${st9});
  tl.to("#fxsp1", { filter: "blur(4px)", duration: 0.42, ease: "power1.in" }, ${st9});
  tl.set("#fxsp2", { opacity: 0.8, scale: 0.4 }, ${(st9 + 0.04).toFixed(3)});
  tl.to("#fxsp2", { scale: 3.4, opacity: 0, duration: 0.5, ease: "expo.out" }, ${(st9 + 0.04).toFixed(3)});
  { const frnd = mulberry32(${fx.seed || 20260611});
    const stg = document.getElementById("stage");
    for (let i = 0; i < ${fx.paintsplat}; i++) {
      const el = document.createElement("div");
      el.className = "fxsdrop"; stg.appendChild(el);
      const s = 3 + Math.round(frnd() * 4);
      gsap.set(el, { width: s, height: s });
      const ang = frnd() * Math.PI * 2, dist = 36 + frnd() * 64; // capped: stays inside the frame edge
      const dx = Math.cos(ang) * dist, dyv = Math.sin(ang) * dist * 0.7;
      const grav = 26 + frnd() * 40, d = 0.38 + frnd() * 0.25;
      tl.set(el, { opacity: 1, x: 0, y: 0 }, ${st9});
      tl.to(el, { keyframes: { x: [0, dx * 0.6, dx],
                               y: [0, dyv * 0.6 + grav * 0.2, dyv + grav],
                               opacity: [1, 0.85, 0] },
                  duration: d, ease: "power2.out" }, ${st9});
    }
  }`;
  }
  if (fx.brushflecks) {
    // ink flecks kicked PAST the subject at the brush's first contact (the
    // start-splat depth pass) — seeded upward ballistic arcs from the pen-START
    // point (baked by setpieceBrushwrite into HG.fleckX/Y/T)
    const bx = Math.max(40, Math.min(W - 40, Math.round(HG.fleckX ?? HG.x - (HG.halfW || 80))));
    const by = Math.round(HG.fleckY ?? HG.y);
    const bt = +Math.min(HG.fleckT ?? I + 0.01, DUR - 0.4).toFixed(3);
    css += `
  .fxbfleck { position:absolute; left:${bx}px; top:${by}px; border-radius:50%;
           background:${dna.palette.ink || "#1a130c"}; opacity:0; }`;
    js += `
  { const frnd = mulberry32(${fx.seed || 20260611});
    const stg = document.getElementById("stage");
    for (let i = 0; i < ${fx.brushflecks}; i++) {
      const el = document.createElement("div"); el.className = "fxbfleck"; stg.appendChild(el);
      const s = 3 + Math.round(frnd() * 3);
      gsap.set(el, { width: s, height: s });
      const a = -0.4 - frnd() * 2.4, dist = 46 + frnd() * 90;
      const dx = Math.cos(a) * dist, dyv = Math.sin(a) * dist;
      tl.set(el, { opacity: 1 }, ${bt});
      tl.to(el, { keyframes: { x: [0, dx * 0.6, dx], y: [0, dyv * 0.6, dyv + 26],
                               opacity: [1, 0.85, 0] },
                  duration: 0.36, ease: "power2.out" }, ${bt});
    }
  }`;
  }
  if (fx.scanband) {
    const lockT =
      heroIn + 0.3 + (dna.hero.params.lockStagger || 0.045) * (heroText.length - 1) + 0.083;
    css += `
  #fxband { position:absolute; left:0; top:-80px; width:100%; height:70px; opacity:0;
          background: linear-gradient(180deg, ${dna.palette.accent}00 0%, ${dna.palette.accent}66 50%, ${dna.palette.accent}00 100%); }
  #fxlock { position:absolute; inset:0; opacity:0;
               background: radial-gradient(80% 60% at 52% 35%, ${dna.palette.accent}99 0%, ${dna.palette.accent}00 70%); }`;
    html += `      <div id="fxband"></div>\n      <div id="fxlock"></div>\n`;
    js += `
  tl.set("#fxband", { opacity: 0.5 }, ${(I - 0.05).toFixed(3)});
  tl.fromTo("#fxband", { y: 0 }, { y: ${H + 160}, duration: 0.5, ease: "power1.in" }, ${(I - 0.05).toFixed(3)});
  tl.set("#fxband", { opacity: 0 }, ${(I + 0.46).toFixed(3)});
  tl.set("#fxlock", { opacity: ${fx.lockflash || 0.55} }, ${(lockT - 0.083).toFixed(3)});
  tl.to("#fxlock", { opacity: 0, duration: 0.2, ease: "expo.out" }, ${lockT.toFixed(3)});`;
  }
  return { css, html, js };
}

/* =====================================================================
 * ASSEMBLE + POSTFX
 * ===================================================================== */
function paradigmLastpage() {
  // THE LAST PAGE: a blurred "future manuscript" haunts the room all video;
  // at the apex one rack-focus reveals every faint line was the apex word.
  const b = dna.body,
    I = heroIn;
  const prnd = (() => {
    let a = b.seed || 4242;
    return () => {
      a |= 0;
      a = (a + 0x6d2b79f5) | 0;
      let t = Math.imul(a ^ (a >>> 15), 1 | a);
      t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
      return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
    };
  })();
  // field instances: seeded positions in the upper room, away from the subject column
  const subjX = sz && sz.heroAnchor ? (W * sz.heroAnchor.centerXPct) / 100 : W * 0.5;
  const inst = [];
  for (let i = 0; i < (b.fieldCount || 9); i++) {
    let x = 80 + prnd() * (W - 160),
      y = 60 + prnd() * (H * 0.55);
    if (Math.abs(x - subjX) < 170)
      x = x < subjX ? subjX - 180 - prnd() * 120 : subjX + 180 + prnd() * 120;
    inst.push({
      x: Math.round(Math.max(70, Math.min(W - 70, x))),
      y: Math.round(y),
      px: Math.round(34 + prnd() * 96),
      rot: +((prnd() - 0.5) * 8).toFixed(1),
      op: +(0.26 + prnd() * 0.2).toFixed(2),
    });
  }
  const main = inst.reduce((a, c) => (c.px > a.px ? c : a), inst[0]);
  const lineData = LINES.map((L) => ({
    id: "m" + L.id,
    in: +L.in.toFixed(3),
    out: +L.out.toFixed(3),
    words: L.words.map((w) => [w.display, +w.start.toFixed(3)]),
  }));
  const css = `
  .fld { position:absolute; white-space:nowrap; font-family:'${dna.fonts.body}', serif;
         font-weight:600; color:${dna.palette.body}; filter: blur(${b.fieldBlur || 9}px); }
  .ms  { position:absolute; left:${W / 2}px; top:${H - (b.bottomPx || 96)}px; opacity:0; white-space:nowrap;
         font-family:'${dna.fonts.body}', serif; font-size:${b.fontPx}px; line-height:1;
         color:${dna.palette.body}; text-shadow: 0 2px 12px rgba(0,0,0,0.6); }
  .ms .w { display:inline-block; opacity:0; margin:0 0.14em; overflow:hidden; vertical-align:bottom; white-space:nowrap; }`;
  const html =
    inst
      .map(
        (f, i) =>
          `      <div class="fld" id="f${i}" style="left:${f.x}px; top:${f.y}px; font-size:${f.px}px; opacity:${f.op}; transform:translate(-50%,-50%) rotate(${f.rot}deg)">${esc(heroDisplay)}</div>`,
      )
      .join("\n") +
    "\n" +
    lineData.map((L) => `      <div class="ms" id="${L.id}"></div>`).join("\n");
  const js = `
  // ---- THE LAST PAGE ----
  const I = ${I.toFixed(3)};
  const MS = ${J(lineData)};
  MS.forEach((L) => {
    const line = document.getElementById(L.id);
    L.words.forEach(([txt]) => { const sp = document.createElement("span"); sp.className = "w"; sp.textContent = txt; line.appendChild(sp); });
    gsap.set(line, { xPercent: -50, yPercent: -100 });
    tl.set(line, { opacity: 1 }, L.in);
    const XO = L.out - ${b.exit === "drop" ? "0.18" : "0.17"};   // exit start (single source)
    let lastB = -1;                                              // line-y bounce ownership guard
    L.words.forEach(([txt, st], wi) => {
      const el = line.children[wi];
      tl.set(el, { opacity: 1 }, st);
      tl.fromTo(el, { width: 0 }, { width: "auto", duration: Math.min(0.18, 0.03 * txt.length + 0.04),
        ease: "steps(" + Math.max(2, txt.length) + ")" }, st);
    });
    const xo = L.out - 0.18;
    tl.to(line, { opacity: 0, duration: 0.16, ease: "power2.in" }, xo);
    tl.set(line, { display: "none" }, xo + 0.18);
  });
  // the field breathes imperceptibly (alive, unreadable)
  ${inst.map((f, i) => `tl.to("#f${i}", { y: ${prnd() - 0.5 > 0 ? "+" : "-"}${(3 + prnd() * 5).toFixed(1)}, duration: ${(2.4 + prnd() * 2).toFixed(1)}, ease: "sine.inOut" }, 0);`).join("\n  ")}
  // APEX: rack focus — the future was only ever one sentence
  tl.to(".fld", { filter: "blur(0px)", duration: 0.38, ease: "power3.inOut" }, I - 0.1);
  ${inst
    .map((f, i) =>
      i === inst.indexOf(main)
        ? `tl.to("#f${i}", { opacity: 1, scale: 1.22, duration: 0.5, ease: "power2.out" }, I + 0.15);
  tl.to("#f${i}", { textShadow: "0 0 28px rgba(255,244,220,0.5)", duration: 0.5 }, I + 0.15);`
        : `tl.to("#f${i}", { opacity: ${(f.op * 0.85).toFixed(2)}, duration: 0.5 }, I + 0.3);`,
    )
    .join("\n  ")}
  // hold revealed ~1.2s, then the future blurs back except the main instance
  tl.to(".fld", { filter: "blur(${b.fieldBlur || 9}px)", duration: 0.6, ease: "power2.inOut" }, I + 1.5);
  tl.to("#f${inst.indexOf(main)}", { filter: "blur(0px)", duration: 0.01 }, I + 1.5);
  ${inst.map((f, i) => (i === inst.indexOf(main) ? "" : `tl.to("#f${i}", { opacity: 0, duration: 0.6 }, I + 1.6);`)).join("\n  ")}
  tl.to("#f${inst.indexOf(main)}", { opacity: 0, duration: 0.3, ease: "power2.in" }, ${(heroOut - 0.3).toFixed(3)});`;
  return { css, html, js };
}

function paradigmFlaprail() {
  // FLAPRAIL: stadium-departures chyron bar; body words flip in on split-flap
  // tiles (2 seeded wrong-word flickers then the real word clacks on), exits
  // flip to blank cascading left→right. Furniture: bar tag + ROW flip counter
  // (+ optional odometer chip pinned to the first emphasis line).
  const b = dna.body;
  const barH = b.barH || 96;
  const stagger = 0.025,
    exDu = 0.075;
  const lineData = LINES.map((L) => ({
    id: "fl" + L.id,
    in: +L.in.toFixed(3),
    out: +L.out.toFixed(3),
    words: L.words.map((w) => [w.display.toUpperCase(), +w.start.toFixed(3), w.minor ? 1 : 0]),
  }));
  const chy = b.chyron || {};
  const ctrVals = lineData.map((_, i) => String(i + 1).padStart(2, "0"));
  // odometer chip docks above the bar while the FIRST emphasis line reads
  const chipL = b.chip ? LINES.find((L) => L.words.some((w) => w.minor)) : null;
  const chipParts = chipL ? String(b.chip.value).split(".") : [];
  const css = `
  #flwrap { position:absolute; inset:0; }
  #flbar { position:absolute; left:0; right:0; top:${H - barH}px; height:${barH}px;
           background: linear-gradient(180deg, rgba(26,29,35,0.93) 0%, rgba(15,17,21,0.96) 100%);
           border-top:3px solid ${dna.palette.accent};
           box-shadow: 0 -6px 26px rgba(0,0,0,0.45), inset 0 1px 0 ${dna.palette.accent}40; }
  #fltag { position:absolute; left:30px; top:30px; font-family:'${dna.fonts.tag}', sans-serif;
           font-weight:600; font-size:25px; letter-spacing:4px; color:${dna.palette.accent}; opacity:0.92; }
  #flctr { position:absolute; right:30px; top:22px; white-space:nowrap; }
  #flctr .clab { font-family:'${dna.fonts.tag}', sans-serif; font-weight:600; font-size:22px;
                 letter-spacing:3px; color:#8A8F98; vertical-align:18px; margin-right:8px; }
  #ctrbox { display:inline-block; width:54px; height:46px; perspective:260px; vertical-align:top; }
  #ctrflap { position:relative; display:block; width:100%; height:100%; transform-origin:50% 0%;
             backface-visibility:hidden; border-radius:3px;
             background: linear-gradient(180deg,#2E333B 0%,#262B32 48%,#1B1F25 52%,#242930 100%);
             box-shadow: inset 0 0 0 1px rgba(0,0,0,0.6), 0 2px 5px rgba(0,0,0,0.5); }
  #ctrflap .nv { position:absolute; inset:0; display:none; text-align:center;
                 font-family:'${dna.fonts.tag}', sans-serif; font-weight:600; font-size:36px;
                 line-height:50px; color:${dna.palette.accent}; }
  .flline { position:absolute; left:${W / 2}px; top:${H - Math.round(barH / 2) - 1}px; white-space:nowrap; }
  .ftile { display:inline-block; perspective:240px; opacity:0; margin-right:8px; vertical-align:middle; }
  .fflap { position:relative; display:inline-block; transform-origin:50% 0%; backface-visibility:hidden;
           font-family:'${dna.fonts.body}', sans-serif; font-weight:600; font-size:${b.fontPx}px; line-height:1;
           letter-spacing:${b.letterSpacing || "1px"}; color:${dna.palette.body}; padding:8px 13px 4px; border-radius:4px;
           background: linear-gradient(180deg,#31363F 0%,#272C33 47%,#1C2026 53%,#262B32 100%);
           box-shadow: inset 0 0 0 1px rgba(0,0,0,0.55), 0 3px 8px rgba(0,0,0,0.5),
                       inset 0 1px 0 rgba(255,255,255,0.07);
           text-shadow: 0 2px 4px rgba(0,0,0,0.45); }
  .ftile.em .fflap { box-shadow: inset 0 0 0 1px rgba(0,0,0,0.55), inset 0 -3px 0 ${dna.palette.accent},
                     0 3px 8px rgba(0,0,0,0.5); }
  .fflap .rl { visibility:hidden; }
  .fflap .wv { position:absolute; left:0; right:0; top:8px; text-align:center; display:none;
               color:${dna.palette.grey || "#B9BEC7"}; }
${
  chipL
    ? `  #flchip { position:absolute; left:${b.chip.x}px; top:${b.chip.y}px; perspective:300px; opacity:0; }
  #chipflap { display:block; transform-origin:50% 0%; backface-visibility:hidden; white-space:nowrap;
              background: linear-gradient(180deg,#2B3038 0%,#22262D 50%,#1A1E24 100%);
              border-radius:4px; border:1px solid rgba(0,0,0,0.6); padding:5px 12px 3px;
              box-shadow: inset 0 0 0 1px ${dna.palette.accent}59, 0 3px 10px rgba(0,0,0,0.5); }
  #chipflap .clab { font-family:'${dna.fonts.tag}', sans-serif; font-weight:600; font-size:24px;
                    letter-spacing:2px; color:${dna.palette.accent}; margin-right:8px; vertical-align:4px; }
  .dcol { display:inline-block; height:36px; overflow:hidden; vertical-align:top; }
  .dcol span { display:block; height:36px; line-height:38px; font-family:'${dna.fonts.tag}', sans-serif;
               font-weight:600; font-size:34px; color:${dna.palette.body}; text-align:center; }
  .cfix { display:inline-block; height:36px; line-height:38px; font-family:'${dna.fonts.tag}', sans-serif;
          font-weight:600; font-size:34px; color:${dna.palette.body}; vertical-align:top; }
  #cunit { font-size:22px; color:#8A8F98; margin-left:3px; }
  #bdot { display:inline-block; width:9px; height:9px; border-radius:50%;
          background:${dna.palette.accent}; margin-left:9px; vertical-align:14px; opacity:0.15;
          box-shadow: 0 0 7px ${dna.palette.accent}cc; }`
    : ""
}`;
  const html = `      <div id="flwrap">
        <div id="flbar">
          <div id="fltag">${esc(chy.tag || "DEPARTURES")}</div>
          <div id="flctr"><span class="clab">${esc(chy.counter || "ROW")}</span><span id="ctrbox"><span id="ctrflap">${ctrVals
            .map((v, i) => `<span class="nv" id="n${i}">${v}</span>`)
            .join("")}</span></span></div>
        </div>
${lineData.map((L) => `        <div class="flline" id="${L.id}"></div>`).join("\n")}
${
  chipL
    ? `        <div id="flchip"><div id="chipflap"><span class="clab">${esc(b.chip.label)}</span>${chipParts
        .map((_, i) => `<span class="dcol" id="dc${i}"></span>`)
        .join(
          `<span class="cfix">.</span>`,
        )}<span class="cfix" id="cunit">${esc(b.chip.unit || "")}</span><span id="bdot"></span></div></div>`
    : ""
}
      </div>`;
  const js = `
  // ---- body paradigm: FLAPRAIL (split-flap word tiles on a chyron bar) ----
  const HOT = ${J(dna.palette.hot || dna.palette.accent)}, BONE = ${J(dna.palette.body)};
  const frnd2 = mulberry32(${b.seed || 808});
  const GLYPHS = ${J(dna.body.glyphs || "ABCDEFGHIKLMNOPRSTUVXYZ0123456789")};
  function wrongOf(txt) {
    let s = "";
    for (const c of txt) s += /[A-Z']/.test(c) ? GLYPHS[Math.floor(frnd2() * GLYPHS.length)] : c;
    return s;
  }
  // chyron bar rides up; nothing visible at t=0
  tl.fromTo("#flbar", { y: ${barH + 14} }, { y: 0, duration: 0.20, ease: "power3.out" }, 0.0);
  tl.set("#n0", { display: "block" }, 0.18);
  const FRAIL = ${J(lineData)};
  FRAIL.forEach((L, li) => {
    const line = document.getElementById(L.id);
    const tiles = [];
    L.words.forEach(([txt, , em]) => {
      const tile = document.createElement("span"); tile.className = "ftile" + (em ? " em" : "");
      const flap = document.createElement("span"); flap.className = "fflap";
      const rl = document.createElement("span"); rl.className = "rl"; rl.textContent = txt;
      const w1 = document.createElement("span"); w1.className = "wv"; w1.textContent = wrongOf(txt);
      const w2 = document.createElement("span"); w2.className = "wv"; w2.textContent = wrongOf(txt);
      flap.appendChild(rl); flap.appendChild(w1); flap.appendChild(w2);
      tile.appendChild(flap); line.appendChild(tile);
      tiles.push({ tile, flap, rl, w1, w2 });
    });
    gsap.set(line, { xPercent: -50, yPercent: -50 });
    L.words.forEach(([txt, st, em], wi) => {
      const { tile, flap, rl, w1, w2 } = tiles[wi];
      // flick 1 (wrong), flick 2 (wrong), flick 3 (real) — the flap motif
      tl.set(tile, { opacity: 1 }, st);
      tl.set(w1, { display: "block" }, st);
      tl.fromTo(flap, { rotationX: -88 }, { rotationX: 0, duration: 0.05, ease: "power1.in" }, st);
      tl.set(w1, { display: "none" }, st + 0.055);
      tl.set(w2, { display: "block" }, st + 0.055);
      tl.set(flap, { rotationX: -88 }, st + 0.055);
      tl.to(flap, { rotationX: 0, duration: 0.05, ease: "power1.in" }, st + 0.06);
      tl.set(w2, { display: "none" }, st + 0.115);
      tl.set(rl, { visibility: "visible" }, st + 0.115);
      tl.set(flap, { rotationX: -88, color: HOT }, st + 0.115);
      tl.to(flap, { rotationX: 0, duration: 0.065, ease: "power2.in" }, st + 0.12);
      // contact squash → elastic settle, paint cools (amber stays on emphasized tiles)
      tl.set(tile, { scaleX: 1.05, scaleY: 0.92 }, st + 0.185);
      tl.to(tile, { scaleX: 1, scaleY: 1, duration: 0.28, ease: "elastic.out(1, 0.4)" }, st + 0.21);
      tl.to(flap, { color: em ? HOT : BONE, duration: 0.35, ease: "power1.in" }, st + 0.25);
    });
    // hold life: low breathe on the line
    const lastIn = L.words[L.words.length - 1][1] + 0.4;
    const span = (L.words.length - 1) * ${stagger} + ${exDu} + 0.02;
    const XO = L.out - span;
    if (XO - lastIn > 0.3)
      tl.to(line, { scale: 1.014, duration: XO - lastIn, ease: "sine.inOut" }, lastIn);
    // exit: flip to blank, cascading left→right (completes before the next line)
    L.words.forEach((w, wi) => {
      const t = XO + wi * ${stagger};
      tl.to(tiles[wi].flap, { rotationX: 90, duration: ${exDu}, ease: "power2.in" }, t);
      tl.set(tiles[wi].tile, { opacity: 0 }, t + ${exDu});
    });
    tl.set(line, { display: "none" }, L.out);
  });
  // row counter flips at each line change
  FRAIL.slice(1).forEach((L, k) => {
    const t = L.words[0][1];
    tl.set("#n" + k, { display: "none" }, t);
    tl.set("#n" + (k + 1), { display: "block" }, t);
    // immediateRender:false — the flap is visible from t=0; the from-state must
    // not leak into the opening hold (paused timeline + seek renders it)
    tl.fromTo("#ctrflap", { rotationX: -80 }, { rotationX: 0, duration: 0.07, ease: "power2.in", immediateRender: false }, t);
    tl.set("#ctrflap", { scaleY: 0.92 }, t + 0.07);
    tl.to("#ctrflap", { scaleY: 1, duration: 0.18, ease: "elastic.out(1, 0.45)" }, t + 0.09);
  });
${
  chipL
    ? `  // odometer chip (emphasis furniture): clacks on beside the emphasis line,
  // digits roll on true reels, status dot blinks through the hold (dead-air life)
  const CIN = ${(chipL.words[chipL.words.length - 1].start + 0.06).toFixed(3)};
  const COUT = ${(+chipL.out).toFixed(3)};
  ${J(chipParts)}.forEach((fin, ci) => {
    const col = document.getElementById("dc" + ci);
    const stack = document.createElement("div");
    const steps = 4 + ci;
    for (let k = 0; k < steps; k++) { const s = document.createElement("span");
      s.textContent = Math.floor(frnd2() * 10); stack.appendChild(s); }
    const f = document.createElement("span"); f.textContent = fin; stack.appendChild(f);
    col.appendChild(stack);
    tl.fromTo(stack, { y: 0 }, { y: -36 * steps, duration: 0.42 + ci * 0.13,
              ease: "steps(" + steps + ")" }, CIN + 0.08 + ci * 0.04);
  });
  tl.set("#flchip", { opacity: 1 }, CIN);
  tl.fromTo("#chipflap", { rotationX: -85 }, { rotationX: 0, duration: 0.09, ease: "power2.in" }, CIN);
  tl.set("#chipflap", { scaleX: 1.04, scaleY: 0.93 }, CIN + 0.09);
  tl.to("#chipflap", { scaleX: 1, scaleY: 1, duration: 0.24, ease: "elastic.out(1, 0.42)" }, CIN + 0.11);
  tl.set(".dcol span", { color: HOT }, CIN + 0.67);
  tl.to(".dcol span", { color: BONE, duration: 0.4 }, CIN + 0.87);
  for (let k = 0; k < 4; k++) {
    const t = CIN + 0.64 + k * 0.22;
    if (t + 0.11 < COUT - 0.05) { tl.set("#bdot", { opacity: 1 }, t); tl.set("#bdot", { opacity: 0.15 }, t + 0.11); }
  }
  tl.to("#chipflap", { rotationX: 88, duration: 0.09, ease: "power2.in" }, COUT);
  tl.set("#flchip", { display: "none" }, COUT + 0.10);`
    : ""
}
${
  b.yield
    ? `  // rail yields while the apex board locks
  tl.to("#flwrap", { opacity: ${b.yield.dim}, duration: 0.18, ease: "power1.in" }, ${(heroIn - (b.yield.pre || 0.07)).toFixed(3)});
  tl.to("#flwrap", { opacity: 1, duration: 0.22, ease: "power1.out" }, ${(heroIn + (b.yield.post || 0.47)).toFixed(3)});`
    : ""
}`;
  return { css, html, js };
}

function paradigmLedboard() {
  // LEDBOARD: docked transit departure-strip; body lines arrive by vertical
  // SCROLL-SNAP paging (steps(6)) inside the board window; each word lights via
  // a small L→R LED column wipe (low-amplitude apex motif) + 1-frame arrival
  // flicker; emphasis words get the LED-refresh double-blink. Furniture: green
  // platform tag, ticking clock seconds, status swap gag (⚠ DELAYED → ● ON TIME
  // shortly after the apex resolves), idle LED shimmer in dead air.
  const b = dna.body;
  const bd = b.board || {};
  const bw = bd.width || 1000,
    bh = bd.h || 116;
  const bleft = bd.left ?? Math.round((W - bw) / 2);
  const btop = H - (bd.bottom ?? 30) - bh;
  const hdrH = 30,
    winH = bh - hdrH - 2,
    lineH = winH - 2;
  const hd = b.header || {};
  const A = dna.palette.accent;
  const GRN = dna.palette.green || "#36e57a";
  const WRN = dna.palette.warn || "#ff9e3d";
  const lineData = LINES.map((L) => ({
    id: "t" + L.id,
    words: L.words.map((w) => [w.display, +w.start.toFixed(3), w.minor ? 1 : 0]),
  }));
  // page-in ~0.2s before the first word lights (the paging IS the anticipation);
  // each line pages out exactly as the next pages in (snap swap), last one early
  // enough that the 0.20s page-out completes before the clip ends
  const pins = LINES.map((L, i) =>
    i === 0
      ? Math.max(0.02, +(L.words[0].start - 0.22).toFixed(3))
      : +(L.words[0].start - 0.22).toFixed(3),
  );
  lineData.forEach((L, i) => {
    L.pin = pins[i];
    L.pout = i + 1 < pins.length ? pins[i + 1] : +Math.min(LINES[i].out, DUR - 0.22).toFixed(3);
  });
  // idle LED shimmer: any dead air ≥1.2s between word lights gets refresh blips
  const starts = LINES.flatMap((L) => L.words.map((w) => w.start));
  const shimmers = [];
  for (let i = 0; i + 1 < starts.length; i++)
    if (starts[i + 1] - starts[i] > 1.2)
      for (let t = starts[i] + 0.43; t < starts[i + 1] - 0.55; t += 0.6)
        shimmers.push(+t.toFixed(3));
  // status gag: ⚠ DELAYED blinks while we wait, pages to ● ON TIME after the apex
  const swapT = +Math.min(heroIn + 0.69, DUR - 1.2).toFixed(3);
  const statBlinks = [swapT - 2.55, swapT - 1.75, swapT - 1.15]
    .filter((t) => t > 0.25)
    .map((t) => +t.toFixed(3));
  // clock seconds reel: one stacked digit per elapsed second
  const nDig = Math.floor(DUR - 0.02) + 1;
  const lateT = +(LASTWORD.start + 0.19).toFixed(3); // one late LED refresh on the final word
  const css = `
  #tboard { position:absolute; left:${bleft}px; top:${btop}px; width:${bw}px; height:${bh}px; border-radius:6px;
           background-color: rgba(8,8,11,0.90);
           background-image: radial-gradient(${A}0d 1px, rgba(0,0,0,0) 1.4px);
           background-size: 7px 7px;
           border:1px solid ${A}47; border-top:2px solid ${A}73;
           box-shadow: 0 10px 30px rgba(0,0,0,0.5), inset 0 0 40px ${A}0a; }
  #thdr { position:absolute; left:0; right:0; top:0; height:${hdrH}px;
          border-bottom:1px solid ${A}38;
          font-family:'${dna.fonts.tag}', monospace; font-size:20px; letter-spacing:0.12em; }
  #tplt { position:absolute; left:16px; top:4px; color:${GRN};
          text-shadow:0 0 8px ${GRN}80; }
  #tstatwin { position:absolute; right:128px; top:3px; width:170px; height:24px; overflow:hidden; }
  #tstatstack { position:absolute; left:0; top:0; width:100%; }
  .tstrow { display:block; height:24px; line-height:24px; text-align:right; }
  #tstrow1 { color:${WRN}; text-shadow:0 0 8px ${WRN}80; }
  #tstrow2 { color:${GRN}; text-shadow:0 0 8px ${GRN}80; }
  #tclock { position:absolute; right:14px; top:4px; color:${A}bf;
            text-shadow:0 0 6px ${A}59; }
  #tclock .cw { display:inline-block; width:11px; height:22px; overflow:hidden;
                vertical-align:bottom; }
  #tdigcol span { display:block; height:22px; line-height:22px; }
  #twin { position:absolute; left:0; right:0; top:${hdrH + 2}px; bottom:0; overflow:hidden; }
  .tline { position:absolute; left:22px; top:0; height:${lineH}px; line-height:${lineH}px;
           font-family:'${dna.fonts.body}', monospace; font-size:${b.fontPx}px; letter-spacing:${b.letterSpacing || "0.05em"};
           color:${dna.palette.body}; white-space:nowrap;
           text-shadow: 0 0 9px ${A}73, 0 0 1px ${dna.palette.hot}e6; }
  .tline .w { display:inline-block; margin-right:0.35em; }`;
  const html = `      <div id="tboard">
        <div id="thdr">
          <div id="tplt">${esc(hd.plt || "PLT 4")}</div>
          <div id="tstatwin"><div id="tstatstack">
            <span class="tstrow" id="tstrow1">${esc(hd.statusWait || "⚠ DELAYED")}</span>
            <span class="tstrow" id="tstrow2">${esc(hd.statusOk || "● ON TIME")}</span>
          </div></div>
          <div id="tclock">${esc(hd.clockPrefix || "19:42:0")}<span class="cw"><span id="tdigcol">${Array.from(
            { length: nDig },
            (_, k) => `<span>${k}</span>`,
          ).join("")}</span></span></div>
        </div>
        <div id="twin"></div>
      </div>`;
  const js = `
  // ---- body paradigm: LEDBOARD (scroll-snap paging + per-word LED column wipes) ----
  const twin = document.getElementById("twin");
  const TRAIL = ${J(lineData)};
  let lastW = null;
  TRAIL.forEach((L) => {
    const line = document.createElement("div"); line.className = "tline";
    twin.appendChild(line);
    gsap.set(line, { y: ${winH + 2} });                                // parked below the window
    tl.to(line, { y: 0,   duration: 0.22, ease: "steps(6)" }, L.pin);  // page in
    tl.to(line, { y: ${-(winH + 2)}, duration: 0.20, ease: "steps(6)" }, L.pout); // page out
    L.words.forEach(([txt, st, em]) => {
      const w = document.createElement("span"); w.className = "w";
      w.textContent = txt; line.appendChild(w); lastW = w;
      const steps = Math.max(3, txt.length);
      tl.fromTo(w, { clipPath: "inset(0% 100% 0% 0%)" },
                { clipPath: "inset(0% 0% 0% 0%)", duration: 0.12, ease: "steps(" + steps + ")" }, st);
      tl.set(w, { clipPath: "none" }, st + 0.17);            // free the glow
      tl.set(w, { opacity: 0.5 }, st + 0.13);                // 1-frame arrival flicker
      tl.set(w, { opacity: 1 },   st + 0.172);
      if (em && st + 0.56 < L.pout) {                        // LED refresh: blinks twice
        tl.set(w, { opacity: 0.22 }, st + 0.30);
        tl.set(w, { opacity: 1 },    st + 0.383);
        tl.set(w, { opacity: 0.22 }, st + 0.466);
        tl.set(w, { opacity: 1 },    st + 0.55);
      }
    });
  });
${
  lateT + 0.05 < DUR - 0.25
    ? `  // the final word gets one late LED refresh blink (kept inside the clip)
  tl.set(lastW, { opacity: 0.22 }, ${lateT.toFixed(3)});
  tl.set(lastW, { opacity: 1 },    ${(lateT + 0.042).toFixed(3)});`
    : ""
}

  // ===== furniture =====
  // status blinks while we wait, then the gag: pages to the OK row
  ${J(statBlinks)}.forEach((t) => {
    tl.set("#tstrow1", { opacity: 0.35 }, t);
    tl.set("#tstrow1", { opacity: 1 },    t + 0.042);
  });
  tl.to("#tstatstack", { y: -24, duration: 0.16, ease: "steps(4)" }, ${swapT.toFixed(3)});
  tl.set("#tstrow2", { opacity: 0.5 }, ${(swapT + 0.18).toFixed(3)});
  tl.set("#tstrow2", { opacity: 1 },   ${(swapT + 0.222).toFixed(3)});

  // clock seconds tick (stacked digit column, stepped shifts)
  for (let k = 1; k <= ${nDig - 1}; k++) tl.set("#tdigcol", { y: -22 * k }, k);

  // idle LED refresh shimmer in the dead air (board never freezes)
  ${J(shimmers)}.forEach((t) => {
    tl.set("#twin", { opacity: 0.88 }, t);
    tl.set("#twin", { opacity: 1 },    t + 0.042);
  });
${
  b.yield
    ? `
  // ===== hierarchy: board yields while the apex lands =====
  tl.to("#tboard", { opacity: ${b.yield.dim}, duration: 0.18, ease: "power1.in" }, ${(heroIn - (b.yield.pre || 0.2)).toFixed(3)});
  tl.to("#tboard", { opacity: 1,   duration: 0.25, ease: "power1.out" }, ${(heroIn + (b.yield.post || 0.9)).toFixed(3)});`
    : ""
}`;
  return { css, html, js };
}

function paradigmVhsrail() {
  // VHSRAIL: camcorder-playback lower third. Every body word carries a PERMANENT
  // 1px red+cyan misregistration fringe and arrives by tracking glitch (instant
  // on + 2-frame y-shake + 1-frame band-slice shift of the word's bottom half);
  // emphasis words get the PAUSE jitter (±1px y oscillation) + a white noise-band
  // flicker. Exits are REWIND streaks left with a flat-white echo copy + scrub
  // lines. Furniture: ▶ PLAY / ◀◀ REW top-left (REW gag in dead air and on the
  // final rewind), ● REC top-right during the apex window, green timestamp
  // bottom-right with a 1 Hz blinking colon, an idle tape-drift line in long
  // silences, and the apex-coupled artifacts: 1-frame full white tear band +
  // head-switching noise bar at REC FREEZE, scrub lines at the apex band on the
  // hero's rewind exit.
  const b = dna.body;
  const p = dna.hero.params || {};
  const X = theme.hero.exitAt ?? Math.min(heroOut, heroIn + (p.hold ?? 1.69)); // hero REWIND (shared with vhsosd)
  const railTop = H - (b.bottomPx || 108);
  const osd = b.osd || {};
  const ts = osd.ts || ["JUN.11 2026 PM 7", "42"];
  const A = dna.palette.accent,
    GRN = dna.palette.green || dna.palette.accent,
    RED = dna.palette.red || "#FF2E2E",
    CYN = dna.palette.cyan || "#3FE8E8";
  // line windows: `out` is the REWIND start — early into dead air (with the
  // PLAY→REW gag), before the clip end for the last line
  const lineData = LINES.map((L, i) => {
    const lastEnd = L.words[L.words.length - 1].end;
    const next = LINES[i + 1];
    let out = +(L.out - 0.05).toFixed(3),
      gag = 0;
    if (next && next.in - lastEnd > 0.7) {
      out = +(lastEnd + 0.53).toFixed(3);
      gag = 1;
    }
    if (!next) out = +(DUR - 0.14).toFixed(3);
    return {
      id: "v" + L.id,
      eid: "ve" + L.id,
      in: +L.in.toFixed(3),
      out,
      gag,
      words: L.words.map((w) => [w.display, +w.start.toFixed(3), w.minor ? 1 : 0]),
    };
  });
  const lastOut = lineData[lineData.length - 1].out;
  const css = `
  /* ---- OSD furniture ---- */
  .osd { position:absolute; font-family:'${dna.fonts.tag}', monospace; font-size:28px; line-height:1;
         letter-spacing:2px; color:${A}; opacity:0;
         text-shadow: -1px 0 0 ${RED}59, 1px 0 0 ${CYN}59,
                      0 0 7px rgba(0,0,0,0.95), 0 2px 5px rgba(0,0,0,0.85); }
  #osdplay { left:36px; top:34px; }
  #pmode, #rmode { display:flex; align-items:center; gap:10px; }
  #rmode { display:none; }
  .tri { width:0; height:0; border-left:16px solid ${A};
         border-top:9px solid transparent; border-bottom:9px solid transparent;
         filter: drop-shadow(0 0 4px rgba(0,0,0,0.9)); }
  .rtri { width:0; height:0; border-right:14px solid ${A};
          border-top:8px solid transparent; border-bottom:8px solid transparent;
          filter: drop-shadow(0 0 4px rgba(0,0,0,0.9)); }
  #rmode .rtri + .rtri { margin-left:-6px; }
  #rec { right:36px; top:34px; display:flex; align-items:center; gap:10px; color:${dna.palette.body}; }
  #rdot { width:15px; height:15px; border-radius:50%; background:#ff3b30;
          box-shadow: 0 0 6px rgba(0,0,0,0.85), 0 0 10px rgba(255,59,48,0.55); }
  #osdts { right:36px; bottom:22px; color:${GRN};
           text-shadow: -1px 0 0 ${RED}4d, 1px 0 0 ${CYN}4d,
                        0 0 7px rgba(0,0,0,0.95), 0 2px 5px rgba(0,0,0,0.85); }

  /* ---- body rail ---- */
  .vln, .vlnE { position:absolute; left:${W / 2}px; top:${railTop}px; white-space:nowrap; opacity:0;
        font-family:'${dna.fonts.body}', monospace; font-size:${b.fontPx}px; line-height:1;
        letter-spacing:${b.letterSpacing || "0.04em"}; text-transform:uppercase;
        color:${dna.palette.body};
        text-shadow: -1px 0 0 ${RED}66, 1px 0 0 ${CYN}66,
                     0 2px 6px rgba(0,0,0,0.85), 0 0 14px rgba(0,0,0,0.55); }
  .vlnE { color:#ffffff; text-shadow:none; }
  .vw { position:relative; display:inline-block; margin-right:0.3em; opacity:0; }
  .vwa { display:inline-block; clip-path: inset(0 0 56% 0); }
  .vwb { position:absolute; left:0; top:0; clip-path: inset(44% 0 0 0); }
  .vnz { position:absolute; left:-4px; right:-4px; top:30%; height:38%; opacity:0;
        background: repeating-linear-gradient(0deg, rgba(255,255,255,0.85) 0 2px, rgba(255,255,255,0) 2px 5px); }

  /* ---- tape artifacts ---- */
  #tear { position:absolute; left:0; top:${HG.y - 55}px; width:100%; height:110px; opacity:0;
          background: linear-gradient(180deg, rgba(255,255,255,0.55) 0%, #ffffff 30%, #ffffff 70%, rgba(255,255,255,0.55) 100%); }
  #hsbar { position:absolute; left:0; bottom:0; width:100%; height:16px; opacity:0;
           background: repeating-linear-gradient(90deg,
             rgba(205,205,205,0.92) 0 7px, rgba(60,60,60,0.85) 7px 11px,
             rgba(165,165,165,0.9) 11px 19px, rgba(28,28,28,0.85) 19px 26px); }
  .scrub { position:absolute; left:0; width:100%; height:3px; opacity:0;
           background: linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.75) 8%,
             rgba(225,225,225,0.5) 38%, rgba(255,255,255,0.8) 62%, rgba(255,255,255,0) 100%); }
  #scrub1 { top:${railTop - 18}px; } #scrub2 { top:${railTop + 20}px; }
  #scrub3 { top:${HG.y - 28}px; } #scrub4 { top:${HG.y + 34}px; }
  #drift { position:absolute; left:0; top:${railTop + 48}px; width:100%; height:2px; opacity:0;
           background: rgba(255,255,255,0.45); }`;
  const html =
    `      <div class="osd" id="osdplay">
        <div id="pmode"><div class="tri"></div><span>PLAY</span></div>
        <div id="rmode"><div class="rtri"></div><div class="rtri"></div><span>REW</span></div>
      </div>
      <div class="osd" id="rec"><div id="rdot"></div><span>REC</span></div>
      <div class="osd" id="osdts">${esc(ts[0])}<span id="col">:</span>${esc(ts[1])}</div>
` +
    lineData
      .map(
        (L) => `      <div class="vln" id="${L.id}"></div><div class="vlnE" id="${L.eid}"></div>`,
      )
      .join("\n") +
    `
      <div id="drift"></div>
      <div class="scrub" id="scrub1"></div><div class="scrub" id="scrub2"></div>
      <div class="scrub" id="scrub3"></div><div class="scrub" id="scrub4"></div>
      <div id="tear"></div>
      <div id="hsbar"></div>`;
  // PLAY → REW gags: each dead-air rewind + the final rewind (no restore)
  const gags = lineData
    .filter((L) => L.gag)
    .map((L) => [+(L.out - 0.01).toFixed(3), +(L.out + 0.35).toFixed(3)]);
  gags.push([+(lastOut - 0.01).toFixed(3), null]);
  const js = `
  // ---- body paradigm: VHSRAIL (tracking-glitch words, REWIND exits, OSD furniture) ----
  const I = ${heroIn.toFixed(3)}, X = ${X.toFixed(3)};

  // ===== OSD furniture: present the whole clip, 1-frame power-on glitch =====
  tl.set(["#osdplay","#osdts"], { opacity: 1, x: 5 }, 0.02);
  tl.set(["#osdplay","#osdts"], { x: 0 }, 0.02 + F);
  // timestamp colon blinks at 1 Hz
  for (let k = 0; 0.50 + k < ${(DUR - 0.04).toFixed(2)}; k++) {
    tl.set("#col", { opacity: 0 }, 0.50 + k);
    if (1.00 + k < ${(DUR - 0.04).toFixed(2)}) tl.set("#col", { opacity: 1 }, 1.00 + k);
  }
  // PLAY -> REW gag on each dead-air rewind, and again at the final rewind
  ${J(gags)}.forEach(([a, b]) => {
    tl.set("#pmode", { display: "none" }, a);
    tl.set("#rmode", { display: "flex" }, a);
    if (b) { tl.set("#rmode", { display: "none" }, b); tl.set("#pmode", { display: "flex" }, b); }
  });

  // ===== body rail =====
  const VRAIL = ${J(lineData)};
  VRAIL.forEach((L) => {
    const line = document.getElementById(L.id);
    const echo = document.getElementById(L.eid);
    echo.textContent = L.words.map((w) => w[0]).join(" ");
    L.words.forEach(([txt]) => {
      const w = document.createElement("span"); w.className = "vw";
      const a = document.createElement("span"); a.className = "vwa"; a.textContent = txt;
      const b2 = document.createElement("span"); b2.className = "vwb"; b2.textContent = txt;
      const n = document.createElement("span"); n.className = "vnz";
      w.appendChild(a); w.appendChild(b2); w.appendChild(n); line.appendChild(w);
    });
    gsap.set([line, echo], { xPercent: -50, yPercent: -50 });
    tl.set(line, { opacity: 1 }, L.words[0][1] - 0.02);

    L.words.forEach(([txt, st, em], wi) => {
      const w = line.children[wi];
      const b2 = w.children[1], n = w.children[2];
      // tracking glitch entrance: instant on, 2-frame y-shake, 1-frame band slice shift
      tl.set(w, { opacity: 1, y: 3 }, st);
      tl.set(b2, { x: 7 }, st);
      tl.set(w, { y: -3 }, st + F);
      tl.set(b2, { x: 0 }, st + F);
      tl.set(w, { y: 0 }, st + 2 * F);
      if (em) {
        // PAUSE jitter: y oscillation +-1px for 0.3s + white noise band flicker
        const e = st + 0.125;
        [-1, 1, -1, 1, 0].forEach((v, k) => tl.set(w, { y: v }, e + k * 0.05));
        [0.5, 0, 0.35, 0].forEach((o, k) => tl.set(n, { opacity: o }, e + k * 0.065));
      }
    });

    // REWIND exit: streak left + echo copy + 2 scrub lines
    const ed = L.out >= ${(DUR - 0.25).toFixed(2)} ? 0.12 : 0.16;
    tl.to(line, { x: -60, opacity: 0, duration: 0.12, ease: "power2.in" }, L.out);
    tl.set(echo, { opacity: 0.35 }, L.out + 0.01);
    tl.to(echo, { x: -110, opacity: 0, duration: ed, ease: "power2.in" }, L.out + 0.02);
    tl.set([line, echo], { display: "none" }, Math.min(L.out + 0.22, ${(DUR - 0.02).toFixed(2)}));
    [0.6, 0.25, 0.5, 0].forEach((o, k) => {
      tl.set(["#scrub1","#scrub2"], { opacity: o }, Math.min(L.out + k * F, ${(DUR - 0.02).toFixed(2)}));
    });
  });
${
  b.yield
    ? `
  // rail yields while the apex lands (dim clamped ≥ line-in; restore only with runway)
  VRAIL.forEach((L) => {
    if (L.in < I + 0.9 && L.out > I - 0.3) {
      const dimT = Math.max(I - ${(b.yield.pre || 0.2).toFixed(2)}, L.in + 0.05);
      if (L.out > dimT + 0.25) {
        tl.to("#" + L.id, { opacity: ${b.yield.dim}, duration: 0.12, ease: "power1.in" }, dimT);
        const resT = I + ${(b.yield.post || 0.9).toFixed(2)};
        if (resT + 0.3 < L.out) tl.to("#" + L.id, { opacity: 1, duration: 0.25 }, resT);
      }
    }
  });`
    : ""
}

  // idle tape noise in dead air
  VRAIL.filter((L) => L.gag).forEach((L) => {
    tl.set("#drift", { opacity: 0.12 }, L.out + 0.2);
    tl.fromTo("#drift", { y: 0 }, { y: -55, duration: 0.5, ease: "none" }, L.out + 0.2);
    tl.set("#drift", { opacity: 0 }, L.out + 0.7);
  });

  // ===== REC FREEZE artifacts (apex-coupled, in FRONT of the subject) =====
  // 1-frame full white tear band
  tl.set("#tear", { opacity: 0.95 }, I);
  tl.set("#tear", { opacity: 0 }, I + F);
  // ● REC blinks top-right (1-frame glitch entrance)
  tl.set("#rec", { opacity: 1, x: 6 }, I + 0.04);
  tl.set("#rec", { x: 0 }, I + 0.04 + F);
  [[0.49, 0], [0.79, 1], [1.24, 0], [1.54, 1]].forEach(([dt, o]) => {
    if (I + dt < X - 0.05) tl.set("#rdot", { opacity: o }, I + dt);
  });
  tl.set("#rec", { opacity: 0 }, X);
  // head-switching noise bar: sets every frame for 10 frames
  const HSX = [0, 140, 60, 220, 30, 180, 90, 250, 10, 120];
  HSX.forEach((px, k) => {
    tl.set("#hsbar", { opacity: k % 2 ? 0.62 : 0.9, backgroundPositionX: px + "px" }, I + k * F);
  });
  tl.set("#hsbar", { opacity: 0 }, I + 10 * F);

  // apex REWIND exit: 2 scrub lines at the apex band
  [0.6, 0.25, 0.5, 0].forEach((o, k) => tl.set(["#scrub3","#scrub4"], { opacity: o }, X + k * F));
  // head-switch reprise on the final rewind
  [[0, 0.85, 40], [0.04, 0.6, 190], [0.08, 0.8, 90]].forEach(([dt, o, px]) => {
    tl.set("#hsbar", { opacity: o, backgroundPositionX: px + "px" }, ${lastOut.toFixed(3)} + dt);
  });
  tl.set("#hsbar", { opacity: 0 }, ${Math.min(lastOut + 0.12, DUR - 0.02).toFixed(3)});`;
  return { css, html, js };
}

function paradigmHudrail() {
  // HUDRAIL: arcade-cabinet HUD. The whole frame plays through a CRT (scanline
  // + vignette overlays); body words coin-pop onto a docked HUD bar (2px accent
  // border, 1UP / CREDIT tags) with steps(2) pixel pops + a 1-frame white
  // coin-blip tick over the word; emphasis words run a 3-frame palette swap
  // (body -> accent -> magenta -> body). Lines exit by checkerboard dissolve
  // (words vanish in 2 alternating 2-frame passes). The 1UP tag blinks on the
  // classic attract cadence all clip; the HUD yields while the boss lands.
  const b = dna.body;
  const bar = b.bar || {};
  const barH = bar.h || 70,
    inset = bar.inset || 24,
    barBot = bar.bottom || 50;
  const barTop = H - barBot - barH;
  const tags = b.tags || {};
  const A = dna.palette.accent,
    MG = dna.palette.magenta || "#FF2E88";
  const lineData = LINES.map((L, i) => ({
    id: "h" + L.id,
    in: +L.in.toFixed(3),
    // checkerboard exit start: just before the next line's first pop
    xo: i + 1 < LINES.length ? +(L.out - 0.04).toFixed(3) : +Math.min(L.out, DUR - 0.11).toFixed(3),
    words: L.words.map((w) => [w.display, +w.start.toFixed(3), w.minor ? 1 : 0]),
  }));
  const css = `
  #hud   { position:absolute; left:${inset}px; top:${barTop}px; width:${W - 2 * inset}px; height:${barH}px;
           opacity:0; background:rgba(3,8,18,0.84); border:2px solid ${A};
           box-shadow:0 0 16px ${A}59, inset 0 0 24px ${A}14; z-index:3; }
  .hln   { position:absolute; left:50%; top:50%; white-space:nowrap; opacity:0;
           font-family:'${dna.fonts.body}', monospace; font-size:${b.fontPx}px; line-height:1;
           ${b.textTransform ? "text-transform:" + b.textTransform + ";" : ""}
           color:${dna.palette.body}; text-shadow:0 3px 0 rgba(0,0,0,0.6); }
  .hw    { position:relative; display:inline-block; margin-right:0.45em; opacity:0; }
  .hw:last-child { margin-right:0; }
  .htick { position:absolute; inset:-3px; background:#ffffff; opacity:0; }
  #tagL  { position:absolute; left:18px; top:50%; transform:translateY(-50%);
           font-family:'${dna.fonts.tag}', monospace; font-size:15px; color:${dna.palette.yellow || A};
           text-shadow:0 2px 0 rgba(0,0,0,0.6); }
  #tagR  { position:absolute; right:18px; top:50%; transform:translateY(-50%);
           font-family:'${dna.fonts.tag}', monospace; font-size:12px; color:${dna.palette.credit || A};
           text-shadow:0 2px 0 rgba(0,0,0,0.6); }
  #crtvig  { position:absolute; inset:0; opacity:0; z-index:20; pointer-events:none;
           background:radial-gradient(120% 95% at 50% 46%, rgba(0,0,0,0) 58%, rgba(0,0,0,0.34) 100%); }
  #crtscan { position:absolute; inset:0; opacity:0; z-index:21; pointer-events:none;
           background:repeating-linear-gradient(180deg, rgba(0,0,0,0.9) 0px, rgba(0,0,0,0.9) 2px, rgba(0,0,0,0) 2px, rgba(0,0,0,0) 5px); }`;
  const html = `      <div id="hud">
        <div id="tagL">${esc(tags.left || "1UP")}</div>
        <div id="tagR">${esc(tags.right || "CREDIT 01")}</div>
${lineData.map((L) => `        <div class="hln" id="${L.id}"></div>`).join("\n")}
      </div>
      <div id="crtvig"></div>
      <div id="crtscan"></div>`;
  const js = `
  // ---- body paradigm: HUDRAIL (coin-pop words on an arcade HUD bar) ----
  // world overlays: scanlines + slight vignette
  tl.set("#crtscan", { opacity: ${b.scan ?? 0.06} }, 0.03);
  tl.set("#crtvig",  { opacity: 1 },    0.03);
  // HUD bar boots up
  tl.set("#hud", { opacity: 1 }, 0.05);
  tl.fromTo("#hud", { y: 14 }, { y: 0, duration: 0.12, ease: "steps(2)" }, 0.05);
  // 1UP blink, classic cadence (baked sets)
  for (let t = 0.28, k = 0; t < ${(DUR - 0.06).toFixed(2)}; t += 0.45, k++) {
    tl.set("#tagL", { opacity: k % 2 === 0 ? 0.3 : 1 }, t);
  }
  const HUD = ${J(lineData)};
  HUD.forEach((L) => {
    const line = document.getElementById(L.id);
    L.words.forEach(([txt]) => {
      const w = document.createElement("span"); w.className = "hw"; w.textContent = txt;
      const tick = document.createElement("div"); tick.className = "htick";
      w.appendChild(tick); line.appendChild(w);
    });
    gsap.set(line, { xPercent: -50, yPercent: -50 });
    tl.set(line, { opacity: 1 }, L.in);
    L.words.forEach(([txt, st, em], wi) => {
      const el = line.children[wi], tick = el.lastChild;
      // coin pop: instant-on + steps(2) scale from the baseline + 1-frame blip
      tl.set(el, { opacity: 1 }, st);
      tl.fromTo(el, { scale: 0 }, { scale: 1, duration: 0.12, ease: "steps(2)",
                                    transformOrigin: "50% 100%" }, st);
      tl.set(tick, { opacity: 0.95 }, st);                // coin blip, exactly 1 frame
      tl.set(tick, { opacity: 0 }, st + 0.042);
      if (em) {  // 3-frame palette swap (body -> accent -> magenta -> body)
        tl.set(el, { color: ${J(A)} }, st + 0.13);
        tl.set(el, { color: ${J(MG)} }, st + 0.172);
        tl.set(el, { color: ${J(dna.palette.body)} }, st + 0.214);
      }
    });
    // checkerboard dissolve: words vanish in 2 alternating set passes
    L.words.forEach((w, wi) => {
      tl.set(line.children[wi], { opacity: 0 },
             wi % 2 === 0 ? L.xo : Math.min(L.xo + 0.083, ${(DUR - 0.03).toFixed(2)}));
    });
    tl.set(line, { opacity: 0, display: "none" }, Math.min(L.xo + 0.15, ${(DUR - 0.02).toFixed(2)}));
  });
${
  b.yield
    ? `  // HUD yields while the boss lands (restore only with runway)
  tl.to("#hud", { opacity: ${b.yield.dim}, duration: 0.15, ease: "power1.in" }, ${(heroIn - (b.yield.pre || 0.2)).toFixed(3)});
${
  heroIn + (b.yield.post || 0.9) + 0.3 < DUR - 0.1
    ? `  tl.to("#hud", { opacity: 1, duration: 0.25, ease: "power1.out" }, ${(heroIn + (b.yield.post || 0.9)).toFixed(3)});`
    : ""
}`
    : ""
}`;
  return { css, html, js };
}

function paradigmCarbonstrip() {
  // CARBONSTRIP: cold-war case-file transcript strip docked on the clearer
  // side (manila gradient, punch holes, faint red watermark, typed case
  // header). Carbon type HAMMERS in per word — per-char sets 30ms apart,
  // scale 1.35→1 in one frame (the stamp motif at low amplitude) — every
  // strike kicks the strip 1px (y-channel locked while the strip itself is
  // tweened); seeded ±1.2px baselines + seeded ribbon-ink weight. Emphasis
  // words get a red ink ellipse drawn around them. Lines live on up to
  // body.rows fixed slots; PAGES (broken at full rows, dead-air gaps > 0.7s,
  // and after the hero hand-off line — it is filed away while the stamp owns
  // the frame) exit by X-row over-strike (3 chunks per line, lines 0.16s
  // apart) then a carriage yank-down feed; the final page is pulled with the
  // whole strip at clip end. The strip top-shadow breathes during the
  // stamp's dead-still hold; the strip yields (body.yield) while it lands.
  const b = dna.body;
  const p = dna.hero.params || {};
  const C = heroIn + (p.crush ?? 0.074); // stamp contact (shared with rubberstamp)
  const SW = (b.strip && b.strip.w) || 764,
    SH = (b.strip && b.strip.h) || 132;
  const SX = (b.strip && b.strip.x) ?? (CLEARER === "left" ? 48 : W - 48 - SW);
  const ST = H - ((b.strip && b.strip.bottomPx) ?? 4) - SH;
  const ROWS = b.rows || [36, 82];
  const FY = +(DUR - 0.1).toFixed(3); // final carriage yank (the file is pulled)
  const HDR = b.header || "TRANSCRIPT OF RECORDING";
  const hdrW = Math.round(HDR.length * 11.4);
  const hdrSteps = Math.max(8, Math.round(HDR.length * 0.58));
  const rgb = (h) =>
    [h.slice(1, 3), h.slice(3, 5), h.slice(5, 7)].map((x) => parseInt(x, 16)).join(",");
  const ACC = dna.palette.accent || "#b3271e";
  // ---- pages: up to ROWS.length lines per carriage feed ----
  const pages = [];
  let cur = [];
  LINES.forEach((L, i) => {
    cur.push(L);
    const next = LINES[i + 1];
    const heroLine = heroIn >= L.in - 0.01 && heroIn < L.out;
    const gap = next ? next.words[0].start - L.words[L.words.length - 1].end : 0;
    if (!next || cur.length === ROWS.length || heroLine || gap > 0.7) {
      pages.push(cur);
      cur = [];
    }
  });
  // ---- exit schedule: X-strike chunks (0.05s apart, lines 0.16s apart) →
  // yank → hide → carriage return. Roomy cadence in dead air, compressed
  // when the next page is close (both cadences are the demo's). The whole
  // exit always completes before the next page's first hammer strike.
  const YLOCK = [[0, 0.215]]; // strip-y tween windows: no kicks inside
  const pageData = pages.map((pg, pi) => {
    const lines = pg.map((L, k) => ({
      id: "d" + L.id,
      top: ROWS[k],
      n: L.words.reduce((a, w) => a + w.display.length, 0) + L.words.length - 1,
      words: L.words.map((w) => [w.display, +w.start.toFixed(3), w.minor ? 1 : 0]),
    }));
    if (pi === pages.length - 1) {
      YLOCK.push([+(FY - 0.01).toFixed(3), +(DUR + 0.02).toFixed(3)]);
      return { lines, xs: null, yank: null, limit: FY };
    }
    const lastW = pg[pg.length - 1].words;
    const lastEnd = lastW[lastW.length - 1].end;
    const nextIn = +(pages[pi + 1][0].words[0].start - 0.02).toFixed(3);
    const n = pg.length;
    const roomySpan = 0.16 * (n - 1) + 0.55,
      tightSpan = 0.16 * (n - 1) + 0.3;
    let xt0 = lastEnd + 0.33,
      roomy = true;
    if (xt0 + roomySpan > nextIn) {
      xt0 = Math.max(lastEnd + 0.18, nextIn - roomySpan);
      if (xt0 + roomySpan > nextIn + 0.001) {
        roomy = false;
        xt0 = Math.max(lastEnd + 0.15, nextIn - tightSpan);
      }
    }
    xt0 = +xt0.toFixed(3);
    const lastChunkEnd = xt0 + 0.16 * (n - 1) + 0.1;
    const yankT = +(lastChunkEnd + (roomy ? 0.14 : 0.07)).toFixed(3);
    const yankDur = roomy ? 0.1 : 0.07;
    const hideT = +(yankT + (roomy ? 0.11 : 0.07)).toFixed(3);
    const returnT = +(yankT + (roomy ? 0.13 : 0.075)).toFixed(3);
    const returnDur = roomy ? 0.18 : 0.055;
    const fits = xt0 + (roomy ? roomySpan : tightSpan) <= nextIn + 0.02;
    YLOCK.push([+(yankT - 0.01).toFixed(3), +(returnT + returnDur).toFixed(3)]);
    return {
      lines,
      xs: fits ? xt0 : null,
      yank: [yankT, yankDur, hideT, returnT, returnDur],
      limit: hideT,
    };
  });
  // red-circle draw durations clamp inside the word's page life
  pageData.forEach((pg) =>
    pg.lines.forEach((ln) =>
      ln.words.forEach((wd) => {
        if (wd[2]) {
          const circT = wd[1] + wd[0].length * 0.03 + 0.02;
          wd.push(+Math.min(0.25, Math.max(0.14, pg.limit - circT - 0.02)).toFixed(3));
        }
      }),
    ),
  );
  const css = `
  #strip { position:absolute; left:${SX}px; top:${ST}px; width:${SW}px; height:${SH}px; opacity:0;
           background: linear-gradient(180deg, #e0cda6 0%, ${dna.palette.paper || "#d8c49a"} 42%, #ccb486 100%);
           border:1px solid rgba(106,86,48,0.9); border-radius:2px;
           box-shadow: inset 0 1px 0 rgba(255,250,235,0.5), inset 0 -14px 22px rgba(95,74,38,0.18); }
  #shade { position:absolute; left:2px; right:2px; top:-16px; height:16px; opacity:0.5;
           background: linear-gradient(180deg, rgba(15,10,4,0) 0%, rgba(15,10,4,0.38) 100%); }
  .hole { position:absolute; left:14px; width:13px; height:13px; border-radius:50%;
          background:rgba(58,45,24,0.34); box-shadow: inset 0 2px 2px rgba(30,22,10,0.5), 0 1px 0 rgba(255,248,230,0.45); }
  #wm { position:absolute; right:18px; top:40px; font-family:'${dna.fonts.tag}', serif;
        font-size:32px; letter-spacing:7px; color:rgba(${rgb(ACC)},0.13);
        transform:rotate(-6deg); white-space:nowrap; }
  #hdrwrap { position:absolute; left:52px; top:9px; overflow:hidden; white-space:nowrap; width:0; }
  #hdr { font-family:'${dna.fonts.tag}', serif; font-size:14px; letter-spacing:3px;
         color:${dna.palette.ink || "#8a6f3f"}; white-space:nowrap; }
  .ln { position:absolute; left:52px; font-family:'${dna.fonts.body}', serif; font-size:${b.fontPx}px;
        line-height:1; color:${dna.palette.body}; white-space:nowrap; }
  .ln .w { display:inline-block; position:relative; margin-right:0.5em; }
  .ln .c { display:inline-block; opacity:0; }
  .xrow { position:absolute; left:52px; font-family:'${dna.fonts.body}', serif; font-size:${b.fontPx}px;
          line-height:1; color:rgba(${rgb(ACC)},0.85); white-space:nowrap; }
  .xrow span { opacity:0; }
  .circ { position:absolute; left:-13px; top:-9px; width:calc(100% + 26px);
          height:calc(100% + 18px); overflow:visible; pointer-events:none; }
  .circ ellipse { fill:none; stroke:${ACC}; stroke-width:3.6; stroke-linecap:round; }`;
  const html = `      <div id="strip">
        <div id="shade"></div>
        <div class="hole" style="top:24px"></div>
        <div class="hole" style="top:${SH - 40}px"></div>
        <div id="wm">${esc(b.watermark || "CONFIDENTIAL")}</div>
        <div id="hdrwrap"><div id="hdr">${esc(HDR)}</div></div>
${pageData
  .flatMap((pg) => pg.lines)
  .map((ln) => `        <div class="ln" id="${ln.id}" style="top:${ln.top}px"></div>`)
  .join("\n")}
      </div>`;
  const shadeStart = +(C + 0.12).toFixed(3);
  const shadeDur = Math.min(1.5, FY - shadeStart - 0.05);
  const dimT = +(heroIn - ((b.yield && b.yield.pre) || 0.02)).toFixed(3);
  const resT = +(heroIn + ((b.yield && b.yield.post) || 0.41)).toFixed(3);
  const js = `
  // ---- body paradigm: CARBONSTRIP (typewriter hammer in / X-strike + carriage-feed out) ----
  const strip = document.getElementById("strip");
  const krnd = mulberry32(${b.seed || 180618});
  // y-channel windows where the strip is tweened — no kicks allowed inside
  const YLOCK = ${J(YLOCK)};
  function kick(t) {
    for (const [a, b] of YLOCK) if (t >= a && t <= b + 0.045) return;
    tl.set(strip, { y: 1 }, t);
    tl.set(strip, { y: 0 }, t + 0.042);
  }

  // ===== strip entrance: the carriage feeds the paper up =====
  tl.set(strip, { opacity: 1 }, 0.02);
  tl.fromTo(strip, { y: 130 }, { y: 0, duration: 0.19, ease: "power3.out" }, 0.02);
  tl.fromTo("#hdrwrap", { width: 0 }, { width: ${hdrW}, duration: 0.42, ease: "steps(${hdrSteps})" }, 0.10);

  // ===== body: typewriter hammer (per-char sets, 30ms apart) =====
  const PAGES = ${J(pageData)};
  const NS = "http://www.w3.org/2000/svg";
  const strikes = [], circles = [];
  PAGES.forEach((pg) => {
    pg.lines.forEach((ln) => {
      const line = document.getElementById(ln.id);
      ln.words.forEach(([txt, st, em, cd]) => {
        const w = document.createElement("span"); w.className = "w";
        [...txt].forEach((ch, i) => {
          const c = document.createElement("span"); c.className = "c"; c.textContent = ch;
          w.appendChild(c);
          strikes.push([c, st + i * 0.03]);
        });
        if (em) {
          const svg = document.createElementNS(NS, "svg");
          svg.setAttribute("class", "circ"); svg.setAttribute("viewBox", "0 0 120 64");
          svg.setAttribute("preserveAspectRatio", "none");
          const el = document.createElementNS(NS, "ellipse");
          el.setAttribute("cx", 60); el.setAttribute("cy", 32);
          el.setAttribute("rx", 55); el.setAttribute("ry", 25);
          el.setAttribute("transform", "rotate(-4 60 32)");
          svg.appendChild(el); w.appendChild(svg);
          circles.push([el, st + txt.length * 0.03 + 0.02, cd]);
        }
        line.appendChild(w);
      });
    });
  });
  strikes.forEach(([c, t]) => {
    const off = (krnd() - 0.5) * 2.4;                  // ±1.2px baseline wobble
    const ink = 0.84 + krnd() * 0.16;                  // uneven ribbon ink
    gsap.set(c, { y: off, transformOrigin: "50% 85%" });
    tl.set(c, { opacity: ink, scale: 1.35 }, t);       // hammer = the stamp motif, low amplitude
    tl.set(c, { scale: 1 }, t + 0.042);
    kick(t);                                           // every strike kicks the strip
  });
  // red ink circles draw around the emphasis words
  circles.forEach(([el, t, d]) => {
    const len = el.getTotalLength();
    gsap.set(el, { attr: { "stroke-dasharray": len, "stroke-dashoffset": len } });
    tl.to(el, { attr: { "stroke-dashoffset": 0 }, duration: d, ease: "power2.inOut" }, t);
  });

  // ===== exits: X-row over-strike (3 chunks per line) then carriage yank =====
  PAGES.forEach((pg) => {
    const hides = pg.lines.map((ln) => "#" + ln.id);
    if (pg.xs != null) pg.lines.forEach((ln, k) => {
      const row = document.createElement("div");
      row.className = "xrow"; row.style.top = (ln.top + 2) + "px";
      strip.appendChild(row); hides.push(row);
      const third = Math.max(1, Math.ceil(ln.n / 3));
      let left = ln.n;
      for (let j = 0; j < 3 && left > 0; j++) {
        const nC = Math.min(third, left); left -= nC;
        const s = document.createElement("span"); s.textContent = "X".repeat(nC);
        row.appendChild(s);
        const t = pg.xs + k * 0.16 + j * 0.05;
        tl.set(s, { opacity: 1 }, t);
        kick(t);
      }
    });
    if (pg.yank) {
      const [yt, yd, ht, rt, rd] = pg.yank;
      tl.to(strip, { y: 30, duration: yd, ease: "power3.in" }, yt);
      tl.set(hides, { opacity: 0 }, ht);
      tl.to(strip, { y: 0, duration: rd, ease: "power2.out" }, rt);
    }
  });
${
  b.yield
    ? `
  // ===== apex etiquette: the strip yields while the stamp lands =====
  tl.to(strip, { opacity: ${b.yield.dim}, duration: 0.18, ease: "power1.in" }, ${dimT});
${resT + 0.3 < FY ? `  tl.to(strip, { opacity: 1, duration: 0.25, ease: "power1.out" }, ${resT});` : ""}`
    : ""
}
${
  shadeDur > 0.4
    ? `
  // strip top-shadow breathes during the stamp's dead-still hold
  tl.to("#shade", { keyframes: { opacity: [0.5, 0.72, 0.54, 0.7, 0.55, 0.66, 0.5] },
                    duration: ${shadeDur.toFixed(2)}, ease: "none" }, ${shadeStart});`
    : ""
}

  // ===== ending: the file is pulled — strip yanks down and is gone =====
  tl.to(strip, { y: 34, duration: 0.09, ease: "power3.in" }, ${FY});
  tl.to(strip, { opacity: 0, duration: 0.06 }, ${(FY + 0.02).toFixed(3)});`;
  return { css, html, js };
}

function paradigmLaserrail() {
  // LASERRAIL: concert lower third — every word is IGNITED where two thin
  // laser beams (accent from the top-left, magenta from the top-right) sweep
  // to CONVERGE on its position 2 frames early; at convergence a flare pops
  // and the word snaps on in laser color (the apex motif at low amplitude:
  // crush → contact squash → elastic settle, brightness cool). Emphasis words
  // strobe once (1 frame off / back on) and their beams linger. Exits: a
  // sweeper beam crosses L→R and carries the words (streak x+44 + blur).
  // Word centers are BAKED at compile time from the measured char-width table
  // (assets/fonts/char-widths.json) so font-load timing can't skew beam aim
  // (calibrated vs the t37 demo's browser-measured table: ≤3px/word).
  const b = dna.body;
  const fam = dna.fonts.body;
  const m = famMetrics(fam) || { ascent: 0.8, descent: 0.2, widths: {} };
  const lsEm = parseFloat(b.letterSpacing) || 0;
  const gapEm = b.wordGapEm ?? 0.24;
  const railBot = H - b.bottomPx;
  const emPx = Math.round(b.fontPx * (b.minorScale || 1.325));
  const A = dna.palette.accent;
  const lineData = LINES.map((L, i) => {
    const words = L.words.map((w) => ({
      txt: b.textTransform === "uppercase" ? w.display.toUpperCase() : w.display,
      st: +w.start.toFixed(3),
      em: w.minor ? 1 : 0,
    }));
    // centered-line layout, baked in px (CSS model: inline-blocks on one
    // baseline, margin 0 <gapEm>em, tracking after every char incl the last)
    const pxs = words.map((w) => (w.em ? emPx : b.fontPx));
    const ws = words.map((w, k) => wordPx(w.txt, fam, pxs[k], lsEm));
    const total = ws.reduce((a, x, k) => a + x + 2 * gapEm * pxs[k], 0);
    const maxPx = Math.max(...pxs);
    const baseline = railBot - (maxPx * (1 - m.ascent + m.descent)) / 2;
    let cur = W / 2 - total / 2;
    const pos = words.map((w, k) => {
      const cx = cur + gapEm * pxs[k] + ws[k] / 2;
      cur += ws[k] + 2 * gapEm * pxs[k];
      return [
        Math.round(cx),
        Math.round(baseline - (pxs[k] * (m.ascent - m.descent)) / 2),
        Math.round(ws[k]),
      ];
    });
    // sweep starts so the carried words clear before the next line's beams own
    // the rail; last line keeps the demo's end-of-clip runway
    const sweepT =
      i + 1 < LINES.length
        ? +(L.out - 0.14).toFixed(3)
        : +Math.min(L.out - 0.14, DUR - 0.26).toFixed(3);
    return {
      id: "lr" + L.id,
      in: +L.in.toFixed(3),
      sweepT,
      words: words.map((w) => [w.txt, w.st, w.em]),
      pos,
    };
  });
  const css = `
  #lrfx { position:absolute; inset:0; }
  .lrline { position:absolute; left:${W / 2}px; top:${railBot}px; opacity:0; white-space:nowrap;
          font-family:'${fam}', sans-serif; font-size:${b.fontPx}px; line-height:1;
          letter-spacing:${b.letterSpacing || "0.02em"}; color:${dna.palette.body};
          ${b.textTransform ? "text-transform:" + b.textTransform + ";" : ""}
          text-shadow: 0 0 10px ${A}bf, 0 0 26px ${A}61, 0 2px 6px rgba(0,0,0,0.7); }
  .lrline .w { display:inline-block; opacity:0; margin:0 ${gapEm}em; }
  .lrline .w.em { font-size:${emPx}px;
          text-shadow: 0 0 14px ${A}e6, 0 0 34px ${A}80, 0 2px 6px rgba(0,0,0,0.7); }
  .lflare { position:absolute; width:54px; height:54px; border-radius:50%; opacity:0;
            background: radial-gradient(circle, rgba(220,255,235,0.95) 0%, ${A}80 40%, ${A}00 70%); }
  .lsweep { position:absolute; width:3px; height:88px; opacity:0;
           background: linear-gradient(180deg, ${A}00 0%, rgba(230,255,242,0.95) 45%, ${A}00 100%);
           filter: drop-shadow(0 0 6px ${A}); }`;
  const html =
    `      <svg id="lrfx" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">
        <g id="gLR"></g>
      </svg>\n` + lineData.map((L) => `      <div class="lrline" id="${L.id}"></div>`).join("\n");
  const js = `
  // ---- body paradigm: LASERRAIL (per-word beam-converge ignition) ----
  const LACC = ${J(A)}, LMAG = ${J(dna.palette.magenta || "#ff4df0")};
  const NSL = "http://www.w3.org/2000/svg";
  const lrStage = document.getElementById("stage");
  const gLR = document.getElementById("gLR");
  function lrLine(x1, y1, x2, y2, col, w) {
    const l = document.createElementNS(NSL, "line");
    l.setAttribute("x1", x1); l.setAttribute("y1", y1);
    l.setAttribute("x2", x2); l.setAttribute("y2", y2);
    l.setAttribute("stroke", col); l.setAttribute("stroke-width", w);
    l.setAttribute("stroke-linecap", "round");
    l.style.opacity = 0; gLR.appendChild(l); return l;
  }
  const LRAIL = ${J(lineData)};
  LRAIL.forEach((L) => {
    const line = document.getElementById(L.id);
    L.words.forEach(([txt, , em]) => {
      const s = document.createElement("span");
      s.className = "w" + (em ? " em" : ""); s.textContent = txt;
      line.appendChild(s);
    });
    gsap.set(line, { xPercent: -50, yPercent: -100, transformOrigin: "50% 100%" });
    tl.set(line, { opacity: 1 }, L.words[0][1] - 0.03);
    L.words.forEach(([txt, st, em], wi) => {
      const el = line.children[wi];
      const [wx, wy] = L.pos[wi];
      // beams yield inside the apex window (the hero owns the light)
      const yld = (st > ${(heroIn - 0.41).toFixed(3)} && st < ${(heroIn + 0.99).toFixed(3)})
        ? ${b.yield ? (b.yield.beamDim ?? 0.55) : 1} : 1;
      const t0 = st - 0.21, cv = st - 0.083;
      // two beams converge on the word 2 frames before its time
      [[-25, -15, LACC, wx - 170], [${W + 25}, -15, LMAG, wx + 170]].forEach(([ex, ey, col, sx]) => {
        const halo = lrLine(ex, ey, sx, wy - 30, col, 5);
        const core = lrLine(ex, ey, sx, wy - 30, col, 1.5);
        tl.set(halo, { opacity: 0.24 * yld }, t0);
        tl.set(core, { opacity: 0.92 * yld }, t0);
        tl.to([halo, core], { attr: { x2: wx, y2: wy }, duration: cv - t0, ease: "power2.out" }, t0);
        tl.to([halo, core], { opacity: 0, duration: em ? 0.45 : 0.2, ease: "power1.in" }, st + 0.03);
      });
      // convergence flare
      const f = document.createElement("div");
      f.className = "lflare"; lrStage.appendChild(f);
      f.style.left = (wx - 27) + "px"; f.style.top = (wy - 27) + "px";
      tl.set(f, { opacity: 0.85, scale: 0.5 }, cv);
      tl.to(f, { scale: 1.35, opacity: 0, duration: 0.13, ease: "power2.out" }, st - 0.01);
      // IGNITE (apex motif at low amplitude): on in 1 frame, crush, squash, settle
      tl.set(el, { opacity: 1, scale: 1.12, filter: "brightness(1.85)", transformOrigin: "50% 80%" }, st);
      tl.to(el,  { scale: 1, duration: 0.09, ease: "power3.in" }, st + 0.005);
      tl.set(el, { scaleX: 1.05, scaleY: 0.94 }, st + 0.1);
      tl.to(el,  { scaleX: 1, scaleY: 1, duration: 0.3, ease: "elastic.out(1, 0.42)" }, st + 0.14);
      tl.to(el,  { filter: "brightness(1)", duration: 0.22, ease: "power2.out" }, st + 0.07);
      if (em) { // single strobe: 1 frame off, back on
        tl.set(el, { opacity: 0 }, st + 0.18);
        tl.set(el, { opacity: 1 }, st + 0.222);
      }
    });
    // hold life: gentle breathe + laser shimmer while the line waits
    const lastSt = L.words[L.words.length - 1][1];
    const free = L.sweepT - lastSt - 0.35;
    if (free > 0.45) {
      const half = Math.min(0.55, free / 2);
      tl.to(line, { scale: 1.013, duration: half, ease: "sine.inOut" }, lastSt + 0.35);
      tl.to(line, { scale: 1.0,   duration: half, ease: "sine.inOut" }, lastSt + 0.35 + half);
    }
    if (free > 0.9) {
      tl.to(line, { keyframes: { filter: ["brightness(1)","brightness(1.07)","brightness(1)","brightness(1.05)","brightness(1)"] },
                    duration: 0.9, ease: "none" }, lastSt + 0.5);
    }
    // EXIT: sweeper beam crosses L→R, words streak away as it passes them
    const xs = L.pos.map((p) => p[0]);
    const x0 = Math.min(...xs) - 46, x1 = Math.max(...xs) + 46;
    const sw = document.createElement("div");
    sw.className = "lsweep"; lrStage.appendChild(sw);
    sw.style.left = x0 + "px"; sw.style.top = (L.pos[0][1] - 58) + "px";
    tl.set(sw, { opacity: 0.9 }, L.sweepT);
    tl.to(sw,  { x: x1 - x0, duration: 0.13, ease: "power1.in" }, L.sweepT);
    tl.to(sw,  { opacity: 0, duration: 0.05 }, L.sweepT + 0.13);
    L.words.forEach((w, wi) => {
      const el = line.children[wi];
      const frac = (L.pos[wi][0] - x0) / Math.max(1, x1 - x0);
      tl.to(el, { x: 44, opacity: 0, filter: "blur(5px)", duration: 0.11, ease: "power2.in" },
            L.sweepT + 0.11 * frac);
    });
    tl.set(line, { display: "none" }, Math.min(L.sweepT + 0.32, ${(DUR - 0.02).toFixed(3)}));
  });
${
  b.yield
    ? `  // rail yields while the apex lands (restore only with clear runway)
  LRAIL.forEach((L) => {
    if (L.in < ${heroIn.toFixed(3)} + 0.9 && L.sweepT > ${heroIn.toFixed(3)} - 0.3) {
      const dimT = Math.max(${(heroIn - (b.yield.pre || 0.04)).toFixed(3)}, L.in + 0.05);
      if (L.sweepT > dimT + 0.25) {
        tl.to("#" + L.id, { opacity: ${b.yield.dim}, duration: 0.15 }, dimT);
        const resT = ${(heroIn + (b.yield.post || 0.9)).toFixed(3)};
        if (resT + 0.3 < L.sweepT) tl.to("#" + L.id, { opacity: 1, duration: 0.25 }, resT);
      }
    }
  });`
    : ""
}`;
  return { css, html, js };
}

function paradigmStormrail() {
  // STORMRAIL: rain-washed lower third. Ambient rain = seeded thin streaks
  // falling on repeating linear cycle tweens (pure f(t), one wind vector,
  // mid-fall at t0); a second streak set DOUBLES the rain for ~1s after the
  // strike. Body words arrive with the lightning-kiss motif at low amplitude
  // (2-frame lit-bright pop + micro settle); emphasis = sheet-lightning double
  // flash (the bg sky double-kisses at the same beats). Exits wash downward
  // with the rain (y+22, blur). The visible line dims while the bolt lands and
  // its text-shadow flips toward the strike for the flash frames.
  const b = dna.body;
  const r = b.rain || {};
  const A = dna.palette.accent;
  const railTop = H - (b.bottomPx || 98);
  const sgn = (dna.hero.params || {}).side === "right" ? -1 : 1;
  const lineData = LINES.map((L) => ({
    id: "st" + L.id,
    in: +L.in.toFixed(3),
    // wash start: just before the next line — but a line facing dead air
    // washes away early (the demo's r1 exits at lastWord + 1.08s)
    out: +Math.min(L.out - 0.05, L.words[L.words.length - 1].end + 1.08, DUR - 0.2).toFixed(3),
    words: L.words.map((w) => [w.display, +w.start.toFixed(3), w.minor ? 1 : 0]),
  }));
  const css = `
  #stscrim { position:absolute; left:0; right:0; top:${H - 180}px; height:180px; opacity:0;
    background:linear-gradient(to bottom, rgba(6,10,18,0) 0%, rgba(6,10,18,0.45) 45%, rgba(6,10,18,0.62) 100%); }
  #rain  { position:absolute; inset:0; opacity:${r.opacity ?? 0.08}; }
  #rain2 { position:absolute; inset:0; opacity:0; }
  .drop { position:absolute; left:0; top:0; width:2px; border-radius:1px;
    background:linear-gradient(to bottom, rgba(200,222,244,0) 0%, rgba(214,232,250,0.95) 100%); }
  .stline { position:absolute; left:${W / 2}px; top:${railTop}px; opacity:0; white-space:nowrap;
    font-family:'${dna.fonts.body}', sans-serif; font-weight:700; font-size:${b.fontPx}px; line-height:1.15;
    letter-spacing:${b.letterSpacing || "0.015em"}; color:${dna.palette.body};
    text-shadow: 0 6px 16px rgba(2,8,18,0.85), 0 2px 4px rgba(2,8,18,0.7), 0 0 14px ${A}47; }
  .stline .w { display:inline-block; opacity:0; margin:0 0.16em; }`;
  const html =
    `      <div id="rain"></div>
      <div id="stscrim"></div>
` +
    lineData.map((L) => `      <div class="stline" id="${L.id}"></div>`).join("\n") +
    `
      <div id="rain2"></div>`;
  const js = `
  // ---- body paradigm: STORMRAIL (lightning-kiss words, rain-wash exits, ambient rain) ----
  const I = ${heroIn.toFixed(3)};
  // ambient rain: seeded streaks on repeating linear cycles (f(t), no random at render)
  function makeRain(container, n, seed, t0, t1) {
    const rnd = mulberry32(seed);
    for (let i = 0; i < n; i++) {
      const el = document.createElement("div");
      el.className = "drop"; container.appendChild(el);
      const x0 = 20 + rnd() * ${W - 40}, d = 0.55 + rnd() * 0.55;
      const len = 46 + rnd() * 70, frac = rnd();
      const travel = ${H + 300}, dx = -160; // slanted fall, one wind vector
      gsap.set(el, { height: len, rotation: 9, x: x0, y: -160 });
      let t = t0;
      // partial first cycle so rain is mid-fall at t0
      tl.fromTo(el, { y: -160 + travel * frac, x: x0 + dx * frac },
                    { y: ${H + 140}, x: x0 + dx, duration: d * (1 - frac), ease: "none" }, t);
      t += d * (1 - frac);
      while (t < t1) {
        tl.fromTo(el, { y: -160, x: x0 },
                      { y: ${H + 140}, x: x0 + dx, duration: d, ease: "none" }, t);
        t += d;
      }
    }
  }
  makeRain(document.getElementById("rain"),  ${r.count ?? 18}, ${r.seed ?? 9021}, 0, ${(DUR - 0.02).toFixed(2)});
  makeRain(document.getElementById("rain2"), ${r.count ?? 18}, ${r.seed2 ?? 4477}, ${(heroIn - 0.11).toFixed(3)}, ${Math.min(heroIn + 1.24, DUR - 0.02).toFixed(3)});
  // rain doubles for ~1s after the strike
  tl.set("#rain2", { opacity: 0.11 }, I + 0.02);
  tl.to("#rain2",  { opacity: 0, duration: 0.30 }, ${Math.min(heroIn + 1.02, DUR - 0.34).toFixed(3)});

  // rail scrim (stable reading surface against the busy lower frame)
  tl.to("#stscrim", { opacity: 1, duration: 0.20 }, 0.05);
  tl.to("#stscrim", { opacity: 0, duration: 0.16 }, ${(DUR - 0.18).toFixed(3)});

  const SRAIL = ${J(lineData)};
  SRAIL.forEach((L) => {
    const line = document.getElementById(L.id);
    L.words.forEach(([txt]) => {
      const s = document.createElement("span");
      s.className = "w"; s.textContent = txt; line.appendChild(s);
    });
    gsap.set(line, { xPercent: -50, yPercent: -100 });
    tl.set(line, { opacity: 1 }, L.words[0][1] - 0.025);
    L.words.forEach(([txt, st, em], wi) => {
      const el = line.children[wi];
      // entrance = the apex motif at low amplitude: lit-bright pop + micro settle
      tl.set(el, { opacity: 1, y: 4, scale: 1.06, filter: "brightness(2.2)",
                   transformOrigin: "50% 85%" }, st);
      tl.to(el, { y: 0, scale: 1, duration: 0.12, ease: "power2.out" }, st + F);
      tl.to(el, { filter: "brightness(1)", duration: 0.14, ease: "power1.out" }, st + F);
      if (em && st + 0.20 + 3 * F < ${(DUR - 0.04).toFixed(2)}) { // sheet lightning: 2-frame double flash
        const t0 = st + 0.20;
        tl.set(el, { filter: "brightness(1.9)" }, t0);
        tl.set(el, { filter: "brightness(1.0)" }, t0 + F);
        tl.set(el, { filter: "brightness(1.8)" }, t0 + 2 * F);
        tl.to(el,  { filter: "brightness(1)", duration: 0.12 }, t0 + 3 * F);
      }
    });
    // EXIT: wash downward with the rain
    tl.to(line, { y: 22, opacity: 0, filter: "blur(3px)", duration: 0.18, ease: "power1.in" }, L.out);
    tl.set(line, { display: "none" }, Math.min(L.out + 0.20, ${(DUR - 0.02).toFixed(2)}));
  });
${
  b.yield
    ? `  // rail yields while the bolt lands; the visible line's shadow flips toward
  // the strike for the flash frames (light from the bolt side)
  SRAIL.forEach((L) => {
    if (L.in < I + 0.9 && L.out > I - 0.3) {
      const dimT = Math.max(I - ${(b.yield.pre ?? 0.15).toFixed(2)}, L.in + 0.05);
      if (L.out > dimT + 0.25) {
        tl.to("#" + L.id, { opacity: ${b.yield.dim}, duration: 0.12 }, dimT);
        const resT = I + ${(b.yield.post ?? 0.29).toFixed(2)};
        if (resT + 0.3 < L.out) tl.to("#" + L.id, { opacity: 1, duration: 0.10 }, resT);
      }
    }
    if (L.in <= I - 2 * F && L.out > I) {
      tl.set("#" + L.id, { textShadow: "${12 * sgn}px 4px 12px rgba(2,8,18,0.85), 0 0 14px ${A}73" }, I - 2 * F);
      tl.set("#" + L.id, { textShadow: "0 6px 16px rgba(2,8,18,0.85), 0 2px 4px rgba(2,8,18,0.7), 0 0 14px ${A}47" }, I + 0.09);
    }
  });`
    : ""
}`;
  return { css, html, js };
}

function paradigmHolorail() {
  // HOLORAIL: a volumetric projection PLATE (scanlined glass slab with corner
  // brackets + feed tag) is the stable reading surface; it boots like a
  // projector turning ON (scaleY snap + 1-frame flicker) and breathes a slow
  // luminance cycle. Body words boot with the apex motif at low amplitude:
  // instant opacity + RISING sweep reveal (clip wipe), a 1-frame interference
  // x-glitch, and a constant chromatic-fringe text-shadow. Emphasis words get
  // a 2-frame projection DROPOUT that restores brighter, then settles. Each
  // line drifts slowly in rotationY (it IS a projection) and exits with the
  // projector-cut: brightness pop → collapse to a horizontal slit → to a dot.
  const b = dna.body;
  const A = dna.palette.accent,
    MG = dna.palette.magenta || "#ff5fd6";
  const plateW = b.plateW || 980,
    plateH = b.plateH || 96;
  const plateY = H - (b.bottomPx || 108); // plate center
  const lineY = plateY + (b.lineDy ?? 9); // line center sits low in the plate
  const lineData = LINES.map((L, i) => ({
    id: "hl" + L.id,
    // projector-cut start: completes (0.15s) before the next line boots;
    // the last line keeps the demo's end-of-clip runway
    exit: +(i + 1 < LINES.length ? L.out - 0.15 : Math.min(L.out + 0.07, DUR - 0.18)).toFixed(3),
    words: L.words.map((w) => [w.display, +w.start.toFixed(3), w.minor ? 1 : 0]),
  }));
  const shadowBase = `-1.5px 0 0 ${A}8c, 1.5px 0 0 ${MG}6b, 0 0 16px ${A}80, 0 2px 8px rgba(1,10,16,0.85)`;
  const shadowEm = `-1.5px 0 0 ${A}cc, 1.5px 0 0 ${MG}99, 0 0 26px ${dna.palette.glowEm || "#A0F2FF"}d9, 0 2px 8px rgba(1,10,16,0.85)`;
  const css = `
  #railwrap { position:absolute; inset:0; }
  #plate { position:absolute; left:${W / 2}px; top:${plateY}px; width:${plateW}px; height:${plateH}px; opacity:0;
           border-radius:3px;
           background: linear-gradient(180deg, rgba(4,22,33,0.46) 0%, rgba(6,30,44,0.55) 100%);
           border: 1px solid ${A}52;
           box-shadow: 0 0 26px rgba(60,170,210,0.20), inset 0 0 38px ${A}0f; }
  #pscan { position:absolute; inset:0; border-radius:3px;
           background: repeating-linear-gradient(180deg, ${A}00 0px, ${A}00 3px, ${A}12 3px, ${A}12 4px); }
  #ptag { position:absolute; left:14px; top:7px; font-family:'${dna.fonts.tag}', sans-serif;
          font-weight:400; font-size:11px; letter-spacing:0.32em; color:${A}99; }
  .crn { position:absolute; width:11px; height:11px; }
  .crn.a { left:-1px;  top:-1px;    border-left:2px solid ${A}cc; border-top:2px solid ${A}cc; }
  .crn.b { right:-1px; top:-1px;    border-right:2px solid ${A}cc; border-top:2px solid ${A}cc; }
  .crn.c { left:-1px;  bottom:-1px; border-left:2px solid ${A}cc; border-bottom:2px solid ${A}cc; }
  .crn.d { right:-1px; bottom:-1px; border-right:2px solid ${A}cc; border-bottom:2px solid ${A}cc; }
  .hln { position:absolute; left:${W / 2}px; top:${lineY}px; opacity:0; white-space:nowrap;
        font-family:'${dna.fonts.body}', sans-serif; font-weight:500; font-size:${b.fontPx}px;
        line-height:1.15; letter-spacing:${b.letterSpacing || "0.02em"}; }
  .hln .w { display:inline-block; opacity:0; margin-right:0.3em;
           color:${dna.palette.body}ed;
           text-shadow: ${shadowBase}; }`;
  const html =
    `      <div id="railwrap">
        <div id="plate">
          <div id="pscan"></div>
          <div id="ptag">${esc(theme.plateTag || b.tag || "VOLUMETRIC FEED // CH-07")}</div>
          <div class="crn a"></div><div class="crn b"></div>
          <div class="crn c"></div><div class="crn d"></div>
        </div>
` +
    lineData.map((L) => `        <div class="hln" id="${L.id}"></div>`).join("\n") +
    `
      </div>`;
  // plate breathe: finite yoyo cycles sized to the runway before the end cut
  const pCut = +(DUR - 0.14).toFixed(3);
  const breaths = Math.max(1, Math.floor((pCut - 0.5) / 0.8) - 1);
  const js = `
  // ---- body paradigm: HOLORAIL (projection plate, sweep-boot words, projector-cut exits) ----
  gsap.set("#plate", { xPercent: -50, yPercent: -50, transformOrigin: "50% 50%" });

  // plate boots like a projector turning ON (inverse of the projector-cut exit)
  tl.set("#plate", { opacity: 1 }, 0.10);
  tl.fromTo("#plate", { scaleY: 0.03 }, { scaleY: 1, duration: 0.13, ease: "expo.out" }, 0.10);
  tl.set("#plate", { opacity: 0.35 }, 0.155);
  tl.set("#plate", { opacity: 1 }, 0.195);
  tl.to("#plate", { opacity: 0.93, duration: 0.8, ease: "sine.inOut", yoyo: true, repeat: ${breaths} }, 0.5);

  const HRAIL = ${J(lineData)};
  const HSH_BASE = ${J(shadowBase)}, HSH_EM = ${J(shadowEm)};
  HRAIL.forEach((L) => {
    const line = document.getElementById(L.id);
    L.words.forEach(([txt], wi) => {
      const s = document.createElement("span");
      s.className = "w"; s.textContent = txt;
      if (wi === L.words.length - 1) s.style.marginRight = "0";
      line.appendChild(s);
    });
    gsap.set(line, { xPercent: -50, yPercent: -50, transformPerspective: 700, transformOrigin: "50% 50%" });
    tl.set(line, { opacity: 1 }, L.words[0][1] - 0.03);

    L.words.forEach(([txt, st, em], wi) => {
      const el = line.children[wi];
      // BOOT (low-amplitude apex motif): instant opacity + vertical sweep reveal
      tl.set(el, { opacity: 1, clipPath: "inset(-20% -10% 100% -10%)" }, st);
      tl.to(el, { clipPath: "inset(-20% -10% -30% -10%)", duration: 0.15, ease: "power2.out" }, st);
      tl.set(el, { clipPath: "none" }, st + 0.16);
      // 1-frame interference glitch
      tl.set(el, { x: 2 },  st + 0.05);
      tl.set(el, { x: -2 }, st + 0.09);
      tl.set(el, { x: 0 },  st + 0.13);
      // EMPHASIS: 2-frame projection dropout, restores brighter (delay shrinks
      // near the line's projector-cut so dropout+restore always complete)
      if (em) {
        const d = Math.min(${b.emphDelay ?? 0.34}, L.exit - st - 0.183);
        if (d >= 0.083) {
          tl.set(el, { opacity: 0.2 }, st + d);
          tl.set(el, { opacity: 1, color: ${J(dna.palette.em || "#f4feff")}, textShadow: HSH_EM }, st + d + 0.083);
          const sb = st + d + 0.51;
          if (sb < L.exit - 0.05)
            tl.to(el, { color: ${J((dna.palette.body || "#c7f1ff") + "ed")}, textShadow: HSH_BASE,
                        duration: Math.min(0.4, ${(DUR - 0.04).toFixed(2)} - sb), ease: "power1.in" }, sb);
        }
      }
    });

    // slow rotateY drift — it IS a projection
    const dS = L.words[0][1] + 0.08;
    const dD = Math.max(0.3, L.exit - dS - 0.04);
    tl.to(line, { keyframes: { rotationY: [-3.5, 3.5, -2.5] }, duration: dD, ease: "none" }, dS);

    // EXIT: projector cut — line collapses to a bright slit, then to a dot
    const ex = L.exit;
    tl.set(line, { filter: "brightness(2.2)" }, ex);
    tl.to(line, { scaleY: 0.045, duration: 0.07, ease: "power3.in" }, ex + 0.005);
    tl.to(line, { scaleX: 0.015, duration: 0.06, ease: "power3.in" }, ex + 0.08);
    tl.set(line, { opacity: 0, display: "none" }, ex + 0.15);
  });
${
  b.yield
    ? `  // the whole rail (plate included) yields while the apex lands
  tl.to("#railwrap", { opacity: ${b.yield.dim}, duration: 0.18, ease: "power1.in" }, ${(heroIn - (b.yield.pre ?? 0.18)).toFixed(3)});
  tl.to("#railwrap", { opacity: 1, duration: 0.22, ease: "power1.out" }, ${(heroIn + (b.yield.post ?? 0.37)).toFixed(3)});`
    : ""
}
  // end: the plate itself gets the projector cut
  tl.set("#plate", { filter: "brightness(2.2)" }, ${pCut});
  tl.to("#plate", { scaleY: 0.03, duration: 0.07, ease: "power3.in" }, ${(pCut + 0.005).toFixed(3)});
  tl.to("#plate", { scaleX: 0.012, duration: 0.05, ease: "power3.in" }, ${(pCut + 0.08).toFixed(3)});
  tl.set("#plate", { opacity: 0 }, ${Math.min(pCut + 0.12, DUR - 0.02).toFixed(3)});`;
  return { css, html, js };
}

function paradigmPlanktonrail() {
  // PLANKTONRAIL: deep-sea lower third on TWO coexisting row slots — even
  // lines ride the upper row, odd lines the lower, so couplets share the
  // water like drifting strata. Words GLOW ON like jellyfish (the apex bloom
  // at low amplitude): 2-frame opacity + radial bloom from center (scale
  // 0.92→1, blur 5→0, brightness 0.45→1) while a cyan glow text-shadow grows
  // in; emphasis words pulse a slow DOUBLE-BEAT (jellyfish propulsion). Every
  // line floats on a gentle y drift and exits by SINKING (+15px, dimming).
  // Furniture: seeded plankton motes drift f(t) in the open water; the ones
  // seeded in a ring around the apex get ATTRACTED to the bloom and brighten
  // as it lands. The line feeding the apex dims while the bloom owns the
  // frame (no restore — it exits dimmed, by design); a line entering during
  // the hold arrives dimmed and restores once the bloom settles.
  const b = dna.body;
  const A = dna.palette.accent,
    VIO = dna.palette.violet || "#9d7bff";
  const rowB = H - (b.bottomPx || 70); // lower row center
  const rowA = rowB - (b.rowDy ?? 58); // upper row center
  const m = b.motes || {};
  const yld = b.yield || {};
  // active line = the one feeding the apex (last line that starts before it)
  const activeIdx = LINES.reduce((a, L, i) => (L.words[0].start < heroIn ? i : a), 0);
  const lineData = LINES.map((L, i) => {
    // two-row coexistence: a line clears when its ROW is next needed (line
    // i+2) or ~0.3s after its couplet finishes speaking (staggered pair
    // exits); a pre-apex leftover clears just before the bloom lands (the
    // feeding line stays and yields instead); the last line floats to the end
    const pairLast = LINES[Math.min(i - (i % 2) + 1, LINES.length - 1)];
    const pairEnd = pairLast.words[pairLast.words.length - 1].end;
    let out =
      i + 2 < LINES.length
        ? Math.min(LINES[i + 2].in - 0.15, pairEnd + 0.28 + (i % 2) * 0.12)
        : i + 1 < LINES.length
          ? LINES[i + 1].words[0].start - 0.05
          : null;
    if (i !== activeIdx && out != null && out > heroIn - 0.25 && L.in < heroIn) out = heroIn - 0.21;
    return {
      id: "pl" + L.id,
      top: i % 2 ? rowB : rowA,
      in: +L.in.toFixed(3),
      out: out == null ? null : +Math.min(out, DUR - 0.36).toFixed(3),
      words: L.words.map((w) => [w.display, +w.start.toFixed(3), w.minor ? 1 : 0]),
    };
  });
  const shOn = `0 2px 10px rgba(1,10,20,0.85), 0 0 16px ${A}cc`;
  const shEm = `0 2px 10px rgba(1,10,20,0.85), 0 0 18px ${A}f2, 0 0 34px ${VIO}8c`;
  const css = `
  .mote { position:absolute; border-radius:50%; background:${dna.palette.mote || "#96f5e8"}f2;
          box-shadow: 0 0 7px 2px ${A}bf; opacity:0; }
  .pln { position:absolute; left:${W / 2}px; white-space:nowrap;
         font-family:'${dna.fonts.body}', sans-serif; font-weight:400; font-size:${b.fontPx}px;
         line-height:1.2; color:${dna.palette.body}; }
  .pln .w { display:inline-block; position:relative; margin-right:0.30em; opacity:0; }
  .pln .w:last-child { margin-right:0; }
  .pln .ink { display:inline-block;
         text-shadow: 0 2px 10px rgba(1,10,20,0.85), 0 0 0px ${A}00; }
  .pln .w.em .ink { color:${dna.palette.em || "#f4fffd"};
         text-shadow: 0 2px 10px rgba(1,10,20,0.85), 0 0 0px ${A}00, 0 0 0px ${VIO}00; }`;
  const html =
    `      <div id="mfield"></div>
` +
    lineData
      .map((L) => `      <div class="pln" id="${L.id}" style="top:${L.top}px"></div>`)
      .join("\n");
  const js = `
  // ---- body paradigm: PLANKTONRAIL (jellyfish glow-on, two-row float, sinking exits) ----
  const I = ${heroIn.toFixed(3)};
  const mrnd = mulberry32(${m.seed ?? 2929});
  const mf = document.getElementById("mfield");
  // plankton motes: seeded drift walks f(t); the first ${m.near ?? 5} seed a
  // ring around the bloom heart and get ATTRACTED to it as the apex lands
  const APX = ${HG.x}, APY = ${HG.y};
  for (let i = 0; i < ${m.count ?? 12}; i++) {
    const near = i < ${m.near ?? 5};
    const el = document.createElement("div");
    el.className = "mote"; mf.appendChild(el);
    let L, T;
    if (near) {
      const ang = (i / ${m.near ?? 5}) * Math.PI * 2 + 0.55 + mrnd() * 0.5;
      const r = (150 + mrnd() * 75) * ${(HG.fontPx / 120).toFixed(3)};
      L = APX + Math.cos(ang) * r * 1.35;
      T = APY + Math.sin(ang) * r * 0.85;
    } else {
      L = 60 + mrnd() * ${W - 120};
      T = 60 + mrnd() * ${H - 260};
    }
    L = Math.max(20, Math.min(${W - 25}, L)); T = Math.max(25, Math.min(${H - 175}, T));
    const s = 2 + Math.round(mrnd() * 2);
    gsap.set(el, { width: s, height: s, left: L, top: T });
    const tin = 0.04 + mrnd() * 0.35;
    tl.to(el, { opacity: 0.1, duration: 0.3, ease: "power1.out" }, tin);
    // slow seeded drift walk
    const a1 = (mrnd() - 0.5) * 36, a2 = a1 + (mrnd() - 0.5) * 36, a3 = a2 + (mrnd() - 0.5) * 30;
    const b1 = (mrnd() - 0.5) * 28, b2 = b1 + (mrnd() - 0.5) * 28, b3 = b2 + (mrnd() - 0.5) * 24;
    const dEnd = near ? I - 0.25 : ${(DUR - 0.34).toFixed(3)};
    tl.to(el, { keyframes: { x: [a1, a2, a3], y: [b1, b2, b3],
                             opacity: [0.13, 0.07, 0.11] },
               duration: dEnd - (tin + 0.32), ease: "sine.inOut" }, tin + 0.32);
    if (near) {
      // ATTRACTION: pulled toward the bloom, brightening
      const tx = (APX - L) * 0.72 + (mrnd() - 0.5) * 24;
      const ty = (APY - T) * 0.72 + (mrnd() - 0.5) * 18;
      tl.to(el, { x: tx, y: ty, opacity: 0.42, scale: 1.7,
                 duration: 0.75, ease: "power2.in" }, I - 0.19 + i * 0.045);
      tl.to(el, { keyframes: { x: [tx + 4, tx - 3, tx + 2], y: [ty - 4, ty + 3, ty - 2] },
                 duration: 0.32, ease: "sine.inOut" }, ${Math.min(heroIn + 0.59, DUR - 0.42).toFixed(3)});
      tl.to(el, { y: ty + 18, opacity: 0, duration: 0.18, ease: "power1.in" }, ${(DUR - 0.22).toFixed(3)});
    } else {
      tl.to(el, { y: "+=14", opacity: 0, duration: 0.16, ease: "power1.in" }, ${(DUR - 0.2).toFixed(3)});
    }
  }

  // ---- body lines: jellyfish glow-on, floating couplet rows, sinking exits ----
  const SH_ON = ${J(shOn)}, SH_EM = ${J(shEm)};
  const PRAIL = ${J(lineData)};
  PRAIL.forEach((L) => {
    const line = document.getElementById(L.id);
    L.words.forEach(([txt, , em]) => {
      const w = document.createElement("span");
      w.className = "w" + (em ? " em" : "");
      const ink = document.createElement("span"); ink.className = "ink";
      ink.textContent = txt; w.appendChild(ink); line.appendChild(w);
    });
    gsap.set(line, { xPercent: -50, yPercent: -50 });

    // gentle float while held (everything in the abyss floats)
    const t0 = Math.max(0.02, L.words[0][1] - 0.02);
    const D = (L.out ?? ${(DUR - 0.06).toFixed(3)}) - t0 - 0.04;
    const yArr = D > 1.5 ? [-2, 2, -3, 1, 3, 0] : [-2, 1, -2, 0];
    tl.to(line, { keyframes: { y: yArr }, duration: D, ease: "sine.inOut" }, t0);

    L.words.forEach(([txt, st, em], wi) => {
      const w = line.children[wi], ink = w.children[0];
      // GLOW ON: radial bloom from word center (the apex bloom at low amplitude)
      tl.set(w, { opacity: 1 }, st - 0.01);
      tl.fromTo(ink, { opacity: 0 }, { opacity: 1, duration: 0.083, ease: "none" }, st);
      tl.fromTo(ink, { scale: 0.92, filter: "blur(5px) brightness(0.45)" },
                { scale: 1, filter: "blur(0px) brightness(1)", duration: 0.4, ease: "sine.out" }, st);
      tl.to(ink, { textShadow: em ? SH_EM : SH_ON, duration: 0.4, ease: "sine.out" }, st);
      // EMPHASIS: slow double-beat pulse (jellyfish propulsion)
      if (em && st + 1.07 <= ${(DUR - 0.09).toFixed(3)}) {
        tl.to(ink, { keyframes: {
            filter: ["blur(0px) brightness(1.4)", "blur(0px) brightness(1.06)",
                     "blur(0px) brightness(1.5)", "blur(0px) brightness(1.12)",
                     "blur(0px) brightness(1)"],
            scale: [1.035, 1.01, 1.05, 1.015, 1] },
          duration: 0.55, ease: "sine.inOut" }, st + 0.52);
      }
    });

    // EXIT: light sinks — drift down +15 while dimming
    if (L.out != null) {
      tl.to(line, { y: 15, opacity: 0, duration: 0.3, ease: "power1.in" }, L.out);
      tl.set(line, { display: "none" }, Math.min(L.out + 0.32, ${(DUR - 0.02).toFixed(2)}));
    }
  });

  // rail yields to the apex landing: the feeding line dims (and exits dimmed
  // unless it has ≥0.9s of hold left); a line entering during the hold
  // arrives at ${yld.enter ?? 0.8} and restores once the bloom settles
  PRAIL.forEach((L, i) => {
    if (i === ${activeIdx} && (L.out ?? 99) > I - ${(yld.pre ?? 0.17).toFixed(2)} + 0.25) {
      tl.to("#" + L.id, { opacity: ${yld.dim ?? 0.55}, duration: 0.18, ease: "power1.out" }, I - ${(yld.pre ?? 0.17).toFixed(2)});
      if ((L.out ?? 99) > I + 1.2)
        tl.to("#" + L.id, { opacity: 1, duration: 0.25, ease: "power1.out" }, I + 0.9);
    }
    if (L.in > I && L.in < I + 0.9) {
      tl.set("#" + L.id, { opacity: ${yld.enter ?? 0.8} }, L.in);
      tl.to("#" + L.id, { opacity: 1, duration: 0.25, ease: "power1.out" },
            Math.min(Math.max(I + ${(yld.post ?? 0.63).toFixed(2)}, L.in + 0.1), ${(DUR - 0.28).toFixed(2)}));
    }
  });`;
  return { css, html, js };
}

function paradigmSheenrail() {
  // SHEENRAIL: iridescent silk lower third on TWO coexisting row slots — even
  // lines ride the upper row (H − bottomPx − rowDy), odd lines the lower
  // (H − bottomPx), so couplets read together like a woven pair. Words are
  // aurora-gradient ink (background-clip:text over a rose→teal→violet wash
  // whose backgroundPosition DRIFTS f(t) across the whole clip — the body's
  // hold life) above a blurred dark understroke; entrance = 2-frame opacity
  // + y8 settle (the ribbon's flow at low amplitude); emphasis = ONE bright
  // sheen sweep marching through the word + a brightness pulse. Exits
  // dissolve UPWARD (y−12, blur 3, staggered) and each line releases 3-4
  // drifting light streaks that float up from its row. A soft radial band
  // scrim sits under the rows (stable reading surface); the whole rail —
  // band included — yields while the ribbon writes. Line scheduling mirrors
  // planktonrail's two-row coexistence: a row clears when next needed (line
  // i+2) or shortly after its couplet finishes speaking (staggered pair
  // exits); the pre-apex leftover clears at heroIn−0.32 while the FEEDING
  // line stays (it dims with the railwrap yield); the last line holds.
  const b = dna.body;
  const PAL = [dna.palette.rose, dna.palette.accent, dna.palette.violet];
  const rowB = H - (b.bottomPx || 76); // lower row TOP edge
  const rowA = rowB - (b.rowDy ?? 58); // upper row TOP edge
  const band = b.band || {};
  const bandW = band.w ?? 860,
    bandH = band.h ?? 170;
  const bandCy = Math.round((rowA + rowB + b.fontPx * 1.1) / 2);
  const yld = b.yield || {};
  // active line = the one feeding the apex (last line that starts before it)
  const activeIdx = LINES.reduce((a, L, i) => (L.words[0].start < heroIn ? i : a), 0);
  const lineData = LINES.map((L, i) => {
    const pairLast = LINES[Math.min(i - (i % 2) + 1, LINES.length - 1)];
    const pairEnd = pairLast.words[pairLast.words.length - 1].end;
    let out =
      i + 2 < LINES.length
        ? Math.min(LINES[i + 2].in - 0.15, pairEnd + 0.23 + (i % 2) * 0.17)
        : i + 1 < LINES.length
          ? LINES[i + 1].words[0].start - 0.11
          : null;
    if (i !== activeIdx && out != null && out > heroIn - 0.25 && L.in < heroIn) out = heroIn - 0.32;
    return {
      id: "sk" + L.id,
      top: i % 2 ? rowB : rowA,
      out: out == null ? null : +Math.min(out, DUR - 0.4).toFixed(3),
      words: L.words.map((w) => [w.display, +w.start.toFixed(3), w.minor ? 1 : 0]),
    };
  });
  const css = `
  #skwrap { position:absolute; inset:0; }
  #band { position:absolute; left:${W / 2}px; top:${bandCy}px; width:${bandW}px; height:${bandH}px;
          margin-left:-${bandW / 2}px; margin-top:-${bandH / 2}px; opacity:0;
          background: radial-gradient(50% 50% at 50% 50%, rgba(6,10,28,0.55) 0%, rgba(6,10,28,0.3) 55%, rgba(6,10,28,0) 78%); }
  .skl { position:absolute; left:${W / 2}px; white-space:nowrap;
         font-family:'${dna.fonts.body}', serif; font-weight:500; font-size:${b.fontPx}px;
         line-height:1.1; }
  .skl .w { position:relative; display:inline-block; margin-right:0.3em; opacity:0; }
  .skl .w:last-child { margin-right:0; }
  .skl .sh { position:absolute; left:0; top:3px; color:rgba(4,8,22,0.7);
         filter:blur(3px); z-index:0; }
  .skl .grad { position:relative; z-index:1;
         background-image: linear-gradient(100deg, ${PAL[0]} 0%, ${PAL[1]} 32%, ${PAL[2]} 64%, ${PAL[0]} 96%);
         background-size: 300% 100%;
         -webkit-background-clip: text; background-clip: text; color: transparent; }
  .skl .shine { position:absolute; left:0; top:0; z-index:2; opacity:0;
         background-image: linear-gradient(100deg, rgba(255,255,255,0) 40%, rgba(255,255,255,0.95) 50%, rgba(255,255,255,0) 60%);
         background-size: 260% 100%;
         -webkit-background-clip: text; background-clip: text; color: transparent; }
  .streak { position:absolute; width:3px; border-radius:2px; opacity:0; filter:blur(2px); }`;
  const html =
    `      <div id="skwrap">
        <div id="band"></div>
` +
    lineData
      .map((L) => `        <div class="skl" id="${L.id}" style="top:${L.top}px"></div>`)
      .join("\n") +
    `
      </div>`;
  const js = `
  // ---- body paradigm: SHEENRAIL (flow-on entrance, sheen-sweep emphasis, dissolve-to-streaks exits) ----
  const I = ${heroIn.toFixed(3)};
  const srnd = mulberry32(${b.seed ?? 20260611});
  const stg = document.getElementById("stage");
  const PAL = ${J(PAL)};
  const SRAIL = ${J(lineData)};
  tl.to("#band", { opacity: 1, duration: 0.35, ease: "sine.out" }, 0.12);
  const allGrads = [];
  SRAIL.forEach((L) => {
    const line = document.getElementById(L.id);
    const wraps = [];
    L.words.forEach(([txt, st, em]) => {
      const w = document.createElement("span"); w.className = "w";
      const sh = document.createElement("span"); sh.className = "sh"; sh.textContent = txt;
      const g  = document.createElement("span"); g.className = "grad"; g.textContent = txt;
      const sn = document.createElement("span"); sn.className = "shine"; sn.textContent = txt;
      w.appendChild(sh); w.appendChild(g); w.appendChild(sn);
      line.appendChild(w); wraps.push(w); allGrads.push(g);
      // FLOW ON: 2-frame opacity + y8 settle (the ribbon's flow at low amplitude)
      tl.fromTo(w, { opacity: 0 }, { opacity: 1, duration: 2 * F, ease: "none" }, st);
      tl.fromTo(w, { y: 8 }, { y: 0, duration: 0.38, ease: "sine.out" }, st);
      // EMPHASIS: the sheen pulses bright once through the word
      if (em) {
        const pt = st + 0.16;
        tl.set(sn, { opacity: 1 }, pt);
        tl.fromTo(sn, { backgroundPosition: "-80% 50%" },
                  { backgroundPosition: "180% 50%", duration: 0.42, ease: "power2.inOut" }, pt);
        tl.to(sn, { opacity: 0, duration: 0.12, ease: "none" }, pt + 0.34);
        tl.to(g, { keyframes: { filter: ["brightness(1)", "brightness(1.55)", "brightness(1)"] },
                   duration: 0.42, ease: "none" }, pt);
      }
    });
    gsap.set(line, { xPercent: -50 });
    // EXIT: the line dissolves upward and releases drifting light streaks
    if (L.out != null) {
      tl.to(wraps, { opacity: 0, y: -12, filter: "blur(3px)",
                     duration: 0.32, ease: "power1.in", stagger: 0.035 }, L.out);
      tl.set(line, { display: "none" }, L.out + 0.6);
      const nS = ${b.streaks ?? 4};
      for (let i = 0; i < nS; i++) {
        const s = document.createElement("div"); s.className = "streak"; stg.appendChild(s);
        const c = PAL[i % 3];
        const x = ${W / 2} + (i - (nS - 1) / 2) * 95 + (srnd() - 0.5) * 50;
        const hh = 40 + Math.round(srnd() * 28);
        gsap.set(s, { left: x, top: L.top + 14, height: hh,
                      background: "linear-gradient(to top, rgba(255,255,255,0) 0%, " + c + " 50%, rgba(255,255,255,0) 100%)" });
        tl.fromTo(s, { y: 14 },
          { keyframes: { opacity: [0, 0.7, 0], y: [14, -28, -78] },
            duration: 0.62, ease: "power1.out" }, Math.min(L.out + 0.04 + i * 0.05, ${(DUR - 0.66).toFixed(3)}));
      }
    }
  });
  // continuous iridescent sheen drift f(t) — the body's hold life
  tl.fromTo(allGrads, { backgroundPosition: "0% 50%" },
            { backgroundPosition: "300% 50%", duration: ${(DUR - 0.04).toFixed(3)}, ease: "none" }, 0);
  // rail yields — band included — while the ribbon writes (apex owns the frame)
  tl.to("#skwrap", { opacity: ${yld.dim ?? 0.5}, duration: 0.2, ease: "sine.in" }, I - ${(yld.pre ?? 0.16).toFixed(2)});
  tl.to("#skwrap", { opacity: 1, duration: 0.3, ease: "sine.out" }, I + ${(yld.post ?? 0.62).toFixed(2)});`;
  return { css, html, js };
}

function paradigmScoperail() {
  // SCOPERAIL: oscilloscope lower third. A dark phosphor scope band (HUD
  // readouts, graticule) hosts an ALWAYS-ALIVE waveform — an SVG polyline
  // rebuilt from baked point-set states (seeded pseudo-RMS envelope: speech
  // grows it, silence flattens it, every word arrival kicks a local gaussian
  // burst for ~3 frames; the hero words kick WIDE bursts where the trace
  // surges off the scope to write). Body lines (phosphor mono) sit on the
  // band above the trace and wipe in L→R over reserved-width slots (the apex
  // pen-write at low amplitude) with a 2-frame just-traced flash; emphasis
  // words get a ticking frequency-readout chip; the trig LED blinks on every
  // word arrival. Lines collapse INTO the waveform on exit. At the FINAL word
  // the trace explodes to max-amp chaos, flatlines, collapses to a single dot
  // and `body.siglost` stamps — the scope dies with the clip.
  const b = dna.body;
  const A = dna.palette.accent,
    BRT = dna.palette.bright || "#d6ffe4",
    FLSH = dna.palette.flash || "#eafff2",
    EM = dna.palette.em || "#ffb347",
    HUD = dna.palette.hud || "#2f8f55";
  const bandH = b.bandH || 164;
  const bandTop = H - bandH;
  const waveH = b.waveH || 84;
  const NZ = +LASTWORD.start.toFixed(3); // the scope-death beat
  // line windows: exits collapse into the trace — early into dead air
  // (lastWordStart+0.67), else just before the next line needs the slot
  const lineData = LINES.map((L, i) => {
    const lastSt = L.words[L.words.length - 1].start;
    const next = LINES[i + 1];
    const out = next
      ? +Math.min(next.in - 0.05, lastSt + 0.67).toFixed(3)
      : +Math.min(NZ + 0.33, DUR - 0.2).toFixed(3);
    return {
      id: "sc" + L.id,
      in: +L.in.toFixed(3),
      out,
      words: L.words.map((w) => [
        w.display,
        +w.start.toFixed(3),
        w.minor ? 1 : 0,
        Math.abs(w.start - NZ) < 0.001 ? 1 : 0, // the death word settles hot
      ]),
    };
  });
  // waveform burst map BAKED from measured glyph metrics (slot centers) +
  // the hero kick positions (the trace surges wide where the apex writes)
  const GAPem = 0.32;
  const burst = []; // [t, x, big]
  LINES.forEach((L) => {
    const ws = L.words.map((w) => wordPx(w.display, dna.fonts.body, b.fontPx, 0));
    const totalW = ws.reduce((a, c) => a + c, 0) + GAPem * b.fontPx * (L.words.length - 1);
    let x = W / 2 - totalW / 2;
    L.words.forEach((w, i) => {
      burst.push([+w.start.toFixed(3), +(x + ws[i] / 2).toFixed(1), 0]);
      x += ws[i] + GAPem * b.fontPx;
    });
  });
  if (!heroInline)
    for (let k = 0; k < hero.len; k++) {
      const hx = HG.x + (hero.len === 1 ? 0 : (k / (hero.len - 1)) * 0.6 * (HG.halfW || 300));
      burst.push([+tWords[hero.idx + k].start.toFixed(3), +hx.toFixed(1), 1]);
    }
  const css = `
  #scband { position:absolute; left:0; top:${bandTop}px; width:${W}px; height:${bandH}px; opacity:0;
            background: rgba(0,12,4,0.58); border-top:1px solid ${A}4d;
            box-shadow: 0 -8px 30px rgba(0,0,0,0.35); }
  .schud { position:absolute; top:6px; font-family:'${dna.fonts.tag}', monospace; font-size:20px;
           color:${HUD}; letter-spacing:2px; }
  #schudL { left:24px; }
  #schudR { right:24px; }
  #sctrig { display:inline-block; width:10px; height:10px; border-radius:50%;
            background:${EM}; box-shadow:0 0 6px ${EM}cc; margin-left:8px; opacity:0.45; }
  #scgrat { position:absolute; left:0; top:${b.waveTop || 80}px; width:${W}px; height:${waveH}px; opacity:0.6;
            background-image:
              repeating-linear-gradient(90deg, ${A}12 0 1px, transparent 1px 80px),
              linear-gradient(180deg, transparent ${Math.round(waveH / 2 - 1)}px, ${A}1a ${Math.round(waveH / 2 - 1)}px ${Math.round(waveH / 2 + 1)}px, transparent ${Math.round(waveH / 2 + 1)}px); }
  #scwavewrap { position:absolute; left:0; top:${b.waveTop || 80}px; width:${W}px; height:${waveH}px; }
  #scwave { filter: drop-shadow(0 0 5px ${A}bf); }
  .scln { position:absolute; left:0; top:${b.lineDy || 34}px; width:${W}px; text-align:center;
          font-family:'${dna.fonts.body}', monospace; font-size:${b.fontPx}px; line-height:${Math.round(b.fontPx * 1.1)}px;
          color:${A}; text-shadow:0 0 8px ${A}73;
          white-space:nowrap; opacity:0; }
  .scln .w { display:inline-block; vertical-align:bottom; white-space:nowrap;
             margin-right:${GAPem}em; position:relative; }
  .scln .sz { visibility:hidden; }
  .scln .clip { position:absolute; left:0; top:0; overflow:hidden; white-space:nowrap; width:0; }
  .scchip { position:absolute; left:50%; top:-26px; width:130px; margin-left:-65px;
            height:24px; font-family:'${dna.fonts.tag}', monospace; font-size:21px;
            color:${EM}; text-shadow:0 0 6px ${EM}99; opacity:0; }
  .scchip .cv { position:absolute; left:0; top:0; width:100%; text-align:center; opacity:0; }
  #scdot { position:absolute; left:${W / 2 - 6}px; top:${bandTop + (b.waveTop || 80) + Math.round(waveH / 2) - 4}px;
           width:9px; height:9px; border-radius:50%; background:${BRT}; opacity:0;
           box-shadow:0 0 8px ${A}, 0 0 22px ${A}e6; }
  #scsiglost { position:absolute; left:0; top:${bandTop + (b.waveTop || 80)}px; width:${W}px; text-align:center;
               font-family:'${dna.fonts.tag}', monospace; font-size:24px; letter-spacing:6px;
               color:${EM}; text-shadow:0 0 10px ${EM}b3; opacity:0; }`;
  const html =
    `      <div id="scband">
        <div class="schud" id="schudL">${esc(b.hudL || "SPECTRUM // CH1")}</div>
        <div class="schud" id="schudR">${esc(b.hudR || FPS + ".0 kS/s · TRIG")}<span id="sctrig"></span></div>
        <div id="scgrat"></div>
        <div id="scwavewrap">
          <svg width="${W}" height="${waveH}" viewBox="0 0 ${W} ${waveH}">
            <polyline id="scwave" fill="none" stroke="${A}" stroke-width="2.5"
                      stroke-linejoin="round" points=""/>
          </svg>
        </div>
` +
    lineData.map((L) => `        <div class="scln" id="${L.id}"></div>`).join("\n") +
    `
      </div>
      <div id="scsiglost">${esc(b.siglost || "— SIGNAL LOST —")}</div>
      <div id="scdot"></div>`;
  const js = `
  // ---- body paradigm: SCOPERAIL (trace-wipe words, waveform alive all clip) ----
  const rnd = mulberry32(${b.waveSeed || 7});
  const NZ = ${NZ.toFixed(3)}, ENDC = ${(DUR - 0.02).toFixed(3)};
  const BURST = ${J(burst)};            // [t, x, big] — word arrivals kick the trace
  const allT = BURST.map((q) => q[0]);
  const burstAt = {}; const bigAt = {};
  BURST.forEach(([t, x, big]) => { burstAt[t] = x; if (big) bigAt[t] = 1; });

  // ===== baked waveform states (pure f(t) + seeded jitter; ~2 states/word frame) =====
  const timesSet = [];
  let t0 = 0;
  while (t0 < NZ - 0.015) { timesSet.push(t0); t0 += 1 / 12; }
  allT.forEach((wt) => { [wt, wt + F, wt + 2 * F].forEach((x) => { if (x < NZ - 0.015) timesSet.push(x); }); });
  for (let te = NZ - 0.01; te <= Math.min(NZ + 0.311, ENDC); te += F) timesSet.push(te);
  timesSet.push(Math.min(NZ + 0.325, ENDC), Math.min(NZ + 0.35, ENDC));
  timesSet.sort((a, b2) => a - b2);
  const times = timesSet.filter((x, i) => i === 0 || x - timesSet[i - 1] > 0.02);
  const NPTS = ${Math.round(W / 20) + 1}, MIDY = ${Math.round(waveH / 2)};
  function speechEnv(t) {
    let e = 0;
    allT.forEach((wt) => {
      const g = Math.exp(-Math.pow((t - wt - 0.10) / 0.21, 2));
      if (g > e) e = g;
    });
    return e;
  }
  const wavePts = [];
  times.forEach((t) => {
    const boom = t > NZ - 0.007 && t < NZ + 0.308;
    const flat = t >= NZ + 0.32;
    const baseAmp = flat ? 0.5 : (boom ? 34 : 2.2 + 15 * speechEnv(t));
    const pts = [];
    for (let i = 0; i < NPTS; i++) {
      const x = i * 20;
      let amp = baseAmp;
      if (!boom && !flat) {
        allT.forEach((wt) => {
          if (t >= wt - 0.005 && t <= wt + 0.13) {
            const big = bigAt[wt];
            amp += (big ? 30 : 20) * Math.exp(-Math.pow((x - burstAt[wt]) / (big ? 240 : 95), 2));
          }
        });
      }
      const jit = (rnd() - 0.5) * 2;
      let y = amp * (0.52 * Math.sin(i * 0.55 + t * 16.2 + 0.7)
                   + 0.42 * Math.sin(i * 1.27 - t * 23.5 + 2.1)
                   + (boom ? 0.85 : 0.42) * jit);
      if (y > MIDY - 4) y = MIDY - 4; if (y < -(MIDY - 4)) y = -(MIDY - 4);
      pts.push(x + "," + (MIDY + y).toFixed(1));
    }
    wavePts.push([t, pts.join(" ")]);
  });
  const scwave = document.getElementById("scwave");
  scwave.setAttribute("points", wavePts[0][1]);
  wavePts.forEach(([t, str]) => { tl.set(scwave, { attr: { points: str } }, Math.min(t, ENDC)); });

  // ===== scope power-on =====
  tl.fromTo("#scband", { opacity: 0 }, { opacity: 1, duration: 0.12, ease: "power1.out" }, 0.02);
  tl.fromTo("#scwavewrap", { scaleX: 0.04, transformOrigin: "50% 50%" },
            { scaleX: 1, duration: 0.22, ease: "power2.out" }, 0.04);
${
  b.yield && !heroInline
    ? `
  // rail yields while the apex trace writes (band carries lines + wave + HUD)
  tl.to("#scband", { opacity: ${b.yield.dim}, duration: 0.18, ease: "power1.in" }, ${Math.max(heroIn - (b.yield.pre || 0.13), 0.2).toFixed(3)});
  tl.to("#scband", { opacity: 1, duration: 0.25, ease: "power1.out" }, ${Math.min(heroIn + (b.yield.post || 0.97), DUR - 0.4).toFixed(3)});`
    : ""
}

  // ===== body lines: trace-wipe entrances over reserved-width slots =====
  const SRAIL = ${J(lineData)};
  function chipVals(r, idx) {
    if (idx % 2 === 0) {
      let v = 150 + Math.round(r() * 250); const a = [v];
      v += 300 + Math.round(r() * 200); a.push(v);
      v += 200 + Math.round(r() * 150); a.push(v);
      return a.map((x) => x + " Hz");
    }
    let v = 0.3 + Math.round(r() * 3) * 0.1; const a = [v];
    v += 0.4 + Math.round(r() * 2) * 0.1; a.push(v);
    v += 0.2 + Math.round(r() * 2) * 0.1; a.push(v);
    return a.map((x) => x.toFixed(1) + " kHz");
  }
  const crnd = mulberry32(${b.chipSeed || 11});
  let chipIdx = 0;
  SRAIL.forEach((L) => {
    const line = document.getElementById(L.id);
    tl.set(line, { opacity: 1 }, L.words[0][1] - 0.03);
    L.words.forEach(([txt, wt, minor, death]) => {
      // reserved-width slot: sizer keeps the centered line static (no
      // re-center drift as words arrive); the clip wipes the word in over it.
      const wrap = document.createElement("span"); wrap.className = "w";
      const sizer = document.createElement("span"); sizer.className = "sz";
      sizer.textContent = txt;
      const clip = document.createElement("span"); clip.className = "clip";
      const inner = document.createElement("span");
      inner.textContent = txt;
      clip.appendChild(inner); wrap.appendChild(sizer); wrap.appendChild(clip);
      line.appendChild(wrap);
      // entrance = apex motif at low amplitude: the trace draws it L→R
      tl.fromTo(clip, { width: 0 }, { width: "auto", duration: 0.10, ease: "none" }, wt);
      // phosphor just-traced flash (full brightness instantly, settle in 2 frames)
      const settle = death
        ? { color: ${J(EM)}, textShadow: "0 0 9px ${EM}8c" }
        : { color: ${J(A)}, textShadow: "0 0 8px ${A}73" };
      tl.set(inner, { color: ${J(FLSH)}, textShadow: "0 0 16px ${BRT}f2" }, wt);
      tl.set(inner, settle, wt + 2 * F);
      // trigger LED blink on every word arrival
      tl.set("#sctrig", { opacity: 1, scale: 1.35 }, wt);
      tl.set("#sctrig", { opacity: 0.45, scale: 1 }, wt + 2 * F);
      // emphasis: ticking frequency readout (seeded, ascending)
      if (minor) {
        const chip = document.createElement("span"); chip.className = "scchip";
        const cvs = chipVals(crnd, chipIdx++).map((v) => {
          const s = document.createElement("span"); s.className = "cv"; s.textContent = v;
          chip.appendChild(s); return s;
        });
        wrap.appendChild(chip);
        tl.set(chip, { opacity: 1 }, wt + 0.12);
        cvs.forEach((s, i) => {
          tl.set(s, { opacity: 1 }, wt + 0.12 + i * 2 * F);
          if (i < cvs.length - 1) tl.set(s, { opacity: 0 }, wt + 0.12 + (i + 1) * 2 * F);
        });
      }
    });
    // exit: the line collapses INTO the waveform
    tl.to(line, { y: 30, opacity: 0, duration: 0.13, ease: "power2.in" }, L.out);
  });

  // ===== the death beat: explosion / band shake / flatline / dot / siglost =====
  [[3, 0], [-4, 1], [3, 2], [-2, 3], [0, 4]].forEach(([dx, k]) => {
    tl.set("#scband", { x: dx }, Math.min(NZ + k * 0.042, ENDC));
  });
  tl.set("#scband", { x: 0 }, Math.min(NZ + 0.21, ENDC));
  // trig LED panics, then dies
  [0, 1, 2, 3].forEach((k) => {
    tl.set("#sctrig", { opacity: k % 2 ? 0.2 : 1, background: ${J(dna.palette.panic || "#ff5f47")},
                        boxShadow: "0 0 8px ${(dna.palette.panic || "#ff5f47") + "e6"}" }, Math.min(NZ + k * 2 * F, ENDC));
  });
  tl.set("#sctrig", { opacity: 0.18 }, Math.min(NZ + 0.35, ENDC));
  // flatline → trace shrinks to a single dot
  tl.to("#scwavewrap", { scaleX: 0.015, duration: 0.055, ease: "power3.in",
                         transformOrigin: "50% 50%" }, Math.min(NZ + 0.365, ENDC - 0.06));
  tl.set("#scwavewrap", { opacity: 0 }, Math.min(NZ + 0.425, ENDC));
  tl.set("#scdot", { opacity: 1 }, Math.min(NZ + 0.39, ENDC));
  tl.set("#scdot", { opacity: 0.45 }, Math.min(NZ + 0.465, ENDC));
  tl.set("#scdot", { opacity: 1 }, Math.min(NZ + 0.49, ENDC));
  // SIGNAL LOST stamp (2-frame flicker in; clamped ≤ DUR−0.02)
  tl.set("#scsiglost", { opacity: 1 }, Math.min(NZ + 0.43, ENDC - 2 * F));
  tl.set("#scsiglost", { opacity: 0.35 }, Math.min(NZ + 0.43 + F, ENDC - F));
  tl.set("#scsiglost", { opacity: 1 }, Math.min(NZ + 0.43 + 2 * F, ENDC));`;
  return { css, html, js };
}

function paradigmPaperrail() {
  // PAPERRAIL: stop-motion craft table — a kraft strip (torn top edge, washi-
  // taped corners, handwritten tag) holds cream paper chips that are PLACED
  // one by one: opacity full in 0 frames, y −16 + scale 1.06→1 with steps(2),
  // seeded ±2.5° crooked = the papermat drop at low amplitude. The strip
  // jitters ±0.7px every 4 frames (handmade weave); chips hold a rotation-only
  // ~5fps wobble. Emphasis words get a washi-tape slap (chip pressed 1 frame);
  // the FINAL word is an accent chip (sign-off); the hero slot keeps a red
  // paper star (rail↔climax hand-off) that pulses as the apex chips land.
  // Exits: chips peeled off (rotation −8°, y −40, steps(3)), staggered.
  const b = dna.body;
  const stripW = (b.strip && b.strip.w) || 1140;
  const stripH = (b.strip && b.strip.h) || 88;
  const SX = (b.strip && b.strip.x) ?? Math.round((W - stripW) / 2);
  const ST = H - ((b.strip && b.strip.bottomPx) ?? 26) - stripH;
  const k = (b.fontPx || 38) / 38; // chip furniture scales with the type
  const r = (v) => Math.round(v * k);
  const tapeDelay = b.tapeDelay ?? 0.25;
  // hero hand-off slot: the line whose window holds the apex keeps a star chip
  const heroLi = heroInline ? -1 : LINES.findIndex((L) => heroIn >= L.in - 0.01 && heroIn < L.out);
  const lineData = LINES.map((L, li) => {
    const n = L.words.length + (li === heroLi ? 1 : 0);
    // peel (0.18s + stagger) always completes on frame; demo cadence L.out−0.04
    const xo = +(
      li === LINES.length - 1 ? Math.min(L.out - 0.04, DUR - 0.2 - (n - 1) * 0.03) : L.out - 0.04
    ).toFixed(3);
    const words = L.words.map((w, wi) => {
      const isLast = li === LINES.length - 1 && wi === L.words.length - 1;
      const kind = isLast ? 3 : w.minor ? 1 : 0; // 0 chip / 1 taped / 2 star / 3 accent
      let tapeT = 0;
      if (kind === 1) {
        const tt = +Math.min(w.start + tapeDelay, xo - 0.2).toFixed(3);
        if (tt >= w.start + 0.08) tapeT = tt;
      }
      return [w.display, +w.start.toFixed(3), kind, tapeT];
    });
    if (li === heroLi) {
      const at = L.words.filter((w) => w.start < heroIn).length;
      words.splice(at, 0, ["*", +heroIn.toFixed(3), 2, 0]);
    }
    return { id: "pl" + L.id, in: +L.in.toFixed(3), xo, words };
  });
  const css = `
  #pstrip { position:absolute; left:${SX}px; top:${ST}px; width:${stripW}px; height:${stripH}px; opacity:0; }
  #pstrip .paper { position:absolute; inset:0;
                   background: linear-gradient(180deg, #bd9e6f 0%, #b89968 45%, #a8854f 100%);
                   box-shadow: inset 0 -4px 0 rgba(90,66,30,0.55); }
  .pwt  { position:absolute; width:84px; height:24px; opacity:0.62; background: rgba(255,99,84,0.85); }
  .pwt i { position:absolute; inset:0; display:block;
           background: repeating-linear-gradient(90deg, rgba(255,255,255,0.22) 0 6px, rgba(255,255,255,0) 6px 13px); }
  #pwtL { left:-16px; top:-11px; } #pwtR { right:-14px; top:-9px; }
  #ptag { position:absolute; right:30px; top:${stripH - 38}px; font-family:'${dna.fonts.tag}', cursive;
          font-size:24px; color:${dna.palette.tag || "#503d20"}; opacity:0.85; white-space:nowrap; }
  #plines { position:absolute; inset:0; }
  .pline { position:absolute; left:${W / 2}px; top:${ST + 14}px; white-space:nowrap; opacity:0; }
  .pchip { position:relative; display:inline-block; margin-right:${r(10)}px; padding:${r(5)}px ${r(14)}px ${r(7)}px;
           background:${dna.palette.chip || "#f6edd8"}; border:1px solid ${dna.palette.chipEdge || "#cdb98e"}; border-radius:3px;
           box-shadow: 0 3px 0 rgba(58,38,16,0.38);
           font-family:'${dna.fonts.body}', sans-serif; font-weight:600; font-size:${b.fontPx}px; line-height:1.15;
           color:${dna.palette.body}; opacity:0; }
  .pchip.red { background:${dna.palette.accent}; border-color:${dna.palette.accentEdge || "#c93527"}; color:#fff3e4; }
  .ptape { position:absolute; top:-10px; right:-15px; width:${r(58)}px; height:${r(18)}px; opacity:0;
           background: rgba(255,79,63,0.66); }
  .ptape i { position:absolute; inset:0; display:block;
             background: repeating-linear-gradient(90deg, rgba(255,255,255,0.25) 0 5px, rgba(255,255,255,0) 5px 11px); }
  .pstarw { position:relative; display:inline-block; width:${r(46)}px; height:${r(44)}px;
            margin-right:${r(10)}px; vertical-align:${-r(7)}px; opacity:0; }
  .pstar { position:absolute; inset:0; background:${dna.palette.accent};
           clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
           filter: drop-shadow(0 3px 0 rgba(58,38,16,0.38)); }`;
  const html = `      <div id="pstrip">
        <div class="paper"></div>
        <div class="pwt" id="pwtL"><i></i></div>
        <div class="pwt" id="pwtR"><i></i></div>
        <div id="ptag">${esc(b.tag || "cut & paste · 12 fps")}</div>
      </div>
      <div id="plines">
${lineData.map((L) => `        <div class="pline" id="${L.id}"></div>`).join("\n")}
      </div>`;
  const dimT = +Math.max(0.15, heroIn - ((b.yield && b.yield.pre) || 0.2)).toFixed(3);
  const resT = +(heroIn + ((b.yield && b.yield.post) || 0.9)).toFixed(3);
  const js = `
  // ---- body paradigm: PAPERRAIL (chips placed steps(2) / peel-off exit) ----
  const RAILP = ${J(lineData)};
  const rrnd = mulberry32(${b.seed || 150372});
  // torn top edge on the kraft strip (sides/bottom are the table cut)
  (function(){
    const pts = []; const SEG = 28;
    for (let i = 0; i <= SEG; i++)
      pts.push(((${stripW}/SEG)*i).toFixed(1) + "px " + Math.max(0, (rrnd()-0.5)*8 + 4).toFixed(1) + "px");
    pts.push("${stripW}px ${stripH}px", "0px ${stripH}px");
    document.querySelector("#pstrip .paper").style.clipPath = "polygon(" + pts.join(",") + ")";
  })();
  gsap.set("#pwtL", { rotation: -38 });
  gsap.set("#pwtR", { rotation: 33 });
  gsap.set("#ptag", { rotation: -2 });

  // ===== STRIP: placed on the table, then handmade jitter every 4 frames =====
  tl.set("#pstrip", { opacity: 1 }, 0.10);
  tl.fromTo("#pstrip", { y: ${stripH + 26} }, { y: 0, duration: 0.18, ease: "steps(3)" }, 0.10);
  for (let t = 0.45; t < ${(DUR - 0.06).toFixed(3)}; t += 1/6)
    tl.set("#pstrip", { x: (rrnd()-0.5)*1.4, y: (rrnd()-0.5)*1.4, rotation: (rrnd()-0.5)*0.36 }, t);
${
  b.yield && !heroInline
    ? `
  // rail yields while the apex chips land (furniture never contests the hero)
  tl.to(["#pstrip", "#plines"], { opacity: ${b.yield.dim}, duration: 0.10 }, ${dimT});
${resT + 0.3 < DUR ? `  tl.to(["#pstrip", "#plines"], { opacity: 1, duration: 0.22 }, ${resT});` : ""}`
    : ""
}

  // ===== BODY: paper chips placed one by one =====
  RAILP.forEach((L) => {
    const line = document.getElementById(L.id);
    gsap.set(line, { xPercent: -50 });
    tl.set(line, { opacity: 1 }, L.in);
    L.words.forEach(([txt, st, kind, tapeT], wi) => {
      let el;
      if (kind === 2) {                  // apex hand-off: red paper star in the slot
        el = document.createElement("span"); el.className = "pstarw";
        const s = document.createElement("span"); s.className = "pstar";
        el.appendChild(s); line.appendChild(el);
        // placement pulse when the apex word starts landing
        tl.set(s, { scale: 1.18 }, st + 0.09);
        tl.set(s, { scale: 1.06 }, st + 0.17);
        tl.set(s, { scale: 1 },    st + 0.25);
      } else {
        el = document.createElement("span");
        el.className = "pchip" + (kind === 3 ? " red" : "");
        el.textContent = txt;
        line.appendChild(el);
      }
      const r0 = (rrnd()-0.5)*5;         // seeded ±2.5° crooked placement
      gsap.set(el, { transformOrigin: "50% 80%" });
      // PLACED: the apex drop at low amplitude — opacity full in 0 frames
      tl.set(el, { opacity: 1, rotation: r0 }, st);
      tl.fromTo(el, { y: -16, scale: 1.06 }, { y: 0, scale: 1, duration: 0.12, ease: "steps(2)" }, st);
      // handmade hold wobble (rotation only, ~5fps)
      for (let t = st + 0.40; t < L.xo - 0.06; t += 0.21)
        tl.set(el, { rotation: r0 + (rrnd()-0.5)*1.0 }, t);
      // washi-tape slap pins the emphasis words
      if (kind === 1 && tapeT > 0) {
        const tape = document.createElement("span"); tape.className = "ptape";
        const ti = document.createElement("i"); tape.appendChild(ti);
        el.appendChild(tape);
        gsap.set(tape, { rotation: -36 });
        tl.set(tape, { opacity: 0.92 }, tapeT);
        tl.fromTo(tape, { scale: 1.7 }, { scale: 1, duration: 0.0833, ease: "steps(2)" }, tapeT);
        tl.set(el, { y: 2 }, tapeT);     // chip pressed by the slap
        tl.set(el, { y: 0 }, tapeT + 0.0833);
      }
      // EXIT: peeled off
      tl.to(el, { rotation: r0 - 8, y: -40, opacity: 0, duration: 0.18, ease: "steps(3)" },
            L.xo + wi*0.03);
    });
  });`;
  return { css, html, js };
}

function paradigmPopuprail() {
  // POPUPRAIL: pop-up book page — a cream page strip (center foldline) unfolds
  // up from the bottom edge (scaleY back.out, the stage is built); words POP
  // UP on a baseline hinge (rotationX 85→0, back.out = the centerfold motif at
  // low amplitude) on two reading rows that alternate per line. Emphasis words
  // land bigger with a contact squash the strip FEELS (1px dip), then flick-
  // wobble; blue paper struts lean in behind emphasis + the sign-off word; the
  // hero slot keeps a blue paper star (rail↔climax hand-off). Exits fold flat
  // in an L→R wave (the page closing); on the final word the whole page TILTS
  // (paper physics) and the strip itself folds shut on the last frame.
  const b = dna.body;
  const stripW = (b.strip && b.strip.w) || 1230;
  const stripH = (b.strip && b.strip.h) || 124;
  const rowY = { A: H - (b.rowA ?? 70), B: H - (b.rowB ?? 20) };
  const k = (b.fontPx || 42) / 42; // chip furniture scales with the type
  const r = (v) => Math.round(v * k);
  const emPx = b.emPx || Math.round((b.fontPx || 42) * 1.24);
  const maxHold = b.maxHold ?? 0.95;
  // hero hand-off slot: the line whose window holds the apex keeps a star chip
  const heroLi = heroInline ? -1 : LINES.findIndex((L) => heroIn >= L.in - 0.01 && heroIn < L.out);
  const lineData = LINES.map((L, li) => {
    const lastSt = L.words[L.words.length - 1].start;
    const n = L.words.length + (li === heroLi ? 1 : 0);
    // fold as the next line begins, or after maxHold in silence; the fold wave
    // + slot hide always complete by DUR−0.017 (demo cadence: L4 folds 5.76)
    const foldT = +Math.min(L.out, lastSt + maxHold, DUR - 0.147 - 0.045 * (n - 1)).toFixed(3);
    const words = L.words.map((w, wi) => {
      const isLast = li === LINES.length - 1 && wi === L.words.length - 1;
      const kind = isLast ? 2 : w.minor ? 1 : 0; // 0 plain | 1 emph | 2 emph-blue | 3 star
      let wobT = 0,
        wobD = 0,
        strutT = 0;
      if (kind === 1) {
        // paper-flick after landing, squeezed to fit before the fold
        const wt = +(w.start + 0.2).toFixed(3);
        const wd = +Math.min(0.5, foldT - wt - 0.04).toFixed(2);
        if (wd >= 0.15) {
          wobT = wt;
          wobD = wd;
        }
      }
      if (kind >= 1) {
        const st2 = +Math.min(w.start + (kind === 2 ? 0.15 : 0.26), foldT - 0.1).toFixed(3);
        if (st2 >= w.start + 0.08) strutT = st2;
      }
      return [w.display, +w.start.toFixed(3), kind, wobT, wobD, strutT];
    });
    if (li === heroLi) {
      const at = L.words.filter((w) => w.start < heroIn).length;
      words.splice(at, 0, ["*", +heroIn.toFixed(3), 3, 0, 0, 0]);
    }
    return { id: "pu" + L.id, row: li % 2 === 0 ? "A" : "B", foldT, words };
  });
  const css = `
  #pupage { position:absolute; inset:0; }
  #pustrip { position:absolute; left:${W / 2}px; top:${H}px; width:${stripW}px; height:${stripH}px;
             opacity:0; border-radius:4px 4px 0 0;
             background: linear-gradient(180deg, ${dna.palette.page || "#f7eed8"} 0%, ${dna.palette.page2 || "#f0e4c8"} 55%, ${dna.palette.page3 || "#e7d8b4"} 100%);
             border-top: 2px solid ${dna.palette.pageEdge || "rgba(122,96,54,0.55)"};
             box-shadow: 0 -12px 26px rgba(18,12,5,0.38), inset 0 14px 18px rgba(255,255,248,0.5); }
  #pufold { position:absolute; left:50%; top:0; bottom:0; width:3px; margin-left:-1px;
            background: linear-gradient(90deg, rgba(120,95,55,0) 0%, rgba(120,95,55,0.30) 50%, rgba(255,252,240,0.5) 100%); }
  .puline { position:absolute; left:${W / 2}px; white-space:nowrap; }
  .puslot { display:inline-block; position:relative; margin-right:0.34em; vertical-align:baseline; }
  .puw { display:inline-block; position:relative; z-index:1; opacity:0;
         font-family:'${dna.fonts.body}', sans-serif; font-weight:600; font-size:${b.fontPx}px;
         line-height:1.04; color:${dna.palette.body}; }
  .puw.em { font-size:${emPx}px; font-weight:800; }
  .puw.blue { color:${dna.palette.blue || dna.palette.accent}; }
  .puw.star { width:${r(30)}px; height:${r(30)}px; background:${dna.palette.accent}; vertical-align:${-r(2)}px;
              filter: drop-shadow(0 2px 2px rgba(60,40,15,0.35));
              clip-path: polygon(50% 0%,61% 35%,98% 35%,68% 57%,79% 91%,50% 70%,21% 91%,32% 57%,2% 35%,39% 35%); }
  .pustrut { position:absolute; right:-8px; bottom:0; width:${r(19)}px; height:${r(16)}px;
             background: linear-gradient(180deg,${dna.palette.strut1 || "#4f93c4"} 0%, ${dna.palette.strut2 || dna.palette.accent} 70%);
             clip-path: polygon(0 100%, 100% 100%, 100% 0); opacity:0; z-index:0; }`;
  const html = `      <div id="pupage">
        <div id="pustrip"><div id="pufold"></div></div>
${lineData.map((L) => `        <div class="puline" id="${L.id}"></div>`).join("\n")}
      </div>`;
  const js = `
  // ---- body paradigm: POPUPRAIL (baseline-hinge pop-ups / fold-flat exit) ----
  const RAILU = ${J(lineData)};
  const ROWY = ${J(rowY)};

  // ===== PAGE STRIP: unfolds up from the bottom edge (the stage is built) =====
  gsap.set("#pustrip", { xPercent: -50, yPercent: -100, transformOrigin: "50% 100%", scaleY: 0.02 });
  tl.set("#pustrip", { opacity: 1 }, 0.14);
  tl.to("#pustrip", { scaleY: 1, duration: 0.24, ease: "back.out(1.5)" }, 0.14);
  // hold life: the whole page breathes very gently
  tl.to("#pupage", { keyframes: { y: [0, -1.6, -0.4, -1.4, 0] },
                     duration: ${Math.min(5.3, DUR - 0.75).toFixed(2)}, ease: "sine.inOut" }, 0.40);

  // ===== BODY: words pop up on the baseline hinge =====
  RAILU.forEach((L) => {
    const line = document.getElementById(L.id);
    line.style.top = ROWY[L.row] + "px";
    const els = L.words.map(([txt, st, kind]) => {
      const slot = document.createElement("span"); slot.className = "puslot";
      let strut = null;
      if (kind === 1 || kind === 2) {
        strut = document.createElement("div"); strut.className = "pustrut";
        slot.appendChild(strut);
      }
      const w = document.createElement("span");
      w.className = "puw" + (kind === 1 ? " em" : kind === 2 ? " em blue" : kind === 3 ? " star" : "");
      if (kind !== 3) w.textContent = txt;
      slot.appendChild(w);
      line.appendChild(slot);
      return { w, strut, slot };
    });
    gsap.set(line, { xPercent: -50, yPercent: -100 });

    L.words.forEach(([txt, st, kind, wobT, wobD, strutT], wi) => {
      const { w, strut, slot } = els[wi];
      gsap.set(w, { transformPerspective: 650, transformOrigin: "50% 100%", rotationX: 85 });
      // POP UP — baseline hinge, paper overshoot; back-shadow grows as it rises
      tl.set(w, { opacity: 1, textShadow: "0 0px 0px rgba(70,50,25,0)" }, st);
      tl.to(w, { rotationX: 0, duration: 0.25, ease: "back.out(1.4)" }, st);
      tl.to(w, { textShadow: "0 3px 5px rgba(70,50,25,0.38)", duration: 0.25, ease: "power1.out" }, st);
      if (kind === 1 || kind === 2) {           // contact squash on the big words
        tl.set(w, { scaleX: 1.06, scaleY: 0.93 }, st + 0.12);
        tl.to(w, { scaleX: 1, scaleY: 1, duration: 0.30, ease: "elastic.out(1, 0.4)" }, st + 0.16);
        tl.set("#pustrip", { y: 1 }, st + 0.13);  // the page feels the landing
        tl.to("#pustrip", { y: 0, duration: 0.16, ease: "power2.out" }, st + 0.18);
      }
      if (wobT) tl.to(w, { keyframes: { rotationX: [0, -4, 2.8, -1.4, 0.5, 0] },
                          duration: wobD, ease: "sine.inOut" }, wobT);
      if (strut && strutT) {                    // visible paper strut leans in behind
        gsap.set(strut, { transformOrigin: "50% 100%", scaleY: 0 });
        tl.set(strut, { opacity: 1 }, strutT);
        tl.to(strut, { scaleY: 1, duration: 0.12, ease: "back.out(2)" }, strutT);
      }
      // EXIT — the page closes: words fold flat in an L→R wave
      const ft = L.foldT + wi * 0.045;
      tl.to(w, { rotationX: 88, duration: 0.12, ease: "power2.in" }, ft);
      tl.to(w, { textShadow: "0 0px 0px rgba(70,50,25,0)", duration: 0.12 }, ft);
      if (strut) tl.to(strut, { scaleY: 0, duration: 0.10, ease: "power2.in" }, ft);
      tl.set(slot, { opacity: 0 }, ft + 0.13);
    });
  });
${
  b.yield && !heroInline
    ? `
  // APEX OWNS ITS WINDOW — page yields while the centerfold lands, restores
  tl.to("#pupage", { opacity: ${b.yield.dim}, duration: 0.12, ease: "power1.out" }, ${(heroIn - (b.yield.pre || 0.03)).toFixed(3)});
  tl.to("#pupage", { opacity: 1, duration: 0.22, ease: "power1.in" }, ${(heroIn + (b.yield.post || 0.47)).toFixed(3)});`
    : ""
}
${
  b.tilt !== false
    ? `
  // paper-world physics: on the final word the whole page TILTS and slides
  tl.to("#pupage", { rotation: 2, x: 6, transformOrigin: "50% 100%",
                     duration: 0.26, ease: "back.out(2.2)" }, ${(LASTWORD.start + 0.02).toFixed(3)});`
    : ""
}
  // ending: after the last fold the strip itself closes (book shut)
  tl.to("#pustrip", { scaleY: 0.03, duration: 0.07, ease: "power3.in" }, ${(DUR - 0.082).toFixed(3)});
  tl.set("#pustrip", { opacity: 0 }, ${(DUR - 0.017).toFixed(3)});`;
  return { css, html, js };
}

function paradigmChalkrail() {
  // CHALKRAIL: night-lecture chalk band — a deep-green board strip (worn top
  // hairline, eraser smudges, handwritten header tag, two chalk sticks) docked
  // at the bottom edge holds reading rows that ACCUMULATE: lines fill the rows
  // top→bottom in phases of rows.length; when the next phase needs the rows an
  // ERASER SWIPE clears the board (a blurred light band sweeps across, the
  // words SMEAR out scaleX 1.3 + blur(8), faint ghost smudges stay — chalk
  // never fully erases). Words are chalk-written fast: 2-frame pop + scribble
  // clip-reveal steps(4) + 6 seeded dust specks puffing at the base + seeded
  // ±2° tilt = the chalkwrite verb at low amplitude. Emphasis = double
  // underline dashes drawn beneath; the FINAL transcript word lands in yellow
  // chalk (sign-off). Band yields during the apex landing, restores after.
  const b = dna.body;
  const stripW = (b.strip && b.strip.w) || 1200;
  const stripH = (b.strip && b.strip.h) || 148;
  const SX = (b.strip && b.strip.x) ?? Math.round((W - stripW) / 2);
  const ST = H - ((b.strip && b.strip.bottomPx) ?? 0) - stripH;
  const rows = b.rows || [26, 82];
  const R = rows.length;
  const kw = stripW / 1200,
    kh = stripH / 148; // furniture scales with the strip (demo 1200×148)
  const lineData = LINES.map((L, li) => ({
    id: "cl" + L.id,
    top: rows[li % R],
    words: L.words.map((w, wi) => [
      w.display,
      +w.start.toFixed(3),
      w.minor ? 1 : 0,
      li === LINES.length - 1 && wi === L.words.length - 1 ? 1 : 0, // yellow sign-off
    ]),
  }));
  // eraser passes: one per phase boundary — swipe after the previous phase's
  // last word ends, always landing clear of the next phase's first word
  const erases = [];
  for (let pi = R; pi < LINES.length; pi += R) {
    const prevEnd = Math.max(
      ...LINES.slice(pi - R, pi).map((L) => L.words[L.words.length - 1].end),
    );
    const nextIn = LINES[pi].in;
    const t = +Math.max(
      prevEnd + 0.05,
      Math.min(prevEnd + (b.eraseDelay ?? 0.23), nextIn - 0.9),
    ).toFixed(3);
    erases.push({ t, lines: lineData.slice(pi - R, pi).map((L) => L.id) });
  }
  const css = `
  #cband { position:absolute; left:${SX}px; top:${ST}px; width:${stripW}px; height:${stripH}px; opacity:0;
           background: linear-gradient(180deg, ${dna.palette.bandHi || "#23392d"} 0%, ${dna.palette.board || "#1d3328"} 45%, ${dna.palette.bandLo || "#16271f"} 100%);
           border-radius:8px 8px 0 0;
           box-shadow: inset 0 0 70px rgba(0,0,0,0.5), inset 0 14px 18px -12px rgba(0,0,0,0.6),
                       0 -3px 18px rgba(0,0,0,0.35); }
  #cband .hairline { position:absolute; top:0; left:0; right:0; height:1px;
                     background: rgba(240,245,235,0.14); }
  #cband .smg { position:absolute; border-radius:50%; }
  #csmg1 { left:${Math.round(520 * kw)}px; top:${Math.round(14 * kh)}px; width:${Math.round(300 * kw)}px; height:${Math.round(90 * kh)}px; transform:rotate(-4deg);
           background: radial-gradient(50% 50% at 50% 50%, rgba(220,230,220,0.07) 0%, rgba(220,230,220,0) 70%); }
  #csmg2 { left:${Math.round(880 * kw)}px; top:${Math.round(60 * kh)}px; width:${Math.round(260 * kw)}px; height:${Math.round(74 * kh)}px; transform:rotate(5deg);
           background: radial-gradient(50% 50% at 50% 50%, rgba(220,230,220,0.06) 0%, rgba(220,230,220,0) 70%); }
  #chdr { position:absolute; top:5px; left:28px; font-family:'${dna.fonts.tag || dna.fonts.body}', cursive;
          font-size:23px; letter-spacing:1.5px; color:rgba(238,244,236,0.5); opacity:0; }
  #cstickW { position:absolute; right:30px; bottom:12px; width:52px; height:9px; border-radius:4px;
             background: linear-gradient(180deg, #f4f1e4 0%, #d9d4c2 100%);
             transform: rotate(9deg); box-shadow: 0 2px 3px rgba(0,0,0,0.4); opacity:0; }
  #cstickY { position:absolute; right:96px; bottom:11px; width:27px; height:8px; border-radius:4px;
             background: linear-gradient(180deg, #efd680 0%, #d8ba55 100%);
             transform: rotate(-5deg); box-shadow: 0 2px 3px rgba(0,0,0,0.4); opacity:0; }
  .crow { position:absolute; left:30px; white-space:nowrap;
          font-family:'${dna.fonts.body}', cursive; font-size:${b.fontPx}px; line-height:1.0; color:${dna.palette.body};
          text-shadow: ${b.glow || "0 0 7px rgba(250,250,240,0.28), 0 1px 0 rgba(255,255,255,0.10)"}; }
  .crow .w { display:inline-block; position:relative; margin-right:0.35em; opacity:0; }
  .crow .w .txt { display:inline-block; }
  .crow .w.yel { color:${dna.palette.em || "#f3d96b"}; text-shadow: 0 0 8px ${(dna.palette.em || "#f3d96b") + "52"}, 0 1px 0 rgba(255,255,255,0.08); }
  .crow .w .d { position:absolute; bottom:2px; width:3px; height:3px; border-radius:50%;
                background:${dna.palette.dust || "#f1efe2"}; opacity:0; pointer-events:none; }
  .crow .w svg.ul { position:absolute; left:2%; bottom:-8px; width:96%; height:18px; overflow:visible; }
  .cghost { position:absolute; border-radius:50%; opacity:0;
            background: radial-gradient(50% 50% at 50% 50%, rgba(240,244,238,0.10) 0%, rgba(240,244,238,0) 70%); }
  #cgh1 { left:${Math.round(110 * kw)}px; top:${Math.round(24 * kh)}px; width:${Math.round(430 * kw)}px; height:${Math.round(62 * kh)}px; }
  #cgh2 { left:${Math.round(80 * kw)}px; top:${Math.round(80 * kh)}px; width:${Math.round(360 * kw)}px; height:${Math.round(56 * kh)}px; }
  #ceraser { position:absolute; top:-8px; left:0; width:120px; height:${stripH + 16}px; opacity:0;
             background: linear-gradient(100deg, rgba(236,242,236,0) 0%, rgba(236,242,236,0.30) 45%, rgba(236,242,236,0) 100%);
             filter: blur(5px); }`;
  const html = `      <div id="cband">
        <div class="hairline"></div>
        <div class="smg" id="csmg1"></div>
        <div class="smg" id="csmg2"></div>
        <div id="chdr">${esc(b.tag || "NIGHT LECTURE · wk 07")}</div>
        <div class="cghost" id="cgh1"></div>
        <div class="cghost" id="cgh2"></div>
${lineData.map((L) => `        <div class="crow" id="${L.id}" style="top:${L.top}px"></div>`).join("\n")}
        <div id="cstickW"></div>
        <div id="cstickY"></div>
        <div id="ceraser"></div>
      </div>`;
  const js = `
  // ---- body paradigm: CHALKRAIL (chalk scribble pop / eraser-swipe phases) ----
  const RAILC = ${J(lineData)};
  const ERASES = ${J(erases)};
  const rrnd = mulberry32(${b.seed || 13});
  const SVGNS = "http://www.w3.org/2000/svg";

  // ===== band furniture =====
  tl.fromTo("#cband", { opacity: 0, y: 26 }, { opacity: 1, y: 0, duration: 0.22, ease: "power2.out" }, 0.03);
  tl.fromTo("#chdr",    { opacity: 0 }, { opacity: 1, duration: 0.25 }, 0.30);
  tl.fromTo("#cstickW", { opacity: 0 }, { opacity: 0.92, duration: 0.2 }, 0.36);
  tl.fromTo("#cstickY", { opacity: 0 }, { opacity: 0.92, duration: 0.2 }, 0.42);

  // ===== body words: chalk-dust pop (entrance = the apex motif, low amplitude) =====
  RAILC.forEach((L) => {
    const line = document.getElementById(L.id);
    L.words.forEach(([txt, st, em, yel]) => {
      const w = document.createElement("span"); w.className = "w" + (yel ? " yel" : "");
      const inner = document.createElement("span"); inner.className = "txt"; inner.textContent = txt;
      w.appendChild(inner); line.appendChild(w);
      gsap.set(w, { rotation: (rrnd() * 4 - 2), transformOrigin: "50% 100%" });
      gsap.set(inner, { clipPath: "inset(-30% 102% -30% -2%)" });
      // 2-frame pop + fast scribble reveal + tiny drop-settle
      tl.fromTo(w, { opacity: 0 }, { opacity: 1, duration: 0.07, ease: "power1.in" }, st);
      tl.to(inner, { clipPath: "inset(-30% -2% -30% -2%)", duration: 0.12, ease: "steps(4)" }, st);
      tl.fromTo(w, { y: 4 }, { y: 0, duration: 0.2, ease: "power2.out" }, st);
      // 6 seeded dust specks puff at the word base
      for (let k = 0; k < 6; k++) {
        const d = document.createElement("span"); d.className = "d";
        d.style.left = (6 + rrnd() * 84) + "%";
        w.appendChild(d);
        tl.set(d, { opacity: 0.85 }, st + 0.02);
        tl.to(d, { y: 12 + rrnd() * 16, x: rrnd() * 16 - 8, opacity: 0,
                   duration: 0.36 + rrnd() * 0.15, ease: "power1.in" }, st + 0.02);
      }
      // emphasis: double underline dashes drawn beneath
      if (em) {
        const svg = document.createElementNS(SVGNS, "svg");
        svg.setAttribute("class", "ul"); svg.setAttribute("viewBox", "0 0 100 18");
        svg.setAttribute("preserveAspectRatio", "none");
        const col = yel ? ${J(dna.palette.em || "#f3d96b")} : ${J(dna.palette.body)};
        [["M 3 5 C 28 2, 66 8, 97 4", Math.min(st + 0.22, ${(DUR - 0.3).toFixed(3)}), 0.12],
         ["M 6 12 C 36 9, 62 15, 95 11", Math.min(st + 0.38, ${(DUR - 0.2).toFixed(3)}), 0.10]].forEach(([dd, tu, du]) => {
          const p = document.createElementNS(SVGNS, "path");
          p.setAttribute("d", dd); p.setAttribute("fill", "none");
          p.setAttribute("stroke", col); p.setAttribute("stroke-width", "3");
          p.setAttribute("stroke-linecap", "round");
          p.setAttribute("vector-effect", "non-scaling-stroke");
          p.setAttribute("opacity", "0");
          svg.appendChild(p);
          const len = p.getTotalLength();
          gsap.set(p, { strokeDasharray: len, strokeDashoffset: len });
          tl.set(p, { opacity: 0.9 }, tu);
          tl.to(p, { strokeDashoffset: 0, duration: du, ease: "power2.in" }, tu);
        });
        w.appendChild(svg);
      }
    });
  });

  // ===== ERASER SWIPE between phases: light band sweeps, old words smear out =====
  ERASES.forEach((E, ei) => {
    tl.set("#ceraser", { opacity: 1, x: -160 }, E.t);
    tl.to("#ceraser", { x: ${stripW + 60}, duration: 0.42, ease: "power1.inOut" }, E.t);
    tl.set("#ceraser", { opacity: 0 }, E.t + 0.44);
    E.lines.forEach((id, r) => {
      const kids = document.getElementById(id).children;
      for (let i = 0; i < kids.length; i++)
        tl.to(kids[i], { scaleX: 1.3, x: "+=24", opacity: 0, filter: "blur(8px)",
                         transformOrigin: "0% 80%", duration: 0.18, ease: "power2.in" },
              E.t + 0.03 + r * 0.01 + i * 0.06);
    });
    // faint smudges the eraser leaves behind (stay)
    if (ei === 0) {
      tl.fromTo("#cgh1", { opacity: 0 }, { opacity: 0.8, duration: 0.25 }, E.t + 0.17);
      tl.fromTo("#cgh2", { opacity: 0 }, { opacity: 0.8, duration: 0.25 }, E.t + 0.27);
    }
  });
${
  b.yield && !heroInline
    ? `
  // APEX OWNS ITS WINDOW — band yields while the chalk word writes, restores
  tl.to("#cband", { opacity: ${b.yield.dim}, duration: 0.14, ease: "power1.in"  }, ${(heroIn - (b.yield.pre || 0.07)).toFixed(3)});
${
  heroIn + (b.yield.post || 0.41) < DUR - 0.3
    ? `  tl.to("#cband", { opacity: 1,    duration: 0.22, ease: "power1.out" }, ${(heroIn + (b.yield.post || 0.41)).toFixed(3)});`
    : ""
}`
    : ""
}`;
  return { css, html, js };
}

function paradigmMarkerrail() {
  // MARKERRAIL: street handstyle rail — marker words FLICK onto a stable
  // bottom rail (opacity full in 1 frame + 1-frame squeak skew, seeded crooked
  // rotation settling back.out — the spray-write verb at low amplitude);
  // emphasis words take an alternating paint color (`palette.em`/`em2`) + a
  // spray-underline SWOOSH (dash-revealed squiggle) with 8 seeded overspray
  // dots; the FINAL transcript word lands BIG in `palette.em` with mini
  // apex-impact physics (crush → squash → elastic — it rides the plate punch).
  // Spent lines are ROLLER-BUFFED: a gray paint roller sweeps across, the
  // words vanish under it and the wet smear lingers before fading; the last
  // line is never buffed (the sign-off stays painted). Rail yields while the
  // apex tag writes.
  const b = dna.body;
  const EMS = [
    dna.palette.em || dna.palette.accent,
    dna.palette.em2 || dna.palette.em || dna.palette.accent,
  ];
  let emIdx = 0;
  const lineData = LINES.map((L, li) => ({
    id: "mr" + L.id,
    in: +L.in.toFixed(3),
    out: +L.out.toFixed(3),
    last: li === LINES.length - 1 ? 1 : 0,
    words: L.words.map((w, wi) => {
      const signoff = li === LINES.length - 1 && wi === L.words.length - 1;
      return [
        w.display,
        +w.start.toFixed(3),
        signoff ? EMS[0] : w.minor ? EMS[emIdx++ % EMS.length] : 0,
        signoff ? 1 : 0,
      ];
    }),
  }));
  const css = `
  .mline { position:absolute; left:${W / 2}px; top:${H - b.bottomPx}px; opacity:0; white-space:nowrap;
           font-family:'${dna.fonts.body}', cursive; font-size:${b.fontPx}px; line-height:1.25;
           color:${dna.palette.body};
           text-shadow: ${b.glow || "0 3px 12px rgba(0,0,0,0.72), 0 1px 3px rgba(0,0,0,0.6)"}; }
  .mline .w { display:inline-block; opacity:0; margin-right:0.32em; position:relative; }
  .mline .w:last-child { margin-right:0; }
  .mroller { position:absolute; left:-14px; top:-8px; right:-14px; bottom:-8px; opacity:0;
             background: linear-gradient(180deg, ${dna.palette.rollerHi || "#8d8a86"} 0%, ${dna.palette.rollerLo || "#6f6c69"} 45%, ${dna.palette.roller || "#807d79"} 100%);
             border-radius:5px; }
  .muline { position:absolute; left:-8%; top:100%; width:116%; height:26px; margin-top:-8px;
            overflow:visible; }
  .modot { position:absolute; width:5px; height:5px; border-radius:50%; opacity:0; }`;
  const html = lineData.map((L) => `      <div class="mline" id="${L.id}"></div>`).join("\n");
  const js = `
  // ---- body paradigm: MARKERRAIL (marker flick in / roller buff out) ----
  const RAILM = ${J(lineData)};
  const DURM = ${(DUR - 0.02).toFixed(3)};
  const mrnd = mulberry32(${b.seed || 20260611});
  const SVGNSM = "http://www.w3.org/2000/svg";
  function mUnderline(span, color, t0) {
    // spray underline swoosh: dash-revealed squiggle + seeded overspray dots
    const svg = document.createElementNS(SVGNSM, "svg");
    svg.setAttribute("class", "muline");
    svg.setAttribute("viewBox", "0 0 120 26");
    svg.setAttribute("preserveAspectRatio", "none");
    const path = document.createElementNS(SVGNSM, "path");
    path.setAttribute("d", "M 5 13 C 28 20, 52 6, 78 12 S 108 16, 115 10");
    path.setAttribute("fill", "none"); path.setAttribute("stroke", color);
    path.setAttribute("stroke-width", "8"); path.setAttribute("stroke-linecap", "round");
    path.setAttribute("opacity", "0");
    svg.appendChild(path); span.appendChild(svg);
    const len = path.getTotalLength();
    gsap.set(path, { strokeDasharray: len, strokeDashoffset: len });
    tl.set(path, { opacity: 0.95 }, t0);
    tl.to(path, { strokeDashoffset: 0, duration: 0.2, ease: "power2.out" }, t0);
    for (let i = 0; i < 8; i++) {
      const d = document.createElement("div");
      d.className = "modot"; d.style.background = color; span.appendChild(d);
      const s = 3 + Math.round(mrnd() * 3);
      gsap.set(d, { width: s, height: s, left: (4 + mrnd() * 92) + "%",
                    bottom: -(2 + mrnd() * 18) });
      const td = t0 + 0.03 + mrnd() * 0.15;
      tl.set(d, { opacity: 0.9, scale: 0.4 }, td);
      tl.to(d, { scale: 1.25, duration: 0.12, ease: "power2.out" }, td);
      tl.to(d, { opacity: 0, duration: 0.3, ease: "power1.in" }, td + 0.12);
    }
  }
  RAILM.forEach((L) => {
    const line = document.getElementById(L.id);
    const txt = document.createElement("div");
    txt.style.position = "relative"; line.appendChild(txt);
    const roller = document.createElement("div");
    roller.className = "mroller"; line.appendChild(roller);
    gsap.set(roller, { scaleX: 0, transformOrigin: "0% 50%" });
    L.words.forEach(([w]) => {
      const s = document.createElement("span");
      s.className = "w"; s.textContent = w; txt.appendChild(s);
    });
    gsap.set(line, { xPercent: -50, yPercent: -100 });
    tl.set(line, { opacity: 1 }, L.words[0][1] - 0.02);
    L.words.forEach(([w, st, em, big], wi) => {
      const el = txt.children[wi];
      const rot0 = (mrnd() - 0.5) * 12, rotF = (mrnd() - 0.5) * 4;
      // FLICK IN: opacity full in 1 frame, squeak skew for 1 frame, back.out settle
      tl.set(el, { opacity: 1, scale: big ? 1.5 : 1.18, rotation: rot0, skewX: 7,
                   color: em || ${J(dna.palette.body)}, transformOrigin: "50% 88%" }, st);
      tl.set(el, { skewX: 0 }, st + F);
      if (big) { // sign-off lands with the plate punch — mini apex-impact physics
        tl.to(el, { scale: 1, duration: 0.09, ease: "power3.in" }, st + F);
        tl.set(el, { scaleX: 1.1, scaleY: 0.9 }, st + F + 0.09);
        tl.to(el, { scaleX: 1, scaleY: 1, duration: 0.26, ease: "elastic.out(1, 0.4)" }, st + F + 0.09 + 2 * F);
        tl.to(el, { rotation: rotF, duration: 0.2, ease: "power2.out" }, st + F);
      } else {
        tl.to(el, { scale: 1, rotation: rotF, duration: 0.18, ease: "back.out(2.2)" }, st + F);
      }
      if (em && !big) mUnderline(el, em, Math.min(st + 0.22, ${(DUR - 0.5).toFixed(3)}));
    });
    if (!L.last) { // BUFF EXIT: gray roller sweeps the line, smear lingers
      const XO = L.out - 0.15;
      tl.set(roller, { opacity: 0.92 }, XO);
      tl.fromTo(roller, { scaleX: 0 }, { scaleX: 1, duration: 0.15, ease: "power2.in" }, XO);
      tl.set(txt, { opacity: 0 }, XO + 0.15);
      tl.to(roller, { opacity: 0.15, filter: "blur(2px)", duration: 0.22, ease: "power1.out" }, XO + 0.17);
      tl.to(roller, { opacity: 0, duration: 0.35 }, Math.min(XO + 0.55, DURM - 0.36));
      tl.set(line, { display: "none" }, Math.min(XO + 0.95, DURM));
    }
  });
${
  b.yield && !heroInline
    ? `  // APEX OWNS ITS WINDOW — the line visible at the tag's onset yields.
  // Restore only with runway before the buff (else the line exits dimmed).
  RAILM.forEach((L) => {
    if (L.in <= ${heroIn.toFixed(3)} && L.out > ${(heroIn - (b.yield.pre || 0.04)).toFixed(3)}) {
      const dimT = Math.max(${(heroIn - (b.yield.pre || 0.04)).toFixed(3)}, L.in + 0.05);
      const XO2 = L.out - 0.15;
      if (XO2 > dimT + 0.2 || L.last) {
        tl.to("#" + L.id, { opacity: ${b.yield.dim}, duration: 0.16 }, dimT);
        const resT = ${(heroIn + (b.yield.post || 0.17)).toFixed(3)};
        if (L.last ? resT < DURM - 0.3 : resT + 0.15 < XO2)
          tl.to("#" + L.id, { opacity: 1, duration: 0.18 }, resT);
      }
    }
  });`
    : ""
}`;
  return { css, html, js };
}

function paradigmBrushrail() {
  // BRUSHRAIL: washi paper band — a deckle-edged paper band (two faintly
  // rotated under-sheets + a warm translucent main sheet) swipes in L→R behind
  // a traveling brush-head shadow (the ink gesture at low amplitude); body
  // words are brush-WIPED on (clip reveal 0.16s behind a per-word brush head)
  // on centered replace-lines with seeded ±1° tilt; emphasis words get a
  // double curved ink underline drawn beneath (fat wash + tight core); spent
  // lines are wiped OFF right-to-left like a wet cloth (the reveal in reverse,
  // an erase brush head riding the wipe); the band + the line under the
  // gesture yield while the apex lands, and the band exits with the last line
  // on one final R→L wipe.
  const b = dna.body;
  const stripW = (b.strip && b.strip.w) || 820;
  const stripH = (b.strip && b.strip.h) || 90;
  const SX = (b.strip && b.strip.x) ?? Math.round((W - stripW) / 2);
  const ST = H - ((b.strip && b.strip.bottomPx) ?? 61) - stripH;
  const CX = SX + Math.round(stripW / 2),
    CY = ST + Math.round(stripH / 2);
  const lineY = CY - 1;
  const maxHold = b.maxHold ?? 0.78;
  const lineData = LINES.map((L, li) => {
    const lastSt = L.words[L.words.length - 1].start;
    // erase: as the next line needs the surface, or after maxHold in silence;
    // the LAST line is wiped with the band just before the clip ends
    let eraseT, eraseEnd;
    if (li === LINES.length - 1) {
      eraseT = +(DUR - 0.142).toFixed(3);
      eraseEnd = +(DUR - 0.032).toFixed(3);
    } else {
      eraseT = +Math.max(lastSt + 0.18, Math.min(L.out - 0.136, lastSt + maxHold)).toFixed(3);
      eraseEnd = +Math.max(eraseT + 0.06, Math.min(L.out + 0.004, eraseT + 0.14)).toFixed(3);
    }
    return {
      id: "br" + L.id,
      eraseT,
      eraseEnd,
      words: L.words.map((w) => [w.display, +w.start.toFixed(3), w.minor ? 1 : 0]),
    };
  });
  const lastE = lineData[lineData.length - 1].eraseT;
  const heroLi = heroInline ? -1 : LINES.findIndex((L) => heroIn >= L.in - 0.01 && heroIn < L.out);
  const INKB = dna.palette.body || "#221a12"; // brush-head shadow = the body ink
  const breD = Math.min(5.5, DUR - 0.55).toFixed(2);
  const breN = Math.max(16, Math.round(breD * 11.6));
  const css = `
  #band { position:absolute; left:${CX}px; top:${CY}px; width:${stripW}px; height:${stripH}px; }
  .bandu { position:absolute; inset:0; border-radius:8px;
           background: linear-gradient(180deg, ${dna.palette.paperHi || "#efe8d7"} 0%, ${dna.palette.paperLo || "#e7dfcb"} 100%); }
  #bandu1 { transform:rotate(-0.7deg) scale(1.012); opacity:0.42; }
  #bandu2 { transform:rotate(0.5deg) scaleX(0.99); opacity:0.42; }
  #bandmain { position:absolute; inset:0; border-radius:8px;
              background: linear-gradient(180deg, ${dna.palette.sheetHi || "#f5efe2"}ed 0%, ${dna.palette.sheetLo || "#ece4d2"}ed 100%);
              box-shadow: 0 5px 20px rgba(18,14,10,0.4), inset 0 0 26px rgba(120,98,70,0.18); }
  .bandbh { position:absolute; top:-8%; width:30px; height:116%; opacity:0;
            background: linear-gradient(90deg, ${dna.palette.bh || "#281e14"}00 0%, ${dna.palette.bh || "#281e14"}80 100%);
            filter: blur(7px); }
  .bline { position:absolute; left:${CX}px; top:${lineY}px; white-space:nowrap; opacity:0;
           font-family:'${dna.fonts.body}', serif; font-weight:${b.weight || 600}; font-size:${b.fontPx}px;
           line-height:1; color:${dna.palette.body};
           text-shadow: 0 1px 2px rgba(70,48,26,0.22); }
  .bline .cell { display:inline-block; position:relative; margin-right:0.3em; }
  .bline .wrap { display:inline-block; position:relative; }
  .bline .bh   { position:absolute; top:-14%; width:26px; height:128%; opacity:0.55;
                 background: linear-gradient(90deg, ${INKB}00 0%, ${INKB}8c 100%);
                 filter: blur(6px); }
  .bline svg.ul { position:absolute; left:-3%; bottom:-12px; width:106%; height:14px;
                  overflow:visible; }
  .bline .ebh  { position:absolute; top:-18%; width:34px; height:136%; opacity:0;
                 background: linear-gradient(270deg, ${INKB}00 0%, ${INKB}80 100%);
                 filter: blur(7px); }`;
  const html = `      <div id="band">
        <div class="bandu" id="bandu1"></div>
        <div class="bandu" id="bandu2"></div>
        <div id="bandmain"></div>
        <div class="bandbh" id="bandbh"></div>
      </div>
      <div id="blines">
${lineData.map((L) => `        <div class="bline" id="${L.id}"></div>`).join("\n")}
      </div>`;
  const js = `
  // ---- body paradigm: BRUSHRAIL (brush-wipe reveals / wet-cloth wipe exits) ----
  const RAILB = ${J(lineData)};
  const brnd = mulberry32(${b.seed || 20260611});
  const SVGNSB = "http://www.w3.org/2000/svg";
  const ULD1 = "M 5 8 C 30 11.5, 68 4, 95 7";
  const ULD2 = "M 2 9 C 32 12.5, 66 4.5, 98 7.5";

  // ===== BAND: swipes in L→R behind a brush head (the gesture, low amplitude) =====
  gsap.set("#band", { xPercent: -50, yPercent: -50, clipPath: "inset(0% 100% 0% 0%)" });
  tl.to("#band", { clipPath: "inset(0% 0% 0% 0%)", duration: 0.2, ease: "power2.out" }, 0.08);
  tl.fromTo("#bandbh", { left: -30, opacity: 0.6 },
            { left: "100%", opacity: 0.6, duration: 0.2, ease: "power2.out" }, 0.08);
  tl.set("#bandbh", { opacity: 0 }, 0.30);
  // paper under light: the deckle under-sheet breathes faintly (baked f(t))
  const bnB = ${breN}, bvB = [];
  for (let k = 0; k <= bnB; k++) bvB.push(0.40 + 0.05 * Math.sin(2 * Math.PI * 0.32 * (k / bnB * ${breD})));
  tl.to("#bandu1", { keyframes: { opacity: bvB }, duration: ${breD}, ease: "none" }, 0.3);

  // ===== BODY: words brush-wiped on, lines wiped off like a wet cloth =====
  RAILB.forEach((L) => {
    const line = document.getElementById(L.id);
    const rot = (brnd() - 0.5) * 2;                    // seeded ±1° tilt
    L.words.forEach(([txt,, em]) => {
      const cell = document.createElement("span"); cell.className = "cell";
      const wrap = document.createElement("span"); wrap.className = "wrap";
      const tx = document.createElement("span"); tx.textContent = txt;
      const bh = document.createElement("div"); bh.className = "bh";
      wrap.appendChild(tx); wrap.appendChild(bh); cell.appendChild(wrap);
      if (em) {                                        // double curved ink underline
        const svg = document.createElementNS(SVGNSB, "svg");
        svg.setAttribute("class", "ul"); svg.setAttribute("viewBox", "0 0 100 14");
        svg.setAttribute("preserveAspectRatio", "none");
        const mkp = (d, w, op) => {
          const p = document.createElementNS(SVGNSB, "path");
          p.setAttribute("d", d); p.setAttribute("fill", "none");
          p.setAttribute("stroke", ${J(dna.palette.ulInk || dna.palette.body)});
          p.setAttribute("stroke-width", w);
          p.setAttribute("stroke-opacity", op); p.setAttribute("stroke-linecap", "round");
          p.setAttribute("opacity", "0"); svg.appendChild(p); return p;
        };
        cell._ulFat = mkp(ULD1, 7, 0.3);
        cell._ulCore = mkp(ULD2, 2.6, 0.95);
        cell.appendChild(svg);
      }
      line.appendChild(cell);
    });
    const ebh = document.createElement("div"); ebh.className = "ebh";
    line.appendChild(ebh);
    gsap.set(line, { xPercent: -50, yPercent: -50, rotation: rot });
    tl.set(line, { opacity: 1 }, L.words[0][1] - 0.02);

    L.words.forEach(([txt, st, em], wi) => {
      const cell = line.children[wi];
      const wrap = cell.firstChild, bh = wrap.children[1];
      gsap.set(wrap, { clipPath: "inset(-14% 100% -14% 0%)" });
      tl.to(wrap, { clipPath: "inset(-14% 0% -14% 0%)", duration: 0.16, ease: "power2.out" }, st);
      tl.fromTo(bh, { left: -26, opacity: 0.55 },
                { left: "100%", opacity: 0.55, duration: 0.16, ease: "power2.out" }, st);
      tl.set(bh, { opacity: 0 }, st + 0.17);
      if (em) {
        [[cell._ulFat, 0.12], [cell._ulCore, 0.15]].forEach(([p, dly]) => {
          const len = p.getTotalLength();
          const tu = Math.min(st + dly, ${(DUR - 0.3).toFixed(3)});
          gsap.set(p, { strokeDasharray: len, strokeDashoffset: len });
          tl.set(p, { opacity: 1 }, tu);
          tl.to(p, { strokeDashoffset: 0, duration: 0.22, ease: "power2.out" }, tu);
        });
      }
    });

    // EXIT: the reveal in reverse — a wet cloth wipes R→L, erase head riding
    gsap.set(line, { clipPath: "inset(-30% 0% -30% 0%)" });
    tl.to(line, { clipPath: "inset(-30% 100% -30% 0%)", duration: L.eraseEnd - L.eraseT,
                  ease: "power2.in" }, L.eraseT);
    tl.fromTo(ebh, { left: "100%", opacity: 0.5 },
              { left: -34, opacity: 0.5, duration: L.eraseEnd - L.eraseT, ease: "power2.in" }, L.eraseT);
    tl.set(ebh, { opacity: 0 }, L.eraseEnd + 0.01);
    tl.set(line, { opacity: 0 }, L.eraseEnd + 0.02);
  });
${
  b.yield && !heroInline
    ? `
  // APEX OWNS ITS WINDOW — band + the line under the gesture yield, band restores
  tl.to(${heroLi >= 0 ? `["#band", "#br${heroLi}"]` : `"#band"`}, { opacity: ${b.yield.dim}, duration: 0.16 }, ${(heroIn - (b.yield.pre || 0.16)).toFixed(3)});
${
  heroIn + (b.yield.post || 0.46) + 0.3 < DUR
    ? `  tl.to("#band", { opacity: 1, duration: 0.18 }, ${(heroIn + (b.yield.post || 0.46)).toFixed(3)});`
    : ""
}`
    : ""
}

  // ending: the band itself is wiped off with the last line (one final R→L wipe)
  tl.to("#band", { clipPath: "inset(0% 100% 0% 0%)", duration: 0.13, ease: "power2.in" }, ${lastE});
  tl.fromTo("#bandbh", { left: "100%", opacity: 0.6 },
            { left: -30, opacity: 0.6, duration: 0.13, ease: "power2.in" }, ${lastE});
  tl.set("#bandbh", { opacity: 0 }, ${Math.min(lastE + 0.14, DUR - 0.01).toFixed(3)});`;
  return { css, html, js };
}

function paradigmInkrail() {
  // INKRAIL: sumi ink on still water — up to three fixed reading ROWS at the
  // bottom center (rows cycle in pairs, the closing line takes the third row,
  // so the final thought ACCUMULATES instead of replacing) where Mincho-serif
  // ink words BLEED in over a paper-white halo glow (opacity snaps in 2
  // frames; blur 12→0 + scale 1.25→1 carry the bleed — the apex bloom at low
  // amplitude) while a duplicate blurred dark halo overshoots then tightens;
  // an ink-settling RULE (gradient hairline) draws beneath each line;
  // emphasis words shed a faint ink wisp curling off the word top; spent
  // lines RE-DISSOLVE (blur up + spread + fade + slight rise — ink dispersing
  // back into the water) as the line-after-next approaches (= the next
  // occupant of their row); the closing lines persist to clip end; the rows
  // visible under the bloom yield while the drop blooms.
  const b = dna.body;
  const rowGap = b.rowGap ?? 56;
  const bottomPx = b.bottomPx ?? 36; // center of the LAST reading row
  const n = LINES.length;
  const base = n >= 3 ? 0 : 3 - n; // few lines sink to the lower rows
  const lineData = LINES.map((L, li) => {
    const slot = li === n - 1 && n >= 3 ? 2 : base + (li % 2);
    let outT = null,
      outDur = 0;
    if (li + 2 < n) {
      // re-dissolve as the line-after-next approaches; never dissolve mid-read
      outDur = +Math.max(0.35, 0.5 - 0.05 * li).toFixed(2);
      outT = +Math.max(
        L.words[L.words.length - 1].start + 0.25,
        LINES[li + 2].words[0].start - outDur - 0.05,
      ).toFixed(3);
    }
    return {
      id: "ik" + L.id,
      in: +L.in.toFixed(3),
      y: H - bottomPx - (2 - slot) * rowGap,
      outT,
      outDur,
      words: L.words.map((w) => [w.display, +w.start.toFixed(3), w.minor ? 1 : 0]),
    };
  });
  const INK = dna.palette.body;
  const PAP = dna.palette.paper || "#fffaee",
    PAP2 = dna.palette.paper2 || "#fff8e8";
  const css = `
  .irow { position:absolute; left:${W / 2}px; opacity:0; white-space:nowrap;
          font-family:'${dna.fonts.body}', serif; font-weight:${b.weight || 400};
          font-size:${b.fontPx}px; line-height:1.25; letter-spacing:${b.letterSpacing || "0.055em"};
          color:${INK}; }
  .irow .w { display:inline-block; position:relative; margin-right:0.34em; }
  .irow .w:last-child { margin-right:0; }
  .irow .ink { position:relative; display:inline-block; opacity:0;
          text-shadow: 0 0 5px ${PAP}, 0 0 14px ${PAP}f2, 0 0 30px ${PAP2}d9, 0 1px 2px ${PAP}e6; }
  .irow .halo { position:absolute; left:0; top:0; opacity:0; color:${INK}; filter:blur(9px); }
  .irule { position:absolute; left:50%; bottom:-7px; width:86%; height:2px;
           margin-left:-43%; opacity:0; transform-origin:50% 50%;
           background: linear-gradient(90deg, ${INK}00 0%, ${INK}8c 18%, ${INK}8c 82%, ${INK}00 100%);
           box-shadow: 0 1px 7px ${PAP}8c; filter: blur(0.8px); }
  .iwisp { position:absolute; left:50%; top:-16px; width:22px; height:28px;
           margin-left:-11px; opacity:0; border:3px solid transparent;
           border-top-color:${INK}8c; border-radius:50%; filter:blur(2px); }`;
  const html = lineData
    .map((L) => `      <div class="irow" id="${L.id}" style="top:${L.y}px"></div>`)
    .join("\n");
  const js = `
  // ---- body paradigm: INKRAIL (ink bleed-in / re-dissolve exits) ----
  const RAILI = ${J(lineData)};
  const IKLATE = ${(DUR - 0.84).toFixed(3)}; // late words bleed faster (resolve before clip end)
  RAILI.forEach((L, li) => {
    const line = document.getElementById(L.id);
    const rule = document.createElement("div"); rule.className = "irule"; line.appendChild(rule);
    L.words.forEach(([txt]) => {
      const w = document.createElement("span"); w.className = "w";
      const halo = document.createElement("span"); halo.className = "halo"; halo.textContent = txt;
      const ink = document.createElement("span"); ink.className = "ink"; ink.textContent = txt;
      w.appendChild(halo); w.appendChild(ink); line.appendChild(w);
    });
    gsap.set(line, { xPercent: -50, yPercent: -50 });
    tl.set(line, { opacity: 1 }, L.in);
    L.words.forEach(([txt, st, em], wi) => {
      const w = line.children[wi + 1]; // children[0] is the rule
      const halo = w.children[0], ink = w.children[1];
      // BLEED IN: opacity snaps (2 frames); blur 12→0 + scale 1.25→1 carry the
      // bleed (late words bleed faster so they fully resolve before clip end)
      const late = st > IKLATE;
      tl.fromTo(ink, { opacity: 0 }, { opacity: 1, duration: 0.083, ease: "none" }, st);
      tl.fromTo(ink, { filter: "blur(12px)", scale: 1.25 },
                { filter: "blur(0px)", scale: 1, duration: late ? 0.4 : 0.6, ease: "power2.out",
                  transformOrigin: "50% 60%" }, st);
      // the dark halo overshoots then tightens (the bloom motif, low amplitude)
      tl.fromTo(halo, { opacity: em ? 0.55 : 0.5, scale: 1.5 },
                { scale: 1.04, opacity: em ? 0.3 : 0.25, duration: late ? 0.5 : 0.7,
                  ease: "power2.out", transformOrigin: "50% 60%" }, st);
      // emphasis: a faint ink wisp curls off the word top (skipped near clip end)
      if (em && st + 1.55 <= ${(DUR - 0.03).toFixed(3)}) {
        const wsp = document.createElement("div"); wsp.className = "iwisp"; w.appendChild(wsp);
        const dir = li % 2 === 0 ? 1 : -1;
        tl.set(wsp, { opacity: 0.5, transformOrigin: "50% 50%" }, st + 0.55);
        tl.fromTo(wsp, { y: 0, x: 0, rotation: 12 * dir, scaleY: 1 },
                  { y: -38, x: 10 * dir, rotation: 92 * dir, scaleY: 1.55,
                    duration: 1.0, ease: "power1.out" }, st + 0.55);
        tl.to(wsp, { opacity: 0, duration: 0.42, ease: "power1.in" }, st + 1.12);
      }
    });
    // ink-settling rule beneath the line
    tl.set(rule, { opacity: 0.55 }, L.words[0][1] + 0.12);
    tl.fromTo(rule, { scaleX: 0 }, { scaleX: 1, duration: 0.9, ease: "power2.out" },
              L.words[0][1] + 0.12);
    // EXIT: re-dissolve — blur up + spread + fade + slight rise, ink dispersing
    // (the closing lines persist — the final thought stays readable)
    if (L.outT !== null) {
      tl.to(line, { filter: "blur(9px)", scale: 1.13, opacity: 0, y: -8,
                    duration: L.outDur, ease: "power1.in", transformOrigin: "50% 50%" }, L.outT);
      tl.set(line, { display: "none" }, L.outT + L.outDur + 0.02);
    }
  });
${
  b.yield && !heroInline
    ? `
  // rows visible under the bloom yield while the drop blooms, restore after.
  // Overlap guards: dim only after the line is IN with runway before its exit;
  // restore only with clear runway (else the line exits dimmed — one owner).
  RAILI.forEach((L) => {
    const dimT = ${(heroIn + (b.yield.delay ?? 0.12)).toFixed(3)};
    if (L.in < dimT - 0.05 && (L.outT === null || L.outT > dimT + 0.35)) {
      tl.to("#" + L.id, { opacity: ${b.yield.dim}, duration: 0.3, ease: "power1.out" }, dimT);
      const resT = ${(heroIn + (b.yield.post ?? 1.07)).toFixed(3)};
      if (L.outT === null || resT + 0.33 < L.outT)
        tl.to("#" + L.id, { opacity: 1, duration: 0.3, ease: "power1.out" }, resT);
    }
  });`
    : ""
}`;
  return { css, html, js };
}

// ---- ransom shared pieces: the cutout-chip recipes and the seeded paper-tear
// polygon are ONE paper stock used by both layers (ransomrail body chips in the
// fg, ransomnote letter chips in the bg) — emitted into each page separately
function ransomChipCss(ls) {
  const p = dna.palette;
  return `
  .ca { background:${p.paper || "#f5f1e4"}; color:${p.paperInk || "#17130d"}; font-family:'${dna.fonts.body}', sans-serif; letter-spacing:${ls}; }
  .cb { background:${p.dark || "#141414"}; color:${p.darkInk || "#f7f5ef"}; font-family:'${dna.fonts.dark}', sans-serif; font-weight:800; }
  .cc { background:${p.news || "#d8d3c5"}; color:${p.newsInk || "#262017"}; font-family:'${dna.fonts.news}', monospace; }
  .cd { background:${p.accent || "#ffd23f"}; color:${p.accentInk || "#1c160c"}; font-family:'${dna.fonts.accent}', cursive; }`;
}
// seeded 12-vertex tear polygon — j() call order matches the demo exactly so
// identical seeds reproduce the demo's torn edges
const RANSOM_TEAR = `function tearPoly(r){
    const j = () => +(r()*5).toFixed(1);
    const p = [[j(),j()],[33,j()],[66,j()],[100-j(),j()],[100-j(),33],[100-j(),66],
               [100-j(),100-j()],[66,100-j()],[33,100-j()],[j(),100-j()],[j(),66],[j(),33]];
    return "polygon(" + p.map(q => q[0] + "% " + q[1] + "%").join(",") + ")";
  }`;

function paradigmRansomrail() {
  // RANSOMRAIL: kidnapper's collage — every transcript word is a cutout chip
  // in one of 4 cycled recipes (paper/Anton, black/Inter-800, newsprint/
  // Special Elite, accent/Permanent Marker; emphasis words snap to the accent
  // chip) PASTED along the bottom rail line with seeded crookedness, seeded
  // ±px sizes and misaligned baselines — the paste is the apex slam motif at
  // low amplitude (1f appear at scale 1.12 → power3.in crush → 2-frame contact
  // squash → elastic settle). Emphasis = 1-frame RE-PASTE lift (shadow grows,
  // rotation flips sign for good); exits = chips RIPPED off one by one with
  // 1-frame paper-tear flashes under each; lines breathe through their hold;
  // the whole railwrap yields while the apex letters slam behind the subject.
  const b = dna.body;
  const last = LINES.length - 1;
  const lineData = LINES.map((L, li) => {
    const n = L.words.length;
    const stag = li === last ? 0.04 : 0.045;
    const ripDur = li === last ? 0.12 : 0.14;
    const lastSt = L.words[n - 1].start;
    // rip cadence (demo): chips tear off just before the next line pastes —
    // the rip tail overlaps the new line a beat (collage chaos, by design);
    // a line whose last word just spoke waits lastWord+0.14 first
    let ripStart =
      li === last
        ? Math.min(L.out - 0.04, DUR - 0.06 - ripDur - stag * (n - 1))
        : Math.max(lastSt + 0.14, LINES[li + 1].in - ripDur - stag * (n - 1) + 0.05);
    ripStart = +Math.min(ripStart, DUR - 0.06 - ripDur - stag * (n - 1)).toFixed(3);
    const words = L.words.map((w, wi) => {
      const st = +w.start.toFixed(3);
      const R = +Math.max(ripStart + stag * wi, st + 0.12).toFixed(3);
      // re-paste lift: needs its own window before the chip's rip
      let emphT = w.minor ? +Math.min(st + (b.emphDelay ?? 0.23), R - 0.27).toFixed(3) : 0;
      if (emphT && emphT < st + 0.08) emphT = 0;
      return [w.display, st, emphT, R, w.minor ? 1 : 0];
    });
    // hold life: gentle collage breathe on the whole line (demo cadence)
    const bs = +(L.words[0].start + 0.08).toFixed(3);
    const bd = +Math.min(1.8, ripStart - 0.03 - bs).toFixed(2);
    return { id: "rr" + L.id, in: +L.in.toFixed(3), ripDur, words, bs, bd: bd >= 0.3 ? bd : 0 };
  });
  const css = `
  #rrailwrap { position:absolute; inset:0; }
  .rline { position:absolute; left:${W / 2}px; top:${H - b.bottomPx}px; white-space:nowrap; }
  .rline .w  { display:inline-block; position:relative; margin:0 5px; }
  .rflash { position:absolute; inset:-4px; background:${dna.palette.flash || "#fffdf2"}; opacity:0; z-index:0; }
  .rmove { display:inline-block; position:relative; z-index:1; opacity:0;
           filter: drop-shadow(0 5px 0 rgba(8,6,4,0.5)); }
  .rchip { display:inline-block; line-height:1.05; padding:0.08em 0.16em 0.11em;
           text-transform:${b.textTransform || "uppercase"}; }
${ransomChipCss(b.letterSpacing || "0.02em")}`;
  const html = `      <div id="rrailwrap">
${lineData.map((L) => `        <div class="rline" id="${L.id}"></div>`).join("\n")}
      </div>`;
  const js = `
  // ---- body paradigm: RANSOMRAIL (chips pasted crooked / ripped off with tear flashes) ----
  const RAILR = ${J(lineData)};
  const crnd = mulberry32(${b.seed || 3940});
  ${RANSOM_TEAR}
  const HARDR = "drop-shadow(0 5px 0 rgba(8,6,4,0.5))";
  const LIFTR = "drop-shadow(0 14px 3px rgba(8,6,4,0.35))";
  const RECS = ["ca", "cb", "cc", "cd"];
  let recC = 0, recPrev = -1;          // recipe cycle: emphasis snaps to the accent chip,
  function nextRec(em) {               // never two identical chips in a row
    let rec = em ? 3 : recC % 4;
    if (rec === recPrev) { recC++; rec = recC % 4; }
    if (!em) recC++;
    recPrev = rec;
    return rec;
  }
  RAILR.forEach((L, li) => {
    const line = document.getElementById(L.id);
    gsap.set(line, { xPercent: -50, yPercent: -100 });
    L.words.forEach(([txt, st, emphT, R, em], wi) => {
      const w = document.createElement("div"); w.className = "w";
      const fl = document.createElement("div"); fl.className = "rflash";
      fl.style.clipPath = tearPoly(crnd);
      const mv = document.createElement("div"); mv.className = "rmove";
      const chip = document.createElement("div"); chip.className = "rchip " + RECS[nextRec(em)];
      chip.style.fontSize = (${b.fontPx} - 2 + Math.round(crnd() * 3) + (em ? 3 : 0)) + "px";
      chip.style.clipPath = tearPoly(crnd);
      chip.textContent = txt;
      mv.appendChild(chip); w.appendChild(fl); w.appendChild(mv); line.appendChild(w);
      // seeded crookedness: rot sign alternates with line+word parity (demo),
      // baseline shove dy leans the other way
      const sgn = (li + wi) % 2 === 0 ? -1 : 1;
      const rot = sgn * (2 + crnd() * 2.5);
      const dy = -sgn * (1 + crnd() * 2);
      // PASTE = apex slam motif at low amplitude
      tl.set(mv, { opacity: 1, scale: 1.12, rotation: rot * 1.5, y: dy - 8 }, st);
      tl.to(mv,  { scale: 1, rotation: rot, y: dy, duration: 0.08, ease: "power3.in" }, st + 0.005);
      tl.set(mv, { scaleX: 1.05, scaleY: 0.95 }, st + 0.09);
      tl.to(mv,  { scaleX: 1, scaleY: 1, duration: 0.22, ease: "elastic.out(1, 0.4)" }, st + 0.173);
      // EMPHASIS = re-paste: 1-frame lift (shadow grows), rotation flips sign for good
      if (emphT) {
        tl.set(mv, { y: dy - 7, scale: 1.08, rotation: -rot, filter: LIFTR }, emphT);
        tl.to(mv,  { y: dy, scale: 1, duration: 0.09, ease: "power3.in" }, emphT + 0.083);
        tl.set(mv, { filter: HARDR }, emphT + 0.175);
        tl.set(mv, { scaleX: 1.04, scaleY: 0.96 }, emphT + 0.178);
        tl.to(mv,  { scaleX: 1, scaleY: 1, duration: 0.16, ease: "elastic.out(1, 0.4)" }, emphT + 0.262);
      }
      // EXIT = ripped off, paper-tear flash 1 frame under the chip
      tl.set(fl, { opacity: 0.85 }, R);
      tl.set(fl, { opacity: 0 }, R + 0.045);
      tl.to(mv,  { rotation: rot + (wi % 2 ? 16 : -17), y: dy - 30, opacity: 0,
                   duration: L.ripDur, ease: "power2.in" }, R);
    });
    if (L.bd) tl.to(line, { keyframes: { scale: [1, 1.015, 1.005, 1.016, 1] },
                            duration: L.bd, ease: "none" }, L.bs);
  });
${
  b.yield && !heroInline
    ? `  // rail yields while the apex letters slam behind the subject (container
  // opacity — never contests the per-chip channels)
  tl.to("#rrailwrap", { opacity: ${b.yield.dim}, duration: 0.12 }, ${Math.max(0.05, heroIn - (b.yield.pre ?? 0.02)).toFixed(3)});
${
  heroIn + (b.yield.post ?? 0.71) < DUR - 0.3
    ? `  tl.to("#rrailwrap", { opacity: 1, duration: 0.2 }, ${(heroIn + (b.yield.post ?? 0.71)).toFixed(3)});`
    : ""
}`
    : ""
}`;
  return { css, html, js };
}

const PARADIGMS = {
  rail: paradigmRail,
  panel: paradigmPanel,
  poem: paradigmPoem,
  takeover: paradigmTakeover,
  lastpage: paradigmLastpage,
  flaprail: paradigmFlaprail,
  ledboard: paradigmLedboard,
  vhsrail: paradigmVhsrail,
  hudrail: paradigmHudrail,
  carbonstrip: paradigmCarbonstrip,
  laserrail: paradigmLaserrail,
  stormrail: paradigmStormrail,
  holorail: paradigmHolorail,
  planktonrail: paradigmPlanktonrail,
  sheenrail: paradigmSheenrail,
  scoperail: paradigmScoperail,
  paperrail: paradigmPaperrail,
  popuprail: paradigmPopuprail,
  chalkrail: paradigmChalkrail,
  markerrail: paradigmMarkerrail,
  brushrail: paradigmBrushrail,
  inkrail: paradigmInkrail,
  ransomrail: paradigmRansomrail,
};

function setpieceCpslam() {
  // CP-language EMBED climax: acid-yellow word slams in BEHIND the subject with
  // REAL notch cuts (SVG mask = true transparency over footage), a chromatic
  // split that settles to a PERMANENT registration error, seeded glitch ticks,
  // and a glitch-out exit. The frame stays footage — the word is set dressing.
  const h = dna.hero,
    p = h.params || {},
    I = heroIn;
  const YEL = dna.palette.body || "#FCEE0A",
    CYN = "#00F0FF",
    RED = "#FF003C";
  const srnd = (() => {
    let a = p.seed || 2077;
    return () => {
      a |= 0;
      a = (a + 0x6d2b79f5) | 0;
      let t = Math.imul(a ^ (a >>> 15), 1 | a);
      t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
      return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
    };
  })();
  const DISP = heroText.toUpperCase(),
    hpx = HG.fontPx;
  const halfW = HG.halfW || (DISP.length * 0.56 * hpx) / 2;
  const BW = Math.round(halfW * 2 + 140),
    BH = Math.round(hpx * 1.5);
  const CX = BW - 80 > W ? W / 2 : Math.max(BW / 2 - 40, Math.min(W - BW / 2 + 40, HG.x)),
    CY = HG.y;
  // seeded diagonal cuts through the letter band (precision is not the point —
  // the cuts read as stencil damage wherever they land)
  const nN = Math.max(2, Math.floor(DISP.length / 2.2));
  const used = [];
  while (used.length < nN) {
    const i = Math.floor(srnd() * DISP.length);
    if (!used.includes(i)) used.push(i);
  }
  const notches = used
    .map((i, k) => {
      const cx = BW / 2 - halfW + (i + 0.5) * 0.56 * hpx + (srnd() - 0.5) * 8;
      const cy = BH / 2 + (k % 2 ? 0.24 : -0.27) * hpx + (srnd() - 0.5) * 10;
      return `<rect x="${(cx - hpx * 0.12).toFixed(0)}" y="${(cy - hpx * 0.038).toFixed(0)}" width="${(hpx * 0.24).toFixed(0)}" height="${(hpx * 0.076).toFixed(0)}" fill="black" transform="rotate(-45 ${cx.toFixed(0)} ${cy.toFixed(0)})"/>`;
    })
    .join("\n            ");
  const lay = (id, fill) =>
    `<g id="${id}"><rect x="0" y="0" width="${BW}" height="${BH}" fill="${fill}" mask="url(#cpm)"/></g>`;
  const css = `
  #cpband { position:absolute; left:0; top:-80px; width:100%; height:70px; opacity:0;
            background: linear-gradient(180deg, rgba(0,240,255,0) 0%, rgba(0,240,255,0.4) 50%, rgba(0,240,255,0) 100%); }
  #cps { position:absolute; left:${CX}px; top:${CY}px; width:0; height:0; opacity:0; }
  #cpsW { position:absolute; left:0; top:0; transform:translate(-50%,-50%);
          filter: drop-shadow(0 4px 18px rgba(0,0,0,0.5)); }
  #cpkana { position:absolute; left:0; top:-${Math.round(hpx * 0.82)}px; transform:translate(-50%,-50%);
            font-family:'Hiragino Sans','Hiragino Kaku Gothic ProN',sans-serif; font-weight:600;
            font-size:20px; letter-spacing:0.5em; text-indent:0.5em; color:${YEL}; opacity:0;
            white-space:nowrap; text-shadow: 0 2px 8px rgba(0,0,0,0.7); }`;
  const html = `      <div id="cpband"></div>
      <div id="cps">
        <svg id="cpsW" width="${BW}" height="${BH}" viewBox="0 0 ${BW} ${BH}">
          <defs><mask id="cpm">
            <text x="${BW / 2}" y="${BH / 2}" text-anchor="middle" dominant-baseline="central"
                  font-family="'${dna.fonts.hero}', sans-serif" font-size="${hpx}"
                  letter-spacing="${(hpx * 0.04).toFixed(1)}" fill="white">${esc(DISP)}</text>
            ${notches}
          </mask></defs>
          ${lay("cpC", CYN)}
          ${lay("cpR", RED)}
          ${lay("cpY", YEL)}
        </svg>
        <div id="cpkana">${esc(p.kana || "サイバーパンク")}</div>
      </div>`;
  // a climax is an EVENT: on long clips holding to clip-end turns it into
  // wallpaper. Default hold ~2.6s after the slam; hero.exitAt / params.hold override.
  const EX = theme.hero.exitAt ?? Math.min(heroOut - 0.2, I + (p.hold ?? 2.6));
  const js = `
  // ---- setpiece: CPSLAM (band charge -> crush slam -> permanent registration -> glitch-out) ----
  const I = ${I.toFixed(3)};
  const crnd = mulberry32(${p.seed || 2077});
  tl.set("#cpband", { opacity: 1 }, I - 0.35);
  tl.fromTo("#cpband", { y: 0 }, { y: ${H + 160}, duration: 0.36, ease: "power1.in" }, I - 0.35);
  tl.set("#cpband", { opacity: 0 }, I + 0.01);
  // crush slam (weighted), chromatic layers split wide on impact
  tl.set("#cps", { opacity: 1 }, I - 0.04);
  tl.fromTo("#cpsW", { scale: 2.6, filter: "blur(9px) brightness(2.2) drop-shadow(0 4px 18px rgba(0,0,0,0.5))" },
    { scale: 1, filter: "blur(0px) brightness(1) drop-shadow(0 4px 18px rgba(0,0,0,0.5))", duration: 0.11, ease: "power4.in" }, I - 0.04);
  tl.set("#cpsW", { scaleX: 1.09, scaleY: 0.93 }, I + 0.08);
  tl.to("#cpsW", { scaleX: 1, scaleY: 1, duration: 0.46, ease: "elastic.out(1.05, 0.36)" }, I + 0.16);
  // SVG <g> children move via attr.transform ONLY (banned: gsap x/y component
  // props on SVG children — getBBox parse path). Frame-quantized step chain
  // replaces the 0.16s recoil tween (identical on screen at 24fps).
  const RG = (dx, dy, at) => { tl.set("#cpC", { attr: { transform: "translate(" + dx + " " + dy + ")" } }, at); tl.set("#cpR", { attr: { transform: "translate(" + (-dx) + " " + (-dy) + ")" } }, at); };
  RG(9, 0, I - 0.04);
  RG(6, 0, I + 0.02); RG(3, 0, I + 0.02 + F); RG(1, 0, I + 0.02 + 2 * F); RG(0, 0, I + 0.02 + 3 * F);
  // ...then the print settles WRONG: permanent registration error (the look)
  RG(2.5, -1, I + 0.3);
  tl.fromTo("#cpkana", { opacity: 0 }, { opacity: 0.92, duration: 0.18 }, I + 0.22);
  // living word: seeded glitch ticks (registration jolts / 1f slips)
  let gt = I + 0.62;
  while (gt < ${EX.toFixed(3)} - 0.25) {
    if (crnd() < 0.55) {
      RG(8, -1, gt); RG(2.5, -1, gt + F);
    } else {
      tl.set("#cps", { x: (crnd() - 0.5) * 10 }, gt);
      tl.set("#cps", { x: 0 }, gt + F);
    }
    gt += 0.45 + crnd() * 0.6;
  }
  tl.to("#cpsW", { scale: 1.035, duration: 1.1, ease: "power1.inOut" }, I + 0.72);
  // EXIT: glitch-out
  const E = ${EX.toFixed(3)};
  tl.set("#cpkana", { opacity: 0 }, E);
  RG(15, -1, E);
  tl.set("#cps", { x: -7 }, E + F);
  tl.set("#cps", { opacity: 0, display: "none" }, E + 2 * F);`;
  return { css, html, js };
}

function setpieceSettle() {
  const I = heroIn;
  const E = theme.hero.exitAt ?? Math.min(heroOut, DUR - 0.12);
  const ruleW = Math.round(2 * HG.halfW * 0.62);
  const holdDur = Math.max(0.4, E - I - 0.85);
  const css = `
  ${dna.plate.dim ? `#dimP { position:absolute; inset:0; opacity:0; background:#06080c; }` : ""}
  #stl { position:absolute; left:${HG.x}px; top:${HG.y}px; transform:translate(-50%,-50%); }
  #stl-w { font-family:'${dna.fonts.hero}', sans-serif; font-weight:700; font-size:${HG.fontPx}px;
           line-height:1; letter-spacing:0.01em; white-space:nowrap; color:${dna.palette.body};
           text-shadow: 0 2px 18px rgba(0,0,0,0.5), 0 1px 4px rgba(0,0,0,0.35); opacity:0; }
  #stl-rule { position:absolute; left:50%; bottom:-20px; transform:translateX(-50%);
              width:0; height:3px; border-radius:2px; background:${dna.palette.accent};
              opacity:0.85; box-shadow: 0 1px 8px rgba(0,0,0,0.4); }`;
  const html = `      ${dna.plate.dim ? `<div id="dimP"></div>` : ""}
      <div id="stl"><div id="stl-w">${heroDisplay.toUpperCase()}</div><div id="stl-rule"></div></div>`;
  const js = `
  // ---- setpiece: SETTLE (quiet blur-settle + accent underline; restraint IS the style) ----
  const I = ${I.toFixed(3)};
${
  dna.plate.dim
    ? `  tl.fromTo("#dimP", { opacity: 0 }, { opacity: ${dna.plate.dim}, duration: 0.35, ease: "power2.in" }, I - 0.3);
  tl.to("#dimP", { opacity: 0, duration: 0.4, ease: "power1.in" }, ${(E + 0.02).toFixed(3)});`
    : ""
}
  // opacity lands in ~2 frames; the transform settles separately (no ghost ramp)
  tl.fromTo("#stl-w", { opacity: 0 }, { opacity: 1, duration: 0.09, ease: "power1.out" }, I);
  tl.fromTo("#stl-w", { y: 14, scale: 1.05, filter: "blur(9px)" },
            { y: 0, scale: 1, filter: "blur(0px)", duration: 0.45, ease: "power3.out" }, I);
  tl.fromTo("#stl-rule", { width: 0 }, { width: ${ruleW}, duration: 0.35, ease: "expo.out" }, I + 0.28);
  // hold life: one slow visible loom
  tl.to("#stl", { scale: 1.018, duration: ${holdDur.toFixed(2)}, ease: "power1.inOut" }, I + 0.6);
  // exit: soften and lift (asymmetric to the landing)
  tl.to("#stl-rule", { width: 0, opacity: 0, duration: 0.2, ease: "power2.in" }, ${(E - 0.05).toFixed(3)});
  tl.to("#stl-w", { filter: "blur(5px)", duration: 0.22, ease: "power1.in" }, ${(E - 0.02).toFixed(3)});
  tl.to("#stl", { opacity: 0, y: -10, duration: 0.24, ease: "power2.in" }, ${E.toFixed(3)});
  tl.set("#stl", { display: "none" }, ${(E + 0.26).toFixed(3)});`;
  return { css, html, js };
}
function setpieceFlapboard() {
  // SPLIT-FLAP DEPARTURES BOARD: floodlights swing on, the housing clacks down
  // behind the subject, one giant flap tile per char flip-cycles seeded wrong
  // glyphs then locks the real char left→right with clack rhythm; rule wipes,
  // status ticker types on; one tile re-flutters during the hold; tiles flip
  // to blank and the housing flips away.
  const h = dna.hero,
    p = h.params,
    I = heroIn;
  const N = heroText.length;
  const gap = p.gap || 7;
  const tileW = HG.tileW,
    tileH = HG.tileH,
    fpx = HG.fontPx;
  const lockWin = p.lockWindow || 0.34;
  const AIN = I - 0.15;
  const lockEnd = I + lockWin;
  const E = heroOut;
  const headH = 34;
  const boardH = 10 + headH + tileH + 18;
  const boardW = HG.halfW * 2;
  const ruleW = Math.round(boardW - 43);
  const ruleY = Math.round(HG.y + boardH / 2 + 37);
  const tagW = Math.round((p.tag || "").length * 19);
  const flutterIdx = Math.min(N - 1, Math.floor(N * 0.6));
  const exit0 = E - ((N - 1) * 0.03 + 0.09);
  const css = `
  #dimF   { position:absolute; inset:0; opacity:0; background:#06080c; }
  #scrimF { position:absolute; inset:0; opacity:0;
            background: linear-gradient(180deg, rgba(5,7,11,0.72) 0%, rgba(5,7,11,0.3) 42%,
                        rgba(5,7,11,0) 68%); }
  #glowF  { position:absolute; inset:0; opacity:0;
            background: radial-gradient(46% 38% at ${((HG.x / W) * 100).toFixed(1)}% ${((HG.y / H) * 100).toFixed(1)}%, rgba(255,196,110,0.5) 0%,
                        rgba(255,160,60,0.16) 55%, rgba(0,0,0,0) 78%); }
  #persp { position:absolute; inset:0; perspective:950px; }
  #board { position:absolute; left:${HG.x}px; top:${HG.y}px; opacity:0; transform-origin:50% 0%;
           background: linear-gradient(180deg,#171B21 0%,#101319 100%);
           border-radius:8px; border:1px solid #353C47; border-top:3px solid ${dna.palette.hot};
           padding:10px 20px 18px;
           box-shadow: 0 18px 60px rgba(0,0,0,0.65), inset 0 1px 0 rgba(255,255,255,0.06); }
  #bhead { display:flex; justify-content:space-between; align-items:baseline; gap:40px;
           font-family:'${dna.fonts.tag}', sans-serif; font-weight:600; font-size:24px;
           letter-spacing:4px; color:${dna.palette.hot}; opacity:0.9; padding:0 4px 8px; }
  #bhead .r { color:#8A8F98; letter-spacing:3px; }
  #slots { display:flex; gap:${gap}px; }
  .atile { position:relative; width:${tileW}px; height:${tileH}px; perspective:620px;
           border-radius:4px; background:#0A0D11;
           box-shadow: inset 0 0 0 1px #1F242B, inset 0 5px 12px rgba(0,0,0,0.75); }
  .aflap { position:absolute; inset:0; transform-origin:50% 0%; backface-visibility:hidden;
           border-radius:4px; text-align:center;
           background: linear-gradient(180deg,#343A43 0%,#272C34 48%,#1A1E25 52%,#242931 100%);
           box-shadow: inset 0 0 0 1px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.08);
           font-family:'${dna.fonts.hero}', sans-serif; font-size:${fpx}px; line-height:${tileH + 2}px;
           color:${dna.palette.boardBone || dna.palette.body};
           text-shadow: 0 4px 14px rgba(0,0,0,0.55); }
  .aflap::after { content:""; position:absolute; left:0; right:0; top:50%; height:2px;
                  margin-top:-1px; background:rgba(0,0,0,0.5); }
  .ac { position:absolute; inset:0; }
  .ac.wv { display:none; color:#C3C8D0; }
  .ac.rl { visibility:hidden; }
  #ruleF { position:absolute; left:${HG.x}px; top:${ruleY}px; width:${ruleW}px; height:3px;
           margin-left:-${Math.round(ruleW / 2)}px; background:${dna.palette.hot}; opacity:0; transform-origin:50% 50%;
           box-shadow: 0 0 12px ${dna.palette.hot}99; }
  #tagwrapF { position:absolute; left:${HG.x}px; top:${ruleY + 14}px; transform:translateX(-50%);
              overflow:hidden; white-space:nowrap; width:0; }
  #tagF { font-family:'${dna.fonts.tag}', sans-serif; font-weight:600; font-size:27px; letter-spacing:7px;
          color:${dna.palette.hot}; white-space:nowrap; text-shadow: 0 2px 8px rgba(0,0,0,0.7); }`;
  const html = `      <div id="dimF"></div><div id="scrimF"></div><div id="glowF"></div>
      <div id="persp">
        <div id="board">
          <div id="bhead"><span>${esc(p.headerLeft || "★ DEPARTURES")}</span><span class="r">${esc(p.headerRight || "")}</span></div>
          <div id="slots"></div>
        </div>
      </div>
      <div id="ruleF"></div>
      ${p.tag ? `<div id="tagwrapF"><div id="tagF">${esc(p.tag)}</div></div>` : ""}`;
  const js = `
  // ---- setpiece: FLAPBOARD (housing clack → flip-cycle → lock left→right → board life) ----
  const I = ${I.toFixed(3)}, AIN = ${AIN.toFixed(3)}, LOCK0 = I, LSTEP = ${(lockWin / Math.max(1, N - 1)).toFixed(5)};
  const WORDB = ${J(heroText)}, BGLYPHS = ${J(p.glyphs || "ABCDEFGHIKLMNOPRSTUVXYZ0123456789")};
  const HOTB = ${J(dna.palette.hot)}, BONEB = ${J(dna.palette.boardBone || dna.palette.body)};
  const brnd = mulberry32(${p.seed || 8088});
  // scene reaction: floodlights swing onto the board
  tl.fromTo("#dimF",   { opacity: 0 }, { opacity: ${dna.plate.dim}, duration: 0.16, ease: "power2.in" }, AIN - 0.08);
  tl.fromTo("#scrimF", { opacity: 0 }, { opacity: ${dna.plate.charge}, duration: 0.16, ease: "power2.in" }, AIN - 0.08);
  tl.to("#dimF",   { opacity: ${(dna.plate.dim * 0.59).toFixed(2)}, duration: 0.8, ease: "power2.out" }, ${(lockEnd - 0.23).toFixed(3)});
  tl.to("#scrimF", { opacity: ${(dna.plate.charge * 0.61).toFixed(2)}, duration: 0.8, ease: "power2.out" }, ${(lockEnd - 0.23).toFixed(3)});
  tl.to(["#dimF", "#scrimF"], { opacity: 0, duration: 0.4, ease: "power1.in" }, ${(E - 0.39).toFixed(3)});
  // build the flap tiles (one per char; spaces are blank tiles)
  const slots = document.getElementById("slots");
  const btiles = [];
  for (let i = 0; i < WORDB.length; i++) {
    const atile = document.createElement("div"); atile.className = "atile";
    if (WORDB[i] === " ") {
      atile.style.width = Math.round(${tileW} * 0.45) + "px";
      slots.appendChild(atile); btiles.push(null); continue;
    }
    const flap = document.createElement("div"); flap.className = "aflap";
    const n = 3 + Math.floor(brnd() * 3); // 3-5 seeded wrong chars
    const wrongs = [];
    for (let k = 0; k < n; k++) {
      const s = document.createElement("div"); s.className = "ac wv";
      s.textContent = BGLYPHS[Math.floor(brnd() * BGLYPHS.length)];
      flap.appendChild(s); wrongs.push(s);
    }
    const rl = document.createElement("div"); rl.className = "ac rl"; rl.textContent = WORDB[i];
    flap.appendChild(rl);
    atile.appendChild(flap); slots.appendChild(atile);
    btiles.push({ atile, flap, rl, wrongs, n });
  }
  gsap.set("#board", { xPercent: -50, yPercent: -50 });
  // housing clacks down (the flap motif at housing scale)
  tl.set("#board", { opacity: 1 }, AIN);
  tl.fromTo("#board", { rotationX: -52 }, { rotationX: 0, duration: 0.13, ease: "power3.in" }, AIN);
  tl.set("#board", { scaleX: 1.012, scaleY: 0.965 }, AIN + 0.13);
  tl.to("#board", { scaleX: 1, scaleY: 1, duration: 0.28, ease: "elastic.out(1, 0.42)" }, AIN + 0.16);
  // flip-cycle wrong chars, lock left→right with clack rhythm
  btiles.forEach((T, i) => {
    if (!T) return;
    const Li = LOCK0 + i * LSTEP;
    const Si = AIN + 0.035 + i * 0.008;
    const cyc = (Li - Si) / T.n;
    tl.set(T.wrongs[0], { display: "block" }, AIN);
    tl.fromTo(T.flap, { rotationX: -85 }, { rotationX: 0, duration: cyc * 0.8, ease: "power1.in" }, Si);
    for (let k = 1; k < T.n; k++) {
      const c = Si + k * cyc;
      tl.set(T.wrongs[k - 1], { display: "none" }, c);
      tl.set(T.wrongs[k], { display: "block" }, c);
      tl.fromTo(T.flap, { rotationX: -85 }, { rotationX: 0, duration: cyc * 0.8, ease: "power1.in" }, c);
    }
    // LOCK: real char drops in, hot flash, squash, elastic settle, cool to bone
    tl.set(T.wrongs[T.n - 1], { display: "none" }, Li);
    tl.set(T.rl, { visibility: "visible" }, Li);
    tl.set(T.flap, { color: HOTB, filter: "brightness(2.1)" }, Li);
    tl.fromTo(T.flap, { rotationX: -90 }, { rotationX: 0, duration: 0.075, ease: "power2.in" }, Li);
    tl.set(T.atile, { scaleX: 1.05, scaleY: 0.92 }, Li + 0.075);
    tl.to(T.atile, { scaleX: 1, scaleY: 1, duration: 0.3, ease: "elastic.out(1, 0.38)" }, Li + 0.1);
    tl.to(T.flap, { color: BONEB, filter: "brightness(1)", duration: 0.45, ease: "power2.out" }, Li + 0.15);
  });
  // clack rhythm: baked y-jitter on the housing across the lock window
  tl.to("#board", { keyframes: { y: [0, 2, 0, 1.4, 0, 2, 0, 1.2, 0, 1.8, 0] },
                    duration: ${(lockWin + 0.08).toFixed(2)}, ease: "none" }, LOCK0 - 0.01);
  // lock-complete: the big clack (plate punch lands here)
  tl.set("#board", { scaleX: 1.03, scaleY: 0.96 }, ${lockEnd.toFixed(3)});
  tl.to("#board", { scaleX: 1, scaleY: 1, duration: 0.42, ease: "elastic.out(1, 0.4)" }, ${(lockEnd + 0.03).toFixed(3)});
  tl.fromTo("#glowF", { opacity: 0 }, { opacity: 0.85, duration: 0.12, ease: "power2.out" }, ${lockEnd.toFixed(3)});
  tl.to("#glowF", { keyframes: { opacity: [0.85, 0.55, 0.7, 0.45, 0.6, 0.42, 0.5] },
                    duration: ${Math.max(0.4, E - 0.49 - (lockEnd + 0.15)).toFixed(2)}, ease: "none" }, ${(lockEnd + 0.15).toFixed(3)});
  tl.fromTo("#ruleF", { scaleX: 0, opacity: 1 }, { scaleX: 1, duration: 0.3, ease: "expo.out" }, ${(lockEnd + 0.05).toFixed(3)});
${p.tag ? `  tl.to("#tagwrapF", { width: ${tagW}, duration: 0.4, ease: "steps(16)" }, ${(lockEnd + 0.11).toFixed(3)});` : ""}
  // hold life: loom + one mechanical re-flutter tic — boards do this
  tl.to("#board", { scaleX: 1.022, scaleY: 1.022, duration: ${Math.max(0.4, Math.min(1.0, exit0 - lockEnd - 0.55)).toFixed(2)}, ease: "power1.inOut" }, ${(lockEnd + 0.5).toFixed(3)});
  const T6 = btiles[${flutterIdx}];
  if (T6 && ${(lockEnd + 1.0).toFixed(3)} < ${(exit0 - 0.2).toFixed(3)}) {
    const ft = ${(lockEnd + 0.9).toFixed(3)};
    tl.set(T6.rl, { visibility: "hidden" }, ft);
    tl.set(T6.wrongs[0], { display: "block" }, ft);
    tl.fromTo(T6.flap, { rotationX: -70 }, { rotationX: 0, duration: 0.055, ease: "power1.in" }, ft);
    tl.set(T6.wrongs[0], { display: "none" }, ft + 0.06);
    tl.set(T6.rl, { visibility: "visible" }, ft + 0.06);
    tl.fromTo(T6.flap, { rotationX: -80 }, { rotationX: 0, duration: 0.06, ease: "power2.in" }, ft + 0.06);
  }
  // exit: tiles flip to blank (0→90) cascading, housing flips away
  tl.to("#glowF", { opacity: 0, duration: 0.3, ease: "power1.in" }, ${(E - 0.49).toFixed(3)});
  tl.to("#ruleF", { scaleX: 0, opacity: 0, duration: 0.12, ease: "power2.in" }, ${(E - 0.44).toFixed(3)});
${p.tag ? `  tl.to("#tagwrapF", { width: 0, duration: 0.16, ease: "steps(8)" }, ${(E - 0.44).toFixed(3)});` : ""}
  btiles.forEach((T, i) => {
    if (!T) return;
    tl.to(T.flap, { rotationX: 90, duration: 0.085, ease: "power2.in" }, ${exit0.toFixed(3)} + i * 0.03);
  });
  tl.to("#board", { rotationX: -45, opacity: 0, duration: 0.13, ease: "power2.in" }, ${(E - 0.04).toFixed(3)});
  tl.set("#board", { display: "none" }, ${Math.min(E + 0.08, DUR - 0.015).toFixed(3)});`;
  return { css, html, js };
}

function setpieceLedwipe() {
  // TRANSIT LED BOARD: cool scrim + dim charge the room, a departure-board
  // panel EXPANDS upward (stepped paging motif), then the word lights
  // column-by-column via a L→R illumination wipe (clipPath steps) inside a
  // chasing LED-marquee border; PA "ding" = two brightness pulses; hold life =
  // glow breathe + LED dropouts; exit pages the word up out of the panel and
  // the panel collapses back down.
  const h = dna.hero,
    p = h.params,
    I = heroIn;
  const C = I + (p.wipeDur || 0.3); // wipe completes = contact
  const E = heroOut;
  const panelW = HG.panelW,
    panelH = HG.panelH,
    fpx = HG.fontPx;
  const px = Math.round(HG.x - panelW / 2),
    py = Math.round(HG.y - panelH / 2);
  const spillW = panelW - 180,
    spillH = Math.round(panelH * 1.9);
  const exScroll = +(E - 0.72).toFixed(3);
  const wipeSteps = Math.max(8, Math.round(heroText.length * 1.4));
  const A = dna.palette.accent,
    HOT = dna.palette.hot,
    GRN = dna.palette.green || "#36e57a";
  // marquee dot lattice scales with the panel; chase runs until the exit
  const nTop = Math.max(10, Math.round(panelW / 45));
  const nSide = Math.max(2, Math.round((panelH - 19) / 52) - 1);
  const kMax = Math.max(4, Math.floor((exScroll - (I - 0.01)) / 0.1));
  const css = `
  #scrimL { position:absolute; inset:0; opacity:0;
            background: radial-gradient(120% 105% at 50% 40%, rgba(2,6,14,0) 30%, rgba(2,6,14,0.85) 100%); }
  #dimL { position:absolute; inset:0; opacity:0; background:#020409; }
  #spillL { position:absolute; left:${Math.round(HG.x - spillW / 2)}px; top:${Math.round(HG.y - spillH / 2)}px;
            width:${spillW}px; height:${spillH}px; opacity:0;
            background: radial-gradient(closest-side, ${A}4d, ${A}00 72%); }
  #apxL { position:absolute; left:${px}px; top:${py}px; width:${panelW}px; height:${panelH}px; opacity:0; }
  #panelL { position:absolute; inset:0; border-radius:8px;
            background-color: rgba(8,8,11,0.93);
            background-image: radial-gradient(${A}0e 1px, rgba(0,0,0,0) 1.4px);
            background-size: 7px 7px;
            border:2px solid ${A}4d;
            box-shadow: 0 18px 50px rgba(0,0,0,0.55), inset 0 0 70px ${A}0d; }
  .ldot { position:absolute; width:7px; height:7px; border-radius:50%;
          background:${A}; opacity:0.18; box-shadow:0 0 6px ${A}8c; }
  #ptagL, #gtagL { position:absolute; top:16px; font-family:'${dna.fonts.tag}', monospace; font-size:22px;
                   letter-spacing:0.18em; opacity:0; white-space:nowrap; }
  #ptagL { left:28px; color:${A}9e; }
  #gtagL { right:28px; color:${GRN}; text-shadow:0 0 8px ${GRN}80; }
  #panwinL { position:absolute; left:24px; right:24px; top:44px; bottom:18px; overflow:hidden; }
  #scrollL { position:absolute; inset:0; }
  #ghostL, #awordL { position:absolute; left:50%; top:50%;
                     font-family:'${dna.fonts.hero}', monospace; font-size:${fpx}px; line-height:1;
                     letter-spacing:0.05em; white-space:nowrap; }
  #ghostL { color:${A}; opacity:0; }
  #awordL { color:${A}; opacity:0;
            text-shadow: 0 0 18px ${A}8c, 0 0 2px ${HOT}cc; }`;
  const html = `      <div id="scrimL"></div><div id="dimL"></div><div id="spillL"></div>
      <div id="apxL">
        <div id="panelL">
          <div id="ptagL">${esc(p.headerLeft || "NOW ARRIVING ▸ 19:42")}</div>
          <div id="gtagL">${esc(p.headerRight || "GATE A4")}</div>
          <div id="panwinL">
            <div id="scrollL">
              <div id="ghostL">${esc(heroText)}</div>
              <div id="awordL">${esc(heroText)}</div>
            </div>
          </div>
        </div>
      </div>`;
  const js = `
  // ---- setpiece: LEDWIPE (panel expands → L→R illumination wipe → PA ding → page-up exit) ----
  const I = ${I.toFixed(3)}, C = ${C.toFixed(3)};
  gsap.set("#panelL", { transformOrigin: "50% 100%", scaleY: 0 });
  gsap.set(["#ghostL", "#awordL"], { xPercent: -50, yPercent: -50 });

  // ===== scene charge / release =====
  tl.fromTo("#scrimL", { opacity: 0 }, { opacity: ${dna.plate.charge}, duration: 0.30, ease: "power2.in" }, I - 0.31);
  tl.fromTo("#dimL",   { opacity: 0 }, { opacity: ${dna.plate.dim}, duration: 0.25, ease: "power2.in" }, I - 0.25);
  tl.fromTo("#spillL", { opacity: 0 }, { opacity: 0.45, duration: 0.40, ease: "power2.out" }, I + 0.05);
  tl.to("#spillL", { opacity: 0, duration: 0.35, ease: "power1.in" }, ${(E - 0.54).toFixed(3)});
  tl.to(["#scrimL", "#dimL"], { opacity: 0, duration: 0.40, ease: "power1.in" }, ${(E - 0.44).toFixed(3)});

  // ===== board expands UPWARD (stepped paging motif, apex scale) =====
  tl.set("#apxL", { opacity: 1 }, I - 0.26);
  tl.to("#panelL", { scaleY: 1, duration: 0.20, ease: "steps(6)" }, I - 0.25);
  tl.set("#panelL", { borderColor: "${HOT}f2" }, I - 0.05);
  tl.set("#panelL", { borderColor: "${A}4d" }, I + 0.07);

  // power-on boot: unlit LED cells + header tags flicker alive
  tl.set("#ghostL", { opacity: 0.06 }, I - 0.045);
  tl.set("#ghostL", { opacity: 0.13 }, I - 0.003);
  tl.set(["#ptagL", "#gtagL"], { opacity: 0.4 }, I - 0.045);
  tl.set(["#ptagL", "#gtagL"], { opacity: 0.9 }, I - 0.003);

  // ===== APEX: L→R illumination wipe — LEDs light column by column =====
  tl.set("#awordL", { opacity: 1, color: "${HOT}" }, I);
  tl.fromTo("#awordL", { clipPath: "inset(0% 100% 0% 0%)" },
            { clipPath: "inset(0% 0% 0% 0%)", duration: ${(p.wipeDur || 0.3).toFixed(2)}, ease: "steps(${wipeSteps})" }, I);
  tl.set("#awordL", { clipPath: "none" }, C + 0.05);       // free the glow halo
  tl.to("#awordL", { color: "${A}", duration: 0.60, ease: "power1.in" }, C + 0.05); // hot → amber cool

  // contact: squash held 2 frames, elastic settle (board cell flexes)
  tl.set("#scrollL", { scaleX: 1.06, scaleY: 0.94 }, C);
  tl.to("#scrollL", { scaleX: 1, scaleY: 1, duration: 0.42, ease: "elastic.out(1, 0.4)" }, C + 0.083);

  // PA "ding" = two quick brightness pulses
  tl.set("#panwinL", { filter: "brightness(1.7)" },  C + 0.02);
  tl.set("#panwinL", { filter: "brightness(1)" },    C + 0.10);
  tl.set("#panwinL", { filter: "brightness(1.45)" }, C + 0.18);
  tl.set("#panwinL", { filter: "brightness(1)" },    C + 0.26);
${
  C + 0.99 < exScroll
    ? `
  // hold life: glow breathes + LED dropouts
  tl.to("#awordL", { textShadow: "0 0 30px ${A}d9, 0 0 4px ${HOT}f2",
                     duration: 0.45, ease: "sine.inOut" }, C + 0.54);
  tl.to("#awordL", { textShadow: "0 0 18px ${A}8c, 0 0 2px ${HOT}cc",
                     duration: 0.45, ease: "sine.inOut" }, C + 0.99);
  tl.set("#awordL", { opacity: 0.82 }, C + 0.61); tl.set("#awordL", { opacity: 1 }, C + 0.652);
  tl.set("#awordL", { opacity: 0.90 }, C + 1.04); tl.set("#awordL", { opacity: 1 }, C + 1.082);
  tl.set("#ptagL", { opacity: 0.45 }, C + 0.41); tl.set("#ptagL", { opacity: 0.9 }, C + 0.452);`
    : ""
}

  // ===== exit: word pages up out of the window, board collapses =====
  tl.to("#scrollL", { y: ${-(panelH - 62)}, duration: 0.22, ease: "steps(6)" }, ${exScroll.toFixed(3)});
  tl.set(["#ptagL", "#gtagL"], { opacity: 0 }, ${(exScroll + 0.2).toFixed(3)});
  tl.to("#panelL", { scaleY: 0, duration: 0.18, ease: "steps(6)" }, ${(exScroll + 0.24).toFixed(3)});
  tl.set("#apxL", { opacity: 0 }, ${(exScroll + 0.44).toFixed(3)});

  // ===== chasing LED marquee border (8-phase set chain) =====
  const panelL = document.getElementById("panelL");
  const PW = ${panelW}, PH = ${panelH}, groups = [[], [], [], [], [], [], [], []];
  const pts = [];
  for (let i = 0; i < ${nTop}; i++) pts.push([10 + i * (PW - 27) / ${nTop - 1}, 6]);        // top L→R
  for (let j = 0; j < ${nSide}; j++) pts.push([PW - 13, 6 + (j + 1) * (PH - 19) / ${nSide + 1}]); // right ↓
  for (let i = ${nTop - 1}; i >= 0; i--) pts.push([10 + i * (PW - 27) / ${nTop - 1}, PH - 13]);   // bottom R→L
  for (let j = ${nSide - 1}; j >= 0; j--) pts.push([6, 6 + (j + 1) * (PH - 19) / ${nSide + 1}]);  // left ↑
  pts.forEach(([x, y], i) => {
    const d = document.createElement("div");
    d.className = "ldot"; d.style.left = Math.round(x) + "px"; d.style.top = Math.round(y) + "px";
    d.style.opacity = "0"; panelL.appendChild(d);
    groups[i % 8].push(d);
  });
  groups.forEach((g) => tl.set(g, { opacity: 0.18 }, I - 0.02));
  for (let k = 0; k <= ${kMax}; k++) {
    const t = I - 0.01 + k * 0.1;
    for (let q = 0; q < 8; q++) {
      const lit = (((q - k) % 8) + 8) % 8 < 2;
      tl.set(groups[q], { opacity: lit ? 0.95 : 0.18 }, t);
    }
  }`;
  return { css, html, js };
}

function setpieceVhsosd() {
  // REC FREEZE: the word slams in as a huge camcorder OSD readout BEHIND the
  // subject. Permanent lens vignette = the camcorder look; a backlight-comp
  // scrim kills the bright sky behind the word. Arrival = tracking-sliced
  // crush (3 horizontal clip-path bands sheared + blurred, power4.in) that
  // SNAPS into register on the contact frame, squash → elastic settle; heavy
  // red/cyan misregistration cools from ±arrival px to ±settled px and keeps
  // BREATHING through the hold; tape-weave y jitter + slow loom; auto-iris
  // pump (white wash up, dark dip, settle). Exit = REWIND streak left with a
  // flat-white echo copy.
  const h = dna.hero,
    p = h.params,
    I = heroIn;
  const C = I + (p.crush ?? 0.1); // contact: slices snap into register
  const X = theme.hero.exitAt ?? Math.min(heroOut, I + (p.hold ?? 1.69)); // REWIND
  const clips = p.bandClips || [
    [0, 64],
    [36, 30],
    [70, 0],
  ];
  const shear = p.bandShear || [-46, 40, -34];
  const mi = p.misregIn ?? 11, // arrival misregistration (px)
    m = p.misreg ?? 3; // settled misregistration (px)
  const RED = dna.palette.red || "#FF2E2E",
    CYN = dna.palette.cyan || "#3FE8E8";
  const yPct = ((HG.y / H) * 100).toFixed(0);
  const css = `
  #vig { position:absolute; inset:0; opacity:0;
         background: radial-gradient(115% 100% at 50% 46%, rgba(0,0,0,0) 52%, rgba(0,0,0,${p.vignette ?? 0.34}) 100%); }
  #vscrim { position:absolute; inset:0; opacity:0;
           background: radial-gradient(58% 44% at 50% ${yPct}%, rgba(0,0,0,0.62) 0%, rgba(0,0,0,0.34) 55%, rgba(0,0,0,0) 78%); }
  #iris  { position:absolute; inset:0; opacity:0; background:#ffffff; }
  #irisd { position:absolute; inset:0; opacity:0; background:#000000; }
  #osdw, #oecho { position:absolute; left:${HG.x}px; top:${HG.y}px; opacity:0; }
  .aw { position:absolute; left:0; top:0; white-space:nowrap;
        font-family:'${dna.fonts.hero}', monospace; font-size:${HG.fontPx}px; line-height:1;
        letter-spacing:0.02em; }
  #mr { color:${RED}; opacity:0.55; }
  #mc { color:${CYN}; opacity:0.55; }
  .wb2 { color:${dna.palette.body}; text-shadow: 0 4px 24px rgba(0,0,0,0.6), 0 1px 4px rgba(0,0,0,0.45); }
  ${clips.map((c, i) => `#vb${i + 1} { clip-path: inset(${c[0]}% 0 ${c[1]}% 0); }`).join("\n  ")}
  #oe { color:#ffffff; opacity:0.4; text-shadow:none; }`;
  const html = `      <div id="vig"></div>
      <div id="vscrim"></div>
      <div id="osdw">
        <div id="weave">
          <div class="aw" id="mr">${esc(heroText)}</div>
          <div class="aw" id="mc">${esc(heroText)}</div>
          ${clips.map((_, i) => `<div class="aw wb2" id="vb${i + 1}">${esc(heroText)}</div>`).join("\n          ")}
        </div>
      </div>
      <div id="oecho"><div class="aw" id="oe">${esc(heroText)}</div></div>
      <div id="iris"></div>
      <div id="irisd"></div>`;
  const bandIds = clips.map((_, i) => "#vb" + (i + 1));
  // hold life windows (clamped inside [contact, exit] so nothing fights the rewind)
  const weaveTs = [];
  const wv = p.weave || [-2, 1, -1, 2, -1, 1, -2, 0];
  for (let k = 0; k < wv.length; k++) {
    const t = C + 0.19 + k * 0.17;
    if (t < X - 0.05) weaveTs.push([+t.toFixed(3), wv[k]]);
  }
  const brDur = Math.min(1.3, X - (C + 0.21) - 0.05);
  const loomDur = Math.min(0.92, X - (C + 0.64) - 0.03);
  const js = `
  // ---- setpiece: VHSOSD (tracking-sliced OSD slam → misreg breathe → REWIND exit) ----
  const I = ${I.toFixed(3)}, C = ${C.toFixed(3)}, X = ${X.toFixed(3)};
  gsap.set(${J(["#mr", "#mc", ...bandIds, "#oe"])}, { xPercent:-50, yPercent:-50 });
  gsap.set("#weave", { x: 0, y: 0 });

  // camcorder lens vignette — present the whole clip
  tl.set("#vig", { opacity: 1 }, 0.02);

  // backlight-compensation scrim behind the word (the sky is bright up there)
  tl.fromTo("#vscrim", { opacity: 0 }, { opacity: ${dna.plate.charge}, duration: 0.20, ease: "power2.in" }, I - 0.04);
  tl.to("#vscrim", { opacity: ${(dna.plate.charge * 0.69).toFixed(2)}, duration: 0.8, ease: "power2.out" }, C + 0.35);
  tl.to("#vscrim", { opacity: 0, duration: 0.30, ease: "power1.in" }, X);

  // ===== ARRIVAL: tracking-sliced crush, opacity 1 instantly =====
  tl.set("#osdw", { opacity: 1 }, I);
  tl.fromTo("#osdw", { scale: 2.9 }, { scale: 1, duration: C - I, ease: "power4.in" }, I);
  const SHEAR = ${J(shear)};
  ${J(bandIds)}.forEach((sel, i) => {
    tl.fromTo(sel, { x: SHEAR[i], filter: "blur(10px) brightness(2.6)" },
              { x: Math.round(SHEAR[i] * 0.3), filter: "blur(1px) brightness(1.7)", duration: C - I, ease: "power4.in" }, I);
  });
  tl.set("#mr", { x: -${mi} }, I);
  tl.set("#mc", { x: ${mi} }, I);

  // SNAP into register on the contact frame + squash, elastic settle
  tl.set(${J(bandIds)}, { x: 0, filter: "brightness(1.85)" }, C);
  tl.set("#mr", { x: -${m} }, C);
  tl.set("#mc", { x: ${m} }, C);
  tl.set("#osdw", { scaleX: 1.10, scaleY: 0.90 }, C);
  tl.to("#osdw", { scaleX: 1, scaleY: 1, duration: 0.50, ease: "elastic.out(1, 0.36)" }, C + 0.083);
  tl.to(${J(bandIds)}, { filter: "brightness(1)", duration: 0.45, ease: "power2.out" }, C + 0.10);

  // AUTO-IRIS PUMP: wash up, then a dark dip, once
  tl.to("#iris", { opacity: 0.20, duration: 0.12, ease: "sine.out" }, C + 0.04);
  tl.to("#iris", { opacity: 0, duration: 0.33, ease: "power1.in" }, C + 0.16);
  tl.to("#irisd", { opacity: ${dna.plate.dim}, duration: 0.28, ease: "sine.out" }, C + 0.55);
  tl.to("#irisd", { opacity: 0, duration: 0.40, ease: "sine.in" }, C + 0.85);
${
  brDur > 0.3
    ? `
  // ===== HOLD LIFE: misregistration breathes, tape weave, slow loom =====
  tl.to("#mr", { keyframes: { x: [-${m}, -${(m * 1.53).toFixed(1)}, -${(m * 1.07).toFixed(1)}, -${(m * 1.67).toFixed(1)}, -${(m * 1.17).toFixed(1)}] }, duration: ${brDur.toFixed(2)}, ease: "none" }, C + 0.21);
  tl.to("#mc", { keyframes: { x: [${m}, ${(m * 1.53).toFixed(1)}, ${(m * 1.07).toFixed(1)}, ${(m * 1.67).toFixed(1)}, ${(m * 1.17).toFixed(1)}] }, duration: ${brDur.toFixed(2)}, ease: "none" }, C + 0.21);
  ${J(weaveTs)}.forEach(([t, v]) => tl.set("#weave", { y: v }, t));`
    : ""
}
${
  loomDur > 0.3
    ? `  tl.to("#osdw", { keyframes: { scale: [1, 1.03, 1.015, 1.028] }, duration: ${loomDur.toFixed(2)}, ease: "none" }, C + 0.64);`
    : ""
}

  // ===== EXIT: REWIND — streak left with echo copy =====
  tl.to("#osdw", { x: -190, scaleX: 1.3, opacity: 0, duration: 0.13, ease: "power2.in" }, X);
  tl.set("#oecho", { opacity: 1 }, X);
  tl.fromTo("#oe", { x: 0, opacity: 0.4 }, { x: -95, opacity: 0, duration: 0.18, ease: "power2.in" }, X + 0.01);
  tl.set(["#osdw","#oecho"], { display: "none" }, X + 0.25);`;
  return { css, html, js };
}

function setpieceBossintro() {
  // BOSS INTRO (bg): the room darkens behind a CRT edge glow (cyan base wash,
  // pulses on the last emphasis before the boss and spills magenta on the
  // strobe + the final word), WARNING blinks 2x above the word rect, then
  // ~COLS×ROWS seeded pixel blocks fall steps(3) into a tight matrix grid
  // covering the word rect (1-frame white land tick each — the coin-blip motif
  // at block scale), and the word flashes ON (instant, oversized, quantized
  // crush → 2-frame squash → elastic settle) with chromatic ghost layers and a
  // 3-frame palette strobe. Hold: quantized breathe + CRT glow cycle + baked
  // block shimmer blinks. Exit: per-letter checkerboard dissolve; blocks drop
  // away quantized. FG furniture (rides rail.html via fg parts): letterbox
  // bars slam in with a 1-frame overshoot and a HI-SCORE odometer rolls
  // top-right through the hold.
  const h = dna.hero,
    p = h.params || {},
    I = heroIn;
  const CY = dna.palette.accent,
    MG = dna.palette.magenta || "#FF2E88",
    YL = dna.palette.yellow || "#FFE600";
  const FLASH = I + (p.flashDelay ?? 0.14);
  const EX = theme.hero.exitAt ?? Math.min(heroOut - 0.2, I + (p.hold ?? 1.24));
  const ROWS = p.rows || 4;
  const CWp = Math.round(HG.fontPx * 0.7),
    CHp = Math.round(HG.fontPx * 0.5);
  const COLS = Math.ceil((HG.halfW * 2 + 100) / CWp);
  const X0 = Math.round(HG.x - (COLS * CWp) / 2),
    Y0 = Math.round(HG.y - (ROWS * CHp) / 2);
  // scene-reaction pulses: the edge glow pre-echoes the boss on the last
  // emphasis word before the takeover; magenta answers on the final word
  const minorsBefore = LINES.flatMap((L) => L.words).filter((w) => w.minor && w.start < I - 0.3);
  const pulseT = minorsBefore.length ? minorsBefore[minorsBefore.length - 1].start : null;
  const endPulse =
    LASTWORD.start + 0.384 < DUR - 0.02 && LASTWORD.start > EX + 0.4 ? LASTWORD.start : null;
  const lb = p.letterbox || 44;
  const css = `
  #bdim  { position:absolute; inset:0; opacity:0;
           background:linear-gradient(180deg, rgba(2,3,12,0.66) 0%, rgba(2,3,12,0.40) 55%, rgba(2,3,12,0.30) 100%); }
  #edgeC { position:absolute; inset:0; opacity:0; box-shadow: inset 0 0 130px ${CY}4d; }
  #edgeM { position:absolute; inset:0; opacity:0; box-shadow: inset 0 0 130px ${MG}52; }
  #blocks{ position:absolute; inset:0; }
  .blk   { position:absolute; opacity:0; }
  #bossWord { position:absolute; left:${HG.x}px; top:${HG.y}px; opacity:0; }
  .bw    { position:absolute; left:0; top:0; white-space:nowrap;
           font-family:'${dna.fonts.hero}', monospace; font-size:${HG.fontPx}px; line-height:1; }
  #bwC   { color:${CY}; opacity:0; }
  #bwM   { color:${MG}; opacity:0; }
  #bwMain{ color:#ffffff;
           text-shadow:0 6px 0 rgba(0,0,0,0.6), 0 0 16px rgba(0,0,0,0.9), 0 0 5px rgba(0,0,0,0.85); }
  #bwMain .bl { display:inline-block; }
  #warning { position:absolute; left:${HG.x}px; top:${Math.round(HG.y - HG.fontPx * 1.43)}px; opacity:0;
             font-family:'${dna.fonts.tag}', monospace; font-size:26px; letter-spacing:0.4em;
             color:${MG};
             text-shadow:0 0 14px rgba(0,0,0,0.95), 0 0 6px rgba(0,0,0,0.9), 0 4px 0 rgba(0,0,0,0.8); }`;
  const html = `      <div id="bdim"></div>
      <div id="edgeC"></div>
      <div id="edgeM"></div>
      <div id="blocks"></div>
      <div id="bossWord">
        <div class="bw" id="bwC">${esc(heroText)}</div>
        <div class="bw" id="bwM">${esc(heroText)}</div>
        <div class="bw" id="bwMain"></div>
      </div>
      <div id="warning">${esc(p.tag || "WARNING")}</div>`;
  const js = `
  // ---- setpiece: BOSSINTRO (dim + WARNING -> pixel-block assembly -> strobe flash) ----
  const I = ${I.toFixed(3)}, FLASH = ${FLASH.toFixed(3)}, OUT = ${EX.toFixed(3)};
  const CYc = ${J(CY)}, MGc = ${J(MG)}, YLc = ${J(YL)}, WHc = "#ffffff";
  const brnd = mulberry32(${p.seed || 13013});

  // ===== scene reaction: CRT edge glow lives behind the subject =====
  tl.set("#edgeC", { opacity: 0.28 }, 0.05);
${
  pulseT != null
    ? `  // pulse on the last emphasis before the boss
  tl.set("#edgeC", { opacity: 0.62 }, ${pulseT.toFixed(3)});
  tl.set("#edgeC", { opacity: 0.28 }, ${(pulseT + 0.084).toFixed(3)});`
    : ""
}
  // boss dim: room darkens for the boss intro (helps a bright sky too)
  tl.to("#bdim", { opacity: ${dna.plate.dim ?? 0.58}, duration: 0.12, ease: "power2.out" }, I - 0.15);
  tl.set("#edgeC", { opacity: 0.8 }, FLASH);
  tl.to("#edgeC", { opacity: 0.34, duration: 0.6, ease: "power2.out" }, FLASH + 0.08);
  tl.set("#edgeM", { opacity: 0.5 }, FLASH + 0.084);            // magenta strobe spill
  tl.to("#edgeM", { opacity: 0, duration: 0.25, ease: "power1.out" }, FLASH + 0.126);
  tl.to("#bdim", { opacity: 0, duration: 0.35, ease: "power1.inOut" }, OUT + 0.10);
${
  endPulse != null
    ? `  // magenta answers on the final word
  tl.set("#edgeM", { opacity: 0.55 }, ${endPulse.toFixed(3)});
  tl.to("#edgeM", { opacity: 0, duration: 0.3, ease: "power1.out" }, ${(endPulse + 0.084).toFixed(3)});`
    : ""
}

  // ===== WARNING blinks 2x above the word =====
  tl.set("#warning", { opacity: 1 }, I - 0.135);
  tl.set("#warning", { opacity: 0 }, I - 0.05);
  tl.set("#warning", { opacity: 1 }, I + 0.035);
  tl.set("#warning", { opacity: 0 }, I + 0.12);
  gsap.set("#warning", { xPercent: -50, yPercent: -50 });

  // ===== pixel-block boss assembly: blocks fall steps(3) into a tight grid =====
  const blocksEl = document.getElementById("blocks");
  const X0 = ${X0}, Y0 = ${Y0}, COLS = ${COLS}, ROWS = ${ROWS}, CW = ${CWp}, CH = ${CHp};
  for (let c = 0; c < COLS; c++) {
    for (let r = 0; r < ROWS; r++) {
      const b = document.createElement("div");
      b.className = "blk"; blocksEl.appendChild(b);
      const s = 8 + Math.round(brnd() * 6);                       // 8–14px
      const jx = Math.round((brnd() - 0.5) * 6), jy = Math.round((brnd() - 0.5) * 6);
      const px = brnd();
      const col = px < 0.4 ? CYc : px < 0.78 ? MGc : px < 0.93 ? YLc : WHc;
      gsap.set(b, { width: s, height: s,
                    left: X0 + c * CW + (CW - s) / 2 + jx,
                    top:  Y0 + r * CH + (CH - s) / 2 + jy,
                    backgroundColor: col });
      const start = Math.min(I - 0.15 + c * 0.010 + brnd() * 0.04, FLASH - 0.10);
      let dur = 0.12 + brnd() * 0.06;
      if (start + dur > FLASH - 0.02) dur = FLASH - 0.02 - start;
      const fall = 240 + brnd() * 260;
      tl.set(b, { opacity: 0.9, y: -fall }, start);
      tl.to(b, { y: 0, duration: dur, ease: "steps(3)" }, start);
      // 1-frame white land tick (the coin-blip motif at block scale)
      const land = start + dur;
      tl.set(b, { backgroundColor: WHc }, land);
      tl.set(b, { backgroundColor: col, opacity: 0.85 }, land + 0.042);
      // hold shimmer: baked blink sets (no infinite repeats)
      const t1 = FLASH + 0.11 + brnd() * 0.45, t2 = FLASH + 0.61 + brnd() * 0.4;
      if (t1 + 0.083 < OUT) { tl.set(b, { opacity: 0.5 },  t1); tl.set(b, { opacity: 0.85 }, t1 + 0.083); }
      if (t2 + 0.083 < OUT) { tl.set(b, { opacity: 0.55 }, t2); tl.set(b, { opacity: 0.85 }, t2 + 0.083); }
      // exit: blocks drop off, quantized
      const tE = Math.min(OUT + brnd() * 0.15, ${(DUR - 0.32).toFixed(2)});
      tl.to(b, { y: 220 + brnd() * 140, opacity: 0, duration: 0.2 + brnd() * 0.1,
                 ease: "steps(3)" }, tE);
    }
  }

  // ===== the word flashes ON over the block grid =====
  const main = document.getElementById("bwMain");
  const letters = [];
  ${J(heroText)}.split("").forEach((ch) => {
    const s = document.createElement("span");
    s.className = "bl"; s.textContent = ch === " " ? "\\u00a0" : ch;
    main.appendChild(s); letters.push(s);
  });
  gsap.set("#bwC",   { xPercent: -50, yPercent: -50, x: -4 });
  gsap.set("#bwM",   { xPercent: -50, yPercent: -50, x: 4 });
  gsap.set("#bwMain",{ xPercent: -50, yPercent: -50 });
  gsap.set("#bossWord", { transformOrigin: "50% 50%" });

  // arrival: instant-on at full opacity, quantized crush, 2-frame squash, elastic settle
  tl.set("#bossWord", { opacity: 1, scale: 1.15 }, FLASH);
  tl.set(["#bwC", "#bwM"], { opacity: 0.85 }, FLASH);
  tl.to("#bossWord", { scale: 1, duration: 0.1, ease: "steps(3)" }, FLASH);
  tl.set("#bossWord", { scaleX: 1.07, scaleY: 0.92 }, FLASH + 0.1);
  tl.to("#bossWord", { scaleX: 1, scaleY: 1, duration: 0.3,
                       ease: "elastic.out(1, 0.38)" }, FLASH + 0.142);
  // 3-frame palette strobe (white -> cyan -> magenta -> white)
  tl.set(main, { color: CYc }, FLASH + 0.042);
  tl.set(main, { color: MGc }, FLASH + 0.084);
  tl.set(main, { color: WHc }, FLASH + 0.126);
  // hold life: quantized breathe + CRT glow cycle (emitted only with runway)
  if (FLASH + 0.61 < OUT) {
    tl.to("#bossWord", { scale: 1.016, duration: 0.4, ease: "sine.inOut",
                         repeat: 1, yoyo: true }, FLASH + 0.61);
  }
  const HALO = ", 0 6px 0 rgba(0,0,0,0.6), 0 0 16px rgba(0,0,0,0.9), 0 0 5px rgba(0,0,0,0.85)";
  [[0.2, CYc], [0.5, MGc], [0.8, CYc]].forEach(([dt, gc]) => {
    if (FLASH + dt < OUT) tl.set(main, { textShadow: "0 0 22px " + gc + "d9" + HALO }, FLASH + dt);
  });

  // exit: checkerboard dissolve — letters vanish in 2 alternating set passes
  letters.forEach((s, i) => {
    tl.set(s, { opacity: 0 }, i % 2 === 0 ? OUT : OUT + 0.083);
  });
  tl.set(["#bwC", "#bwM"], { opacity: 0 }, OUT + 0.042);
  tl.set("#bossWord", { opacity: 0, display: "none" }, OUT + 0.17);`;
  // fg furniture: letterbox bars + HI-SCORE odometer ride the fg alpha layer
  // (in FRONT of the subject — the cabinet frames the player)
  const fgCss = `
  #lbTop { position:absolute; left:0; top:-${lb}px;    width:${W}px; height:${lb}px; background:#000; z-index:14; }
  #lbBot { position:absolute; left:0; bottom:-${lb}px; width:${W}px; height:${lb}px; background:#000; z-index:14; }
  #hi    { position:absolute; right:36px; top:${lb + 18}px; opacity:0; white-space:nowrap;
           font-family:'${dna.fonts.tag}', monospace; font-size:18px; z-index:15;
           text-shadow:0 3px 0 rgba(0,0,0,0.75), 0 0 10px rgba(0,0,0,0.6); }
  #hi .lab { color:${CY}; margin-right:0.5em; }
  #hi #hsv { color:${YL}; }`;
  const fgHtml = `      <div id="lbTop"></div>
      <div id="lbBot"></div>
      <div id="hi"><span class="lab">HI-SCORE</span><span id="hsv">0000000</span></div>`;
  const fgJs = `
  // ---- bossintro furniture (fg): letterbox slam + HI-SCORE odometer ----
  {
    const BI = ${I.toFixed(3)}, BF = ${FLASH.toFixed(3)}, BO = ${EX.toFixed(3)};
    const hrnd = mulberry32(${(p.seed || 13013) + 7});
    // letterbox bars slam in (quantized), 1-frame overshoot, retract after
    tl.to("#lbTop", { y: ${lb},  duration: 0.12, ease: "steps(3)" }, BI - 0.15);
    tl.to("#lbBot", { y: -${lb}, duration: 0.12, ease: "steps(3)" }, BI - 0.15);
    tl.set("#lbTop", { y: ${lb + 4} },  BI - 0.02); tl.set("#lbTop", { y: ${lb} },  BI + 0.022);
    tl.set("#lbBot", { y: -${lb + 4} }, BI - 0.02); tl.set("#lbBot", { y: -${lb} }, BI + 0.022);
    tl.to("#lbTop", { y: 0, duration: 0.15, ease: "steps(3)" }, BO + 0.13);
    tl.to("#lbBot", { y: 0, duration: 0.15, ease: "steps(3)" }, BO + 0.13);
    // HI-SCORE counter odometer-rolls during the hold
    const hsv = document.getElementById("hsv");
    tl.set("#hi", { opacity: 1 }, BF + 0.03);
    tl.fromTo("#hi", { scale: 0 }, { scale: 1, duration: 0.1, ease: "steps(2)",
                                     transformOrigin: "100% 50%" }, BF + 0.03);
    let score = 1370;
    for (let t = BF + 0.09; t < BO - 0.03; t += 0.066) {
      score += 4000 + Math.round(hrnd() * 5500);
      tl.set(hsv, { textContent: String(score).padStart(7, "0") }, t);
    }
    tl.set("#hi", { color: "#ffffff" }, BO + 0.08);              // 1-frame white tick out
    tl.set("#hi", { opacity: 0 }, BO + 0.122);
  }`;
  return { css, html, js, fgCss, fgHtml, fgJs };
}

function setpieceRubberstamp() {
  // RUBBER STAMP: the footage becomes an archival page at the apex — vignette
  // charge (plate.charge) + manila sepia wash + a dim kick (plate.dim) that
  // cools — then the verdict slams in BEHIND the subject as a bordered rubber
  // stamp at params.rotation: crush 2.2→1 power4.in, 2-frame contact squash,
  // double-strike ghost print, two thin-ink patches (clip-path circles in
  // paper color), wet-ink saturate/brightness settle, and a paper indent
  // shadow that blooms under the face. Stamps don't move: the hold is DEAD
  // STILL, sold by the breathing indent + a slow paper loom. Exit at
  // C + params.hold = mechanical 3-frame yank-down cut (the page is filed
  // away); the archive plate releases 0.25s before it.
  const h = dna.hero,
    p = h.params || {},
    I = heroIn;
  const C = I + (p.crush ?? 0.074); // stamp contact (plate punch lands here via punchOffset)
  const X = theme.hero.exitAt ?? Math.min(heroOut - 0.06, C + (p.hold ?? 2.02));
  const ROT = p.rotation ?? -8;
  const RED = dna.palette.accent || "#b3271e";
  const s = HG.fontPx / 120; // demo face was cut at 120px — chrome scales with it
  const bw = Math.max(3, Math.round(6 * s)),
    rad = Math.round(14 * s);
  const padT = Math.round(20 * s),
    padX = Math.round(40 * s),
    padB = Math.round(14 * s);
  const paperW = Math.round(HG.halfW * 2 * 1.18),
    paperH = Math.round(HG.fontPx * 2.83);
  const indW = Math.round(HG.halfW * 2 * 1.06),
    indH = Math.round(HG.fontPx * 2.08);
  const patches = p.patches || [
    [27, 36, 0.72],
    [71, 68, 0.57],
  ];
  const loomDur = Math.min(1.7, DUR - (C + 0.35) - 0.03);
  const breDur = Math.min(1.6, DUR - (C + 0.45) - 0.03);
  const css = `
  #dsep { position:absolute; inset:0; opacity:0; background:${dna.palette.paper || "#d8c49a"}; }
  #dvig { position:absolute; inset:0; opacity:0;
          background: radial-gradient(115% 100% at 50% 40%, rgba(0,0,0,0) 30%, rgba(20,14,8,0.85) 100%); }
  #ddim { position:absolute; inset:0; opacity:0; background:#140d06; }
  #dapex { position:absolute; left:${HG.x}px; top:${HG.y}px; width:0; height:0; }
  #dpaper { position:absolute; width:${paperW}px; height:${paperH}px; opacity:0;
            background: radial-gradient(50% 50% at 50% 50%, rgba(222,203,160,0.92) 0%, rgba(216,196,154,0.55) 52%, rgba(216,196,154,0) 78%); }
  #dindent { position:absolute; width:${indW}px; height:${indH}px; opacity:0; filter:blur(8px);
             background: radial-gradient(50% 50% at 50% 50%, rgba(26,16,9,0.62) 0%, rgba(26,16,9,0.30) 48%, rgba(26,16,9,0) 72%); }
  #dstamp { position:absolute; }
  #dsface { position:relative; display:inline-block; }
  .dface { font-family:'${dna.fonts.hero}', serif; font-size:${HG.fontPx}px; line-height:1;
           white-space:nowrap; letter-spacing:0.015em; color:${RED};
           border:${bw}px solid currentColor; border-radius:${rad}px;
           padding:${padT}px ${padX}px ${padB}px; }
  #dghost, #dp1, #dp2 { position:absolute; inset:0; }
  #dghost { opacity:0; }
  #dp1 .dface, #dp2 .dface { color:rgba(219,199,156,0.55); }
  #dp1 { clip-path: circle(${Math.round(patches[0][2] * HG.fontPx)}px at ${patches[0][0]}% ${patches[0][1]}%); }
  #dp2 { clip-path: circle(${Math.round(patches[1][2] * HG.fontPx)}px at ${patches[1][0]}% ${patches[1][1]}%); }`;
  const html = `      <div id="dsep"></div>
      <div id="dvig"></div>
      <div id="ddim"></div>
      <div id="dapex">
        <div id="dpaper"></div>
        <div id="dindent"></div>
        <div id="dstamp">
          <div id="dsface">
            <div id="dghost"><div class="dface">${esc(heroText)}</div></div>
            <div id="dmain" class="dface">${esc(heroText)}</div>
            <div id="dp1"><div class="dface">${esc(heroText)}</div></div>
            <div id="dp2"><div class="dface">${esc(heroText)}</div></div>
          </div>
        </div>
      </div>`;
  const js = `
  // ---- setpiece: RUBBERSTAMP (archive charge → stamp crush → dead-still hold → filed away) ----
  const I = ${I.toFixed(3)}, C = ${C.toFixed(3)}, X = ${X.toFixed(3)};

  // static geometry
  gsap.set("#dpaper",  { xPercent:-50, yPercent:-50, rotation: ${p.paperRot ?? 2.4} });
  gsap.set("#dindent", { xPercent:-50, yPercent:-50, rotation: ${ROT} });
  gsap.set("#dstamp",  { xPercent:-50, yPercent:-50, rotation: ${ROT}, opacity: 0,
                         transformOrigin: "50% 50%" });
  gsap.set("#dghost",  { x: ${Math.round(5 * s) || 5}, y: -${Math.round(4 * s) || 4}, rotation: 1.3 });

  // ===== SCENE REACTION: the footage becomes an archive page =====
  tl.fromTo("#dvig", { opacity: 0 }, { opacity: ${dna.plate.charge}, duration: 0.18, ease: "power2.in" }, C - 0.18);
  tl.fromTo("#dsep", { opacity: 0 }, { opacity: ${p.sepia ?? 0.14}, duration: 0.28, ease: "power1.out" }, C - 0.12);
  tl.set("#ddim", { opacity: ${dna.plate.dim} }, C);
  tl.to("#ddim",  { opacity: ${(dna.plate.dim * 0.385).toFixed(2)}, duration: 0.5, ease: "power2.out" }, C + 0.05);
  tl.to("#dvig",  { opacity: ${(dna.plate.charge * 0.5).toFixed(2)}, duration: 0.8, ease: "power2.out" }, C + 0.12);
  tl.to(["#dvig","#dsep","#ddim"], { opacity: 0, duration: 0.4, ease: "power1.in" }, X - 0.25);

  // the page lights up an instant before contact (what the stamp lands ON)
  tl.fromTo("#dpaper", { opacity: 0, scale: 0.92 },
            { opacity: 0.6, scale: 1, duration: 0.10, ease: "expo.out" }, C - 0.05);
  tl.to("#dpaper", { keyframes: { scale: [1, 1.016, 1.005, 1.018, 1.008, 1.015] },
                     duration: ${loomDur.toFixed(2)}, ease: "none" }, C + 0.35);

  // ===== ARRIVAL: rubber stamp crush → contact squash → dead still =====
  tl.set("#dstamp", { opacity: 1, scale: 2.2 }, C - 0.115);
  tl.to("#dstamp",  { scale: 1, duration: 0.115, ease: "power4.in" }, C - 0.115);
  tl.set("#dstamp", { scaleX: 1.07, scaleY: 0.93 }, C);             // contact squash
  tl.to("#dstamp",  { scaleX: 1, scaleY: 1, duration: 0.09, ease: "power2.out" }, C + 0.083);
  // double-strike ghost prints on contact
  tl.set("#dghost", { opacity: 0.35 }, C);
  // wet ink settles
  tl.set("#dmain", { filter: "saturate(1.5) brightness(1.3)" }, C);
  tl.to("#dmain",  { filter: "saturate(1) brightness(1)", duration: 0.5, ease: "power2.out" }, C + 0.06);

  // paper indent shadow blooms under the stamp, then breathes (the hold-life;
  // the stamp itself stays DEAD STILL)
  tl.set("#dindent", { opacity: 0.5, scale: 0.7 }, C);
  tl.to("#dindent",  { scale: 1, opacity: 0.42, duration: 0.35, ease: "expo.out" }, C + 0.02);
  tl.to("#dindent",  { keyframes: { opacity: [0.42, 0.30, 0.40, 0.31, 0.38, 0.33] },
                       duration: ${breDur.toFixed(2)}, ease: "none" }, C + 0.45);

  // ===== EXIT: page filed away — mechanical 3-frame yank-down cut =====
  tl.set("#dapex", { y: 10 }, X);
  tl.set("#dapex", { y: 28, opacity: 0.5 }, X + F);
  tl.set("#dapex", { opacity: 0 }, X + 2 * F);`;
  return { css, html, js };
}

function setpieceLasercage() {
  // LASER CAGE: concert blackout — the plate dims and haze cones breathe in
  // the top corners; a 6-beam laser array (alternating accent/magenta,
  // emitters along the top edge) snaps on and SWEEPS to converge on the word
  // center, a hot core pops at convergence, and the word IGNITES behind the
  // subject with an additive glow stack (wide blur + magenta fringe + tight
  // blur + core): crush → contact squash → elastic settle + 2 controlled
  // strobe pops. A criss-cross beam CAGE draws itself around the word rect
  // and flickers; during the hold the array FANS OUT slowly behind the word
  // (the iconic concert fan) while the word looms. Exit: the beams sweep away
  // L→R carrying the letters (streak + blur) and the room lifts back up.
  const I = heroIn;
  const X0 = +(heroOut - 0.38).toFixed(3); // beams-carry-the-letters exit
  const A = dna.palette.accent,
    MG = dna.palette.magenta || "#ff4df0";
  const CORE = dna.palette.core || "#eefff5",
    SOFT = dna.palette.glowSoft || "#c9ffe2";
  const fpx = HG.fontPx,
    halfW = Math.round(HG.halfW),
    cx = HG.x,
    cy = HG.y;
  // emitters along the top edge, initial aim points, fan-out floor targets
  // (demo geometry expressed as frame fractions)
  const EMX = [-0.023, 0.141, 0.352, 0.648, 0.859, 1.023].map((f) => Math.round(W * f));
  const EMY = [-20, -45, -55, -55, -45, -20];
  const STX = [0.164, 0.828, 0.313, 0.688, 0.406, 0.594].map((f) => Math.round(W * f));
  const FANX = [0.094, 0.258, 0.426, 0.574, 0.742, 0.906].map((f) => Math.round(W * f));
  const FANY = [0.75, 0.9, 0.97, 0.97, 0.9, 0.75].map((f) => Math.round(H * f));
  // beam-grid cage around the word rect (extents derive from halfW + fontPx)
  const CAGE = [
    [cx - (halfW + 90), cy + 0.76 * fpx, cx + (halfW + 90), cy - 0.68 * fpx, A, 0.5],
    [cx - (halfW + 90), cy - 0.68 * fpx, cx + (halfW + 90), cy + 0.76 * fpx, MG, 0.42],
    [cx - (halfW - 80), cy + 1.0 * fpx, cx + (halfW - 80), cy - 0.92 * fpx, A, 0.5],
    [cx - (halfW - 80), cy - 0.92 * fpx, cx + (halfW - 80), cy + 1.0 * fpx, MG, 0.42],
    [cx - (halfW + 40), cy - 0.74 * fpx, cx + (halfW + 40), cy - 0.74 * fpx, A, 0.45],
    [cx - (halfW + 40), cy + 0.82 * fpx, cx + (halfW + 40), cy + 0.82 * fpx, MG, 0.4],
  ].map((c) => c.map((v) => (typeof v === "number" ? Math.round(v) : v)));
  const spillW = 2 * halfW + 240,
    spillH = Math.round(fpx * 5.2);
  const hazeW = Math.round(W * 0.656),
    hazeH = H + 60;
  const blurBig = Math.round(0.22 * fpx),
    blurTight = Math.max(3, Math.round(0.07 * fpx)),
    fringeOff = Math.max(2, Math.round(0.03 * fpx));
  // hold-life choreography only with runway before the exit
  const strobeTs = [I + 0.5, I + 1.08].filter((t) => t < X0 - 0.25);
  const fanDur = +Math.max(0.4, Math.min(1.0, X0 - (I + 0.62 + 5 * 0.04) - 0.05)).toFixed(2);
  const flickDur = +Math.max(0.4, Math.min(1.2, X0 - 0.05 - (I + 0.6))).toFixed(2);
  const lstag = +Math.min(
    0.025,
    Math.max(0.008, (DUR - 0.25 - (X0 + 0.02)) / Math.max(1, heroText.length - 1)),
  ).toFixed(4);
  const css = `
  #dimC { position:absolute; inset:0; background:#000; opacity:0; }
  #hazeLc { position:absolute; left:-150px; top:-130px; width:${hazeW}px; height:${hazeH}px; opacity:0;
           background: radial-gradient(58% 95% at 12% 4%, ${A}80 0%, ${A}00 68%);
           filter: blur(10px); }
  #hazeRc { position:absolute; right:-150px; top:-130px; width:${hazeW}px; height:${hazeH}px; opacity:0;
           background: radial-gradient(58% 95% at 88% 4%, ${MG}6b 0%, ${MG}00 68%);
           filter: blur(10px); }
  #spillC { position:absolute; left:${cx}px; top:${cy}px; width:${spillW}px; height:${spillH}px; opacity:0;
           background: radial-gradient(50% 50% at 50% 50%, ${A}8c 0%, ${A}00 70%);
           mix-blend-mode:screen; }
  #lasersC { position:absolute; inset:0; }
  #hotC { position:absolute; left:${cx}px; top:${cy}px; width:130px; height:130px; opacity:0;
         background: radial-gradient(circle, rgba(235,255,244,0.95) 0%, ${A}99 42%, ${A}00 72%);
         filter: blur(4px); mix-blend-mode:screen; }
  #apexC { position:absolute; left:${cx}px; top:${cy}px; opacity:0; white-space:nowrap;
          font-family:'${dna.fonts.hero}', sans-serif; font-size:${fpx}px; line-height:1; letter-spacing:0.02em; }
  .clayer { position:absolute; left:0; top:0; }
  #cglow  { color:${A}; filter:blur(${blurBig}px); opacity:0; mix-blend-mode:screen; }
  #cglow2 { color:${SOFT}; filter:blur(${blurTight}px); opacity:0; mix-blend-mode:screen; }
  #cfringe{ color:${MG}; left:${fringeOff}px; top:${fringeOff}px; opacity:0; }
  #cword  { position:relative; color:${CORE};
            text-shadow: 0 0 16px ${A}d9, 0 0 44px ${A}80, 0 2px 8px rgba(0,0,0,0.55); }
  #cword .L { display:inline-block; }`;
  const html = `      <div id="dimC"></div>
      <div id="hazeLc"></div><div id="hazeRc"></div>
      <div id="spillC"></div>
      <svg id="lasersC" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">
        <g id="gB"></g><g id="gC"></g>
      </svg>
      <div id="hotC"></div>
      <div id="apexC">
        <div class="clayer" id="cglow">${esc(heroText)}</div>
        <div class="clayer" id="cfringe">${esc(heroText)}</div>
        <div class="clayer" id="cglow2">${esc(heroText)}</div>
        <div id="cword"></div>
      </div>`;
  const js = `
  // ---- setpiece: LASERCAGE (6-beam converge → ignite → cage → fan-out → carried exit) ----
  const I = ${I.toFixed(3)}, X0 = ${X0.toFixed(3)};
  const CACC = ${J(A)}, CMAG = ${J(MG)};
  const NSC = "http://www.w3.org/2000/svg";
  gsap.set("#apexC", { xPercent: -50, yPercent: -50, transformOrigin: "50% 50%" });
  gsap.set(["#spillC", "#hotC"], { xPercent: -50, yPercent: -50 });

  // apex word as letters (for the carried-away exit)
  const cw = document.getElementById("cword");
  const cletters = [];
  for (const ch of ${J(heroText)}) {
    const s = document.createElement("span");
    s.className = "L"; s.textContent = ch === " " ? "\\u00a0" : ch;
    cw.appendChild(s); cletters.push(s);
  }

  // ---- 6-beam array (emitters along the top edge, alternating accent/magenta)
  const gB = document.getElementById("gB"), gC = document.getElementById("gC");
  const EM = ${J(EMX.map((x, i) => [x, EMY[i]]))};
  const STARTX = ${J(STX)};
  const FAN = ${J(FANX.map((x, i) => [x, FANY[i]]))};
  function mkLnC(parent, x1, y1, x2, y2, col, w) {
    const l = document.createElementNS(NSC, "line");
    l.setAttribute("x1", x1); l.setAttribute("y1", y1);
    l.setAttribute("x2", x2); l.setAttribute("y2", y2);
    l.setAttribute("stroke", col); l.setAttribute("stroke-width", w);
    l.setAttribute("stroke-linecap", "round");
    l.style.opacity = 0; parent.appendChild(l); return l;
  }
  const cbeams = [];
  for (let i = 0; i < 6; i++) {
    const col = i % 2 ? CMAG : CACC;
    const halo = mkLnC(gB, EM[i][0], EM[i][1], STARTX[i], ${cy + 4}, col, 6);
    const core = mkLnC(gB, EM[i][0], EM[i][1], STARTX[i], ${cy + 4}, col, 1.6);
    core.style.filter = "drop-shadow(0 0 6px " + col + ")";
    cbeams.push([halo, core]);
  }

  // ---- beam-grid cage around the word rect
  const ccage = ${J(CAGE)}.map(([x1, y1, x2, y2, col, op]) => {
    const el = mkLnC(gC, x1, y1, x2, y2, col, 1.2);
    const len = Math.hypot(x2 - x1, y2 - y1);
    el.setAttribute("stroke-dasharray", len);
    el.setAttribute("stroke-dashoffset", len);
    return { el, len, op };
  });

  // ===== WORLD: concert blackout + haze cones
  tl.fromTo("#dimC", { opacity: 0 }, { opacity: ${dna.plate.dim}, duration: 0.35, ease: "power1.out" }, 0.03);
  tl.to(["#hazeLc","#hazeRc"], { opacity: 0.08, duration: 0.5, ease: "power1.out" }, 0.15);

  // ===== CHARGE: room deepens, 6 beams snap on and sweep to center
  tl.to("#dimC", { opacity: ${dna.plate.charge}, duration: 0.26, ease: "power2.in" }, I - 0.31);
  tl.to(["#hazeLc","#hazeRc"], { opacity: 0.10, duration: 0.28 }, I - 0.29);
  cbeams.forEach((els, i) => {
    const t0 = I - 0.26 + i * 0.022;
    tl.set(els[0], { opacity: 0.26 }, t0);
    tl.set(els[1], { opacity: 0.95 }, t0);
    tl.to(els, { attr: { x2: ${cx}, y2: ${cy} }, duration: 0.16, ease: "power2.inOut" }, t0);
  });
  tl.fromTo("#hotC", { opacity: 0, scale: 0.4 },
            { opacity: 0.6, scale: 1, duration: 0.1, ease: "power2.in" }, I - 0.11);
  tl.to("#hotC", { scale: 2.8, opacity: 0, duration: 0.2, ease: "expo.out" }, I);

  // ===== IGNITE: crush in → contact squash → elastic settle; additive glow stack
  tl.set("#apexC", { opacity: 1, scale: 1.16 }, I - 0.02);
  tl.to("#apexC",  { scale: 1, duration: 0.09, ease: "power4.in" }, I - 0.018);
  tl.set("#apexC", { scaleX: 1.09, scaleY: 0.92 }, I + 0.08);
  tl.to("#apexC",  { scaleX: 1, scaleY: 1, duration: 0.5, ease: "elastic.out(1, 0.38)" }, I + 0.165);
  tl.set("#cglow",  { opacity: 0.95 }, I);
  tl.set("#cglow2", { opacity: 0.60 }, I);
  tl.set("#cfringe",{ opacity: 0.30 }, I);
  tl.set("#cword",  { filter: "brightness(1.9)" }, I);
  tl.to("#cword",   { filter: "brightness(1)", duration: 0.3, ease: "power2.out" }, I + 0.08);
  tl.to("#cglow",   { opacity: 0.55, duration: 0.45, ease: "power2.out" }, I + 0.15);
  tl.to("#cglow2",  { opacity: 0.38, duration: 0.45, ease: "power2.out" }, I + 0.15);
  tl.set("#dimC", { opacity: ${(dna.plate.charge - 0.06).toFixed(2)} }, I + 0.05);
  tl.to("#dimC",  { opacity: ${(dna.plate.charge - 0.1).toFixed(2)}, duration: 0.8, ease: "power1.out" }, I + 0.2);
  tl.set("#spillC", { opacity: 0.22 }, I + 0.02);
  tl.to("#spillC",  { opacity: 0.12, duration: 0.2, ease: "power2.out" }, I + 0.25);

  // ===== CAGE draws around the word
  ccage.forEach((c, i) => {
    const t = I + 0.08 + i * 0.03;
    tl.set(c.el, { opacity: c.op }, t);
    tl.to(c.el, { attr: { "stroke-dashoffset": 0 }, duration: 0.18, ease: "power2.out" }, t);
  });
  tl.to(ccage.map((c) => c.el),
        { keyframes: { opacity: [0.48, 0.36, 0.44, 0.34, 0.42, 0.38] }, duration: ${flickDur}, ease: "none" },
        I + 0.6);

  // ===== controlled strobe pops (with the ignite flash: events spread > 1s)
  ${J(strobeTs.map((t) => +t.toFixed(3)))}.forEach((t) => {
    tl.set("#apexC", { filter: "brightness(2.05)" }, t);
    tl.set("#apexC", { filter: "brightness(1)" }, t + 0.05);
    tl.set("#spillC", { opacity: 0.27 }, t);
    tl.to("#spillC",  { opacity: 0.12, duration: 0.3, ease: "power2.out" }, t + 0.05);
  });

  // ===== FAN OUT: the iconic fan opens behind the word during the hold
  cbeams.forEach((els, i) => {
    const t = I + 0.62 + i * 0.04;
    tl.to(els, { attr: { x2: FAN[i][0], y2: FAN[i][1] }, duration: ${fanDur}, ease: "power1.inOut" }, t);
    tl.to(els[0], { opacity: 0.18, duration: 0.4 }, t);
    tl.to(els[1], { opacity: 0.50, duration: 0.4 }, t);
  });
  tl.to(["#hazeLc","#hazeRc"], { opacity: 0.16, duration: 0.6, ease: "power1.inOut" }, I + 0.35);

  // ===== HOLD LIFE: loom breathe + glow flicker
  tl.to("#apexC", { scale: 1.03, duration: 0.6, ease: "sine.inOut" }, I + 0.8);
  tl.to("#apexC", { scale: 1.0,  duration: 0.6, ease: "sine.inOut" }, I + 1.4);
  tl.to("#cglow", { keyframes: { opacity: [0.55, 0.42, 0.52, 0.40, 0.48, 0.45] },
                    duration: ${Math.min(1.15, Math.max(0.4, X0 - 0.06 - (I + 0.62))).toFixed(2)}, ease: "none" }, I + 0.62);

  // ===== EXIT: beams sweep away L→R carrying the letters
  ccage.forEach((c, i) => {
    tl.to(c.el, { attr: { "stroke-dashoffset": c.len }, duration: 0.16, ease: "power2.in" }, X0 + i * 0.02);
    tl.set(c.el, { opacity: 0 }, X0 + i * 0.02 + 0.17);
  });
  cletters.forEach((l, i) => {
    tl.to(l, { x: 44, opacity: 0, filter: "blur(6px)", duration: 0.16, ease: "power2.in" },
          X0 + 0.02 + i * ${lstag});
  });
  tl.to(["#cglow","#cglow2","#cfringe"], { x: 60, opacity: 0, duration: 0.22, ease: "power2.in" }, X0 + 0.06);
  cbeams.forEach((els, i) => {
    tl.to(els, { attr: { x2: "+=${Math.round(W * 0.742)}" }, duration: 0.28, ease: "power2.in" }, X0 + 0.02 + i * 0.03);
    tl.to(els, { opacity: 0, duration: 0.22 }, X0 + 0.04 + i * 0.03);
  });
  tl.to("#spillC", { opacity: 0, duration: 0.25 }, X0 - 0.01);
  tl.to("#dimC",   { opacity: 0.12, duration: 0.4, ease: "power1.in" }, X0 + 0.06);
  tl.to(["#hazeLc","#hazeRc"], { opacity: 0.03, duration: 0.4 }, X0 + 0.06);
  tl.set("#apexC", { display: "none" }, ${(DUR - 0.02).toFixed(3)});`;
  return { css, html, js };
}

function setpieceBoltstrike() {
  // THUNDER apex: the storm is the editor. A slate storm mood sits on the room
  // from t=0 and the sky "kisses" (1-frame brighten) on every body word —
  // double-kiss on emphasis words, re-flashes during the hold. Before the
  // strike the room charge-dims under a radial word scrim; a hand-authored
  // branched bolt (haze/glow/core stack) flashes 2 frames BEFORE the word
  // (leader → return stroke at contact) with a sky flash, then the word slams
  // in lit HARD (brightness 2.5 → 1) with its text-shadow flipped toward the
  // bolt for the flash frames; an ozone under-glow blooms and breathes through
  // the hold (loom + 2-frame lit blips on the sheet re-flashes); exit washes
  // downward with the rain.
  const p = dna.hero.params || {},
    I = heroIn;
  const A = dna.palette.accent, // ozone / bolt haze
    GL = dna.palette.glow || "#cfe8ff", // bolt glow
    CORE = dna.palette.core || "#ffffff", // bolt core
    HC = dna.palette.hero || dna.palette.body;
  const EX = theme.hero.exitAt ?? Math.min(heroOut - 0.2, I + (p.hold ?? 2.05));
  const hpx = HG.fontPx,
    halfW = HG.halfW || heroText.length * 0.28 * hpx;
  // word scrim sized from the word rect (demo 1100×330 around a 720×145 word)
  const scW = Math.round(2 * halfW + 380),
    scH = Math.round(hpx * 2.28);
  // hand-authored branched bolt (5-seg zigzag + 2 branches) measured off the
  // demo cut — offsets from the word center in halfW (x) / fontPx (y) units;
  // the leader always enters from above the frame
  const sgn = p.side === "right" ? -1 : 1;
  const kx = ((halfW || 360) / 360) * sgn,
    ky = hpx / 145;
  const pts = (arr, top) =>
    arr
      .map(([dx, dy], k) => {
        const x = HG.x + dx * kx;
        let y = HG.y + dy * ky;
        if (top && k === 0) y = Math.min(y, -25);
        return `${Math.round(x)},${Math.round(y)}`;
      })
      .join(" ");
  const MAINP = pts(
    [
      [-45, -282],
      [-107, -134],
      [-23, -56],
      [-113, 66],
      [-17, 150],
      [-85, 268],
    ],
    true,
  );
  const BAP = pts([
    [-107, -134],
    [-183, -56],
    [-157, 10],
  ]);
  const BBP = pts([
    [-113, 66],
    [-187, 140],
    [-163, 200],
  ]);
  const wS = (w) => (w * ky).toFixed(1);
  const boltSvg = (color, w, wb) =>
    `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">` +
    `<polyline points="${MAINP}" fill="none" stroke="${color}" stroke-width="${wS(w)}" stroke-linejoin="miter" stroke-linecap="round"/>` +
    `<polyline points="${BAP}" fill="none" stroke="${color}" stroke-width="${wS(wb)}" stroke-linecap="round"/>` +
    `<polyline points="${BBP}" fill="none" stroke="${color}" stroke-width="${wS(wb)}" stroke-linecap="round"/>` +
    `</svg>`;
  // the sky kisses on every BODY word (the hero word is consumed by the slam)
  const kissData = LINES.flatMap((L) =>
    L.words.map((w) => [+w.start.toFixed(3), w.minor ? 1 : 0]),
  ).filter(([t]) => t < DUR - 0.1);
  const css = `
  #mood { position:absolute; inset:0; background:rgba(38,62,96,${p.mood ?? 0.1}); }
  #dimP { position:absolute; inset:0; opacity:0; background:#0a1422; }
  #kiss { position:absolute; inset:0; opacity:0;
          background:linear-gradient(to bottom, rgba(222,236,255,0.95) 0%, rgba(222,236,255,0.40) 38%, rgba(222,236,255,0) 72%); }
  #wordscrim { position:absolute; left:${HG.x}px; top:${HG.y}px; width:${scW}px; height:${scH}px;
          margin-left:-${Math.round(scW / 2)}px; margin-top:-${Math.round(scH / 2)}px; opacity:0; border-radius:50%;
          background:radial-gradient(50% 50% at 50% 50%, rgba(5,10,20,0.85) 0%, rgba(5,10,20,0.45) 55%, rgba(5,10,20,0) 100%); }
  #apex { position:absolute; left:${HG.x}px; top:${HG.y}px; opacity:0; }
  .aw { position:absolute; left:0; top:0; transform:translate(-50%,-50%);
        font-family:'${dna.fonts.hero}', sans-serif; font-size:${hpx}px; line-height:1;
        letter-spacing:0.012em; white-space:nowrap; }
  #ozone { color:${A}; filter:blur(${Math.max(10, Math.round(26 * ky))}px); opacity:0; top:${Math.round(8 * ky)}px; }
  #aword { color:${HC};
           text-shadow: 0 8px 26px rgba(3,8,18,0.85), 0 2px 6px rgba(3,8,18,0.7), 0 0 18px ${A}59; }
  #bolt { position:absolute; inset:0; opacity:0; }
  .boltlayer { position:absolute; inset:0; }
  .boltlayer svg { position:absolute; inset:0; width:${W}px; height:${H}px; }
  #boltHaze { filter:blur(16px); opacity:0.55; }
  #boltGlow { filter:blur(5px); opacity:0.9; }
  #skyflash { position:absolute; inset:0; opacity:0;
          background:radial-gradient(120% 90% at 42% 22%, rgba(240,248,255,0.95) 0%, rgba(200,222,250,0.55) 45%, rgba(120,150,190,0) 100%); }`;
  const html = `      <div id="mood"></div>
      <div id="dimP"></div>
      <div id="kiss"></div>
      <div id="wordscrim"></div>
      <div id="apex">
        <div class="aw" id="ozone">${esc(heroText)}</div>
        <div class="aw" id="aword">${esc(heroText)}</div>
      </div>
      <div id="bolt">
        <div class="boltlayer" id="boltHaze">${boltSvg(A, 22, 13)}</div>
        <div class="boltlayer" id="boltGlow">${boltSvg(GL, 9, 5)}</div>
        <div class="boltlayer" id="boltCore">${boltSvg(CORE, 3.5, 2.2)}</div>
      </div>
      <div id="skyflash"></div>`;
  // breathe/loom clamp inside [contact, exit] for any clip length
  const breDur = Math.max(0.6, Math.min(1.5, EX - 0.23 - (I + 0.3)));
  const loomDur = Math.max(0.4, EX - 0.04 - (I + 0.74));
  const js = `
  // ---- setpiece: BOLTSTRIKE (storm charge → branched-bolt leader → word slams in lit) ----
  const I = ${I.toFixed(3)}, EXB = ${EX.toFixed(3)};

  // distant lightning KISS: 1-frame sky brighten on every body word
  const KISS = ${J(kissData)};
  KISS.forEach(([t, em]) => {
    tl.set("#kiss", { opacity: 0.06 }, t);
    tl.set("#kiss", { opacity: 0 }, t + F);
    if (em && t + 0.20 + 3 * F < ${(DUR - 0.04).toFixed(2)}) {
      // emphasis words get the sheet-lightning double kiss (matches the rail double flash)
      const t0 = t + 0.20;
      tl.set("#kiss", { opacity: 0.07 }, t0);
      tl.set("#kiss", { opacity: 0 },    t0 + F);
      tl.set("#kiss", { opacity: 0.06 }, t0 + 2 * F);
      tl.set("#kiss", { opacity: 0 },    t0 + 3 * F);
    }
  });
  // sheet re-flashes during the apex hold
  [I + 0.84, I + 1.44].forEach((t) => {
    if (t < EXB - 0.2) { tl.set("#kiss", { opacity: 0.08 }, t); tl.set("#kiss", { opacity: 0 }, t + F); }
  });

  // CHARGE: the storm gathers before the strike (ends before the bolt frames)
  tl.fromTo("#dimP", { opacity: 0 }, { opacity: ${dna.plate.charge || 0.3}, duration: 0.30, ease: "power2.in" }, I - 0.39);
  tl.fromTo("#wordscrim", { opacity: 0 }, { opacity: 0.42, duration: 0.30, ease: "power2.in" }, I - 0.39);

  // THE STRIKE: bolt flashes 2 frames before the word, return stroke at contact
  tl.set("#bolt", { opacity: 1 },    I - 2 * F);
  tl.set("#bolt", { opacity: 0.35 }, I - F);
  tl.set("#bolt", { opacity: 1 },    I);
  tl.to("#bolt",  { opacity: 0, duration: 0.07, ease: "power2.in" }, I + 0.045);

  tl.set("#skyflash", { opacity: 0.26 }, I - 2 * F);
  tl.set("#skyflash", { opacity: 0.10 }, I - F);
  tl.set("#skyflash", { opacity: 0.30 }, I + 0.02);
  tl.to("#skyflash",  { opacity: 0, duration: 0.22, ease: "expo.out" }, I + 0.06);

  // dim released by the flash, settles to a storm hold, lifts at the end
  tl.set("#dimP", { opacity: 0.08 }, I - 2 * F);
  tl.to("#dimP",  { opacity: ${dna.plate.dim || 0.22}, duration: 0.25, ease: "power2.out" }, I + 0.10);
  tl.to("#dimP",  { opacity: 0, duration: 0.40, ease: "power1.in" }, EXB - 0.14);
  tl.set("#wordscrim", { opacity: 0.32 }, I - 2 * F);
  tl.to("#wordscrim",  { opacity: 0.36, duration: 0.30 }, I + 0.10);
  tl.to("#wordscrim",  { opacity: 0, duration: 0.30 }, EXB - 0.04);

  // APEX WORD: slam transit → contact squash (2 frames) → elastic settle
  tl.set("#apex", { opacity: 1 }, I - 2 * F);
  tl.fromTo("#apex", { scale: 3.1 }, { scale: 1, duration: 0.105, ease: "power4.in" }, I - 2 * F);
  tl.fromTo("#aword", { filter: "blur(14px) brightness(3.2)" },
            { filter: "blur(0px) brightness(2.5)", duration: 0.105, ease: "power4.in" }, I - 2 * F);
  tl.set("#apex", { scaleX: 1.10, scaleY: 0.90 }, I + 0.025);
  tl.to("#apex",  { scaleX: 1, scaleY: 1, duration: 0.55, ease: "elastic.out(1.05, 0.34)" }, I + 0.025 + 2 * F);
  // lit HARD for 2 frames, then cools
  tl.to("#aword", { filter: "blur(0px) brightness(1)", duration: 0.45, ease: "power2.out" }, I + 0.025 + 2 * F);

  // SIGNATURE: shadow flips toward the bolt (light from the strike side) for the flash frames
  tl.set("#aword", { textShadow: "${16 * sgn}px 6px 18px rgba(2,6,14,0.9), ${4 * sgn}px 2px 6px rgba(2,6,14,0.75), 0 0 26px ${A}b3" }, I - 2 * F);
  tl.set("#aword", { textShadow: "0 8px 26px rgba(3,8,18,0.85), 0 2px 6px rgba(3,8,18,0.7), 0 0 18px ${A}59" }, I + 0.09);

  // ozone under-glow blooms with the hit, breathes during hold
  tl.set("#ozone", { opacity: 0.85 }, I + 0.02);
  tl.to("#ozone", { keyframes: { opacity: [0.85, 0.5, 0.65, 0.42, 0.55, 0.38] },
                    duration: ${breDur.toFixed(2)}, ease: "none" }, I + 0.30);
  tl.to("#ozone", { opacity: 0.32, duration: 0.18 }, EXB - 0.22);

  // hold life: loom + 2-frame lit blips on the sheet re-flashes
  tl.to("#apex", { scale: 1.035, duration: ${loomDur.toFixed(2)}, ease: "power1.inOut" }, I + 0.74);
  [[I + 0.84, 1.45], [I + 1.44, 1.35]].forEach(([t, b2]) => {
    if (t < EXB - 0.2) {
      tl.set("#aword", { filter: "brightness(" + b2 + ")" }, t);
      tl.set("#aword", { filter: "brightness(1)" }, t + F);
    }
  });

  // EXIT: washes downward with the rain
  tl.to("#apex", { y: 38, opacity: 0, filter: "blur(6px)", duration: 0.22, ease: "power2.in" }, EXB);
  tl.set("#apex", { display: "none" }, ${Math.min(EX + 0.26, DUR - 0.02).toFixed(3)});`;
  return { css, html, js };
}

function setpieceHoloboot() {
  // HOLOGRAM apex: full volumetric boot behind the subject. A desk emitter dot
  // pops, the projection cone SNAPS wide (scaleX expo), a seed beam fires up
  // from the emitter, and the word boots with a RISING clip sweep (opacity is
  // instant — the reveal is a wipe) into a 7-layer projection stack (dark
  // backing shadow / wide glow / magenta+cyan chromatic fringes / core /
  // scanline mask / climbing interference bands). Contact = over-bright flash
  // + squash held 2 frames + elastic settle + 1-frame x interference jumps;
  // fringes blow wide then settle to a constant registration offset. Hold
  // life: bands climb, rotationY oscillates, glow/cone breathe, emitter dot
  // pulses, slow loom; a hand "passes through" the projection (skew + blur +
  // fringe blowout, pure projection logic). Exit: the projector cut — the
  // word collapses to a horizontal line, then to the base dot. A second,
  // smaller emitter cone rises from the desk under the holorail plate all
  // video (the body rail is cast from the SAME projector grammar).
  const p = dna.hero.params || {},
    I = heroIn;
  const A = dna.palette.accent,
    MG = dna.palette.magenta || "#ff5fd6";
  const GLOW = dna.palette.glow || "#3eccf2",
    CORE = dna.palette.core || "#eafbff";
  const fpx = HG.fontPx,
    halfW = Math.round(HG.halfW || heroText.length * 0.4 * fpx),
    cx = HG.x,
    cy = HG.y;
  const k = fpx / 118; // demo scale unit (Orbitron 700 @ 118px)
  // apex rig geometry derives from the word rect
  const coneW = Math.min(2 * halfW + 80, W - 40);
  const coneTop = Math.round(cy - 0.55 * fpx);
  const emitY = Math.min(H - 30, Math.round(cy + 2.4 * fpx));
  const coneH = emitY - coneTop;
  const beamTop = coneTop + 40,
    beamH = emitY - beamTop;
  const cxP = ((cx / W) * 100).toFixed(1),
    cyP = ((cy / H) * 100).toFixed(1);
  // body-zone projector under the holorail plate (cone behind the subject)
  const plateBot = Math.round(H - (dna.body.bottomPx || 108) + (dna.body.plateH || 96) / 2);
  const bcW = p.coneW || 800,
    bcTop = plateBot - 4,
    bcH = H - bcTop - 4;
  // interference-band pitch scales with the type
  const bandH = Math.max(20, Math.round(fpx * 0.39));
  const bp = (f) => Math.round(bandH * f);
  // projector-cut exit + hold-life windows (all runway-clamped)
  const cutT = +Math.min(heroOut - 0.22, DUR - 0.3).toFixed(3);
  const C = I + 0.07; // contact
  const holdD = +Math.max(0.6, Math.min(1.72, cutT - 0.05 - (I + 0.24))).toFixed(2);
  const holdD2 = +Math.max(0.5, holdD - 0.1).toFixed(2);
  const reactD = +Math.max(0.3, Math.min(0.9, cutT - 0.3 - (I + 0.24))).toFixed(2);
  const loomD = +Math.min(0.62, Math.max(0.2, cutT - 0.3 - (I + 0.74))).toFixed(2);
  // the hand-pass needs the loom done (scale channel) and clear exit runway
  const passT = +Math.max(I + (p.passAt ?? 1.29), I + 1.25).toFixed(3);
  const doPass = passT + 0.65 < cutT - 0.05;
  const css = `
  #dimH { position:absolute; inset:0; opacity:0; background:#02101a; }
  #scrimH { position:absolute; inset:0; opacity:0;
           background: radial-gradient(58% 46% at ${cxP}% ${cyP}%, rgba(2,9,15,0.88) 0%, rgba(2,9,15,0.55) 48%, rgba(2,9,15,0) 78%); }
  #bcone { position:absolute; left:${Math.round(W / 2 - bcW / 2)}px; top:${bcTop}px; width:${bcW}px; height:${bcH}px; opacity:0;
           clip-path: polygon(0% 0%, 100% 0%, 51.6% 100%, 48.4% 100%);
           background: linear-gradient(to top, ${A}4d 0%, ${A}1a 55%, ${A}05 100%); }
  #bdot { position:absolute; left:${Math.round(W / 2 - 4.5)}px; top:${H - 10}px; width:9px; height:9px; border-radius:50%;
          background:${dna.palette.dot || "#bff0ff"}; opacity:0;
          box-shadow: 0 0 10px ${A}e6, 0 0 24px ${A}8c; }
  #apxcone { position:absolute; left:${Math.round(cx - coneW / 2)}px; top:${coneTop}px; width:${coneW}px; height:${coneH}px; opacity:0;
             clip-path: polygon(0% 0%, 100% 0%, 53% 100%, 47% 100%);
             background: linear-gradient(to top, ${A}57 0%, ${A}1f 50%, ${A}05 100%); }
  #apxbeam { position:absolute; left:${Math.round(cx - 2.5)}px; top:${beamTop}px; width:5px; height:${beamH}px; opacity:0;
             background: linear-gradient(to top, ${CORE}f2, ${CORE}1f);
             filter: blur(0.6px); }
  #apxdot { position:absolute; left:${Math.round(cx - 6.5)}px; top:${emitY - 6}px; width:13px; height:13px; border-radius:50%;
            background:${dna.palette.dotHot || "#dff8ff"}; opacity:0;
            box-shadow: 0 0 12px ${A}f2, 0 0 34px ${A}99; }
  #apx { position:absolute; left:${cx}px; top:${cy}px; opacity:0; }
  #apxclip { position:relative; }
  .aw { font-family:'${dna.fonts.hero}', sans-serif; font-weight:700; font-size:${fpx}px; line-height:1;
        letter-spacing:0; white-space:nowrap; }
  #adark { position:absolute; left:0; top:${Math.max(2, Math.round(3 * k))}px; color:rgba(1,12,20,0.92);
           filter:blur(${Math.max(4, Math.round(7 * k))}px); transform:scale(1.03); }
  #aglow { position:absolute; left:0; top:0; color:${GLOW}f2;
           filter:blur(${Math.max(8, Math.round(16 * k))}px); transform:scale(1.05); opacity:0.55; }
  #cmag  { position:absolute; left:0; top:0; color:${MG}8c; }
  #ccyn  { position:absolute; left:0; top:0; color:${A}99; }
  #acore { position:relative; color:${CORE};
           text-shadow: 0 0 22px ${A}73, 0 3px 14px rgba(1,10,16,0.55); }
  #ascan { position:absolute; left:0; top:0; color:transparent;
           -webkit-background-clip:text; background-clip:text;
           background-image: repeating-linear-gradient(180deg, rgba(0,0,0,0) 0px, rgba(0,0,0,0) 2.5px, rgba(2,12,18,0.62) 2.5px, rgba(2,12,18,0.62) 4px); }
  #abands { position:absolute; left:0; top:0; color:transparent; opacity:0;
            -webkit-background-clip:text; background-clip:text;
            background-image: repeating-linear-gradient(180deg, rgba(255,255,255,0) 0px, rgba(255,255,255,0) ${bp(0.717)}px, ${dna.palette.band || "#d8faff"}f2 ${bp(0.783)}px, ${dna.palette.band || "#d8faff"}f2 ${bp(0.87)}px, rgba(255,255,255,0) ${bp(0.935)}px, rgba(255,255,255,0) ${bandH}px);
            background-size: 100% ${bandH}px; }`;
  const html = `      <div id="dimH"></div>
      <div id="scrimH"></div>
      <div id="bcone"></div>
      <div id="bdot"></div>
      <div id="apxcone"></div>
      <div id="apxbeam"></div>
      <div id="apxdot"></div>
      <div id="apx">
        <div id="apxclip">
          <div class="aw" id="adark">${esc(heroText)}</div>
          <div class="aw" id="aglow">${esc(heroText)}</div>
          <div class="aw" id="cmag">${esc(heroText)}</div>
          <div class="aw" id="ccyn">${esc(heroText)}</div>
          <div class="aw" id="acore">${esc(heroText)}</div>
          <div class="aw" id="ascan">${esc(heroText)}</div>
          <div class="aw" id="abands">${esc(heroText)}</div>
        </div>
      </div>`;
  // body-projector flicker cycles, baked to the clip length
  const coneFlicks = [];
  for (let t = 0.6; t + 2.2 <= DUR - 0.25; t += 2.3) coneFlicks.push(+t.toFixed(2));
  const dotPulses = [];
  let dt = 0.5;
  for (; dt + 2.0 <= DUR - 0.3; dt += 2.15) dotPulses.push(+dt.toFixed(2));
  const dotTail = dt + 1.0 <= DUR - 0.24 ? +dt.toFixed(2) : null;
  const js = `
  // ---- setpiece: HOLOBOOT (emitter pop → cone snap → seed beam → rising-sweep word boot) ----
  const I = ${I.toFixed(3)}, CUT = ${cutT.toFixed(3)};
  gsap.set("#apx", { xPercent: -50, yPercent: -50, transformPerspective: 900, transformOrigin: "50% 50%" });
  gsap.set("#apxcone", { transformOrigin: "50% 100%" });
  gsap.set("#apxbeam", { transformOrigin: "50% 100%" });
  gsap.set("#bcone",   { transformOrigin: "50% 100%" });
  gsap.set("#apxclip", { clipPath: "inset(100% 0% 0% 0%)" });

  // ===== body-zone projector boots with the rail (cone behind the subject)
  tl.set("#bdot", { opacity: 1 }, 0.06);
  tl.fromTo("#bdot", { scale: 0.2 }, { scale: 1, duration: 0.12, ease: "back.out(2)" }, 0.06);
  tl.set("#bcone", { opacity: 0.9 }, 0.08);
  tl.fromTo("#bcone", { scaleY: 0.05 }, { scaleY: 1, duration: 0.14, ease: "expo.out" }, 0.08);
  tl.set("#bcone", { opacity: 0.5 }, 0.22);
  tl.set("#bcone", { opacity: 0.9 }, 0.26);
  ${J(coneFlicks)}.forEach((t, i) => {
    tl.to("#bcone", { keyframes: { opacity: i % 2 ? [0.86, 0.72, 0.84, 0.7, 0.84] : [0.9, 0.74, 0.86, 0.72, 0.86] },
                      duration: 2.2, ease: "none" }, t);
  });
  ${J(dotPulses)}.forEach((t) => {
    tl.to("#bdot", { keyframes: { scale: [1, 1.3, 1, 1.25, 1] }, duration: 2.0, ease: "none" }, t);
  });
  ${dotTail != null ? `tl.to("#bdot", { keyframes: { scale: [1, 1.25, 1] }, duration: 1.0, ease: "none" }, ${dotTail});` : ""}
  // projector cut at the very end
  tl.to("#bcone", { opacity: 0, duration: 0.18, ease: "power1.in" }, ${(DUR - 0.2).toFixed(3)});
  tl.set("#bdot", { scale: 1.6 }, ${(DUR - 0.12).toFixed(3)});
  tl.set("#bdot", { opacity: 0 }, ${(DUR - 0.05).toFixed(3)});

  // ===== scene reaction: the room dims while the volumetric apex burns
  tl.fromTo("#scrimH", { opacity: 0 }, { opacity: ${dna.plate.scrim ?? 0.58}, duration: 0.34, ease: "power2.in" }, I - 0.21);
  tl.set("#dimH", { opacity: ${dna.plate.charge ?? 0.28} }, I - 0.05);
  tl.to("#dimH",   { opacity: ${dna.plate.dim ?? 0.18}, duration: ${reactD}, ease: "power2.out" }, I + 0.24);
  tl.to("#scrimH", { opacity: ${((dna.plate.scrim ?? 0.58) * 0.79).toFixed(2)}, duration: ${reactD}, ease: "power2.out" }, I + 0.24);
  tl.to(["#dimH","#scrimH"], { opacity: 0, duration: 0.4, ease: "power1.in" }, CUT - 0.12);

  // ===== APEX: full volumetric boot
  // 1. emitter dot pops, cone SNAPS wide
  tl.set("#apxdot", { opacity: 1 }, I - 0.135);
  tl.fromTo("#apxdot", { scale: 0.2 }, { scale: 1, duration: 0.12, ease: "back.out(2.4)" }, I - 0.135);
  tl.set("#apxcone", { opacity: 1 }, I - 0.125);
  tl.fromTo("#apxcone", { scaleX: 0.07 }, { scaleX: 1, duration: 0.11, ease: "expo.out" }, I - 0.125);
  tl.set("#apxcone", { opacity: 0.55 }, I - 0.01);
  tl.set("#apxcone", { opacity: 0.95 }, I + 0.035);
  // 2. seed beam fires up from the emitter
  tl.set("#apxbeam", { opacity: 1 }, I - 0.095);
  tl.fromTo("#apxbeam", { scaleY: 0 }, { scaleY: 1, duration: 0.085, ease: "power3.in" }, I - 0.095);
  tl.to("#apxbeam", { opacity: 0, duration: 0.3, ease: "power1.in" }, I + 0.11);
  // 3. the word boots with a RISING sweep (opacity instant, reveal is a wipe)
  tl.set("#apx", { opacity: 1 }, I - 0.055);
  tl.to("#apxclip", { clipPath: "inset(0% 0% 0% 0%)", duration: 0.125, ease: "power2.in" }, I - 0.055);
  tl.set("#apxclip", { clipPath: "none" }, I + 0.08); // free the glow bloom after the sweep
  // 4. contact: over-bright flash + squash held 2 frames + elastic settle
  tl.set("#apxclip", { filter: "brightness(2.3)" }, ${C.toFixed(3)});
  tl.to("#apxclip",  { filter: "brightness(1)", duration: 0.36, ease: "power2.out" }, ${(C + 0.05).toFixed(3)});
  tl.set("#apx", { scaleX: 1.07, scaleY: 0.91 }, ${C.toFixed(3)});
  tl.to("#apx",  { scaleX: 1, scaleY: 1, duration: 0.5, ease: "elastic.out(1, 0.38)" }, ${(C + 0.084).toFixed(3)});
  // interference glitch on arrival (1-frame x jumps)
  tl.set("#apxclip", { x: 3 },  ${(C + 0.02).toFixed(3)});
  tl.set("#apxclip", { x: -3 }, ${(C + 0.062).toFixed(3)});
  tl.set("#apxclip", { x: 0 },  ${(C + 0.104).toFixed(3)});
  // chromatic fringes blow wide then settle to the constant offset
  tl.fromTo("#cmag", { x: -8 }, { x: -2, duration: 0.4, ease: "power2.out" }, ${C.toFixed(3)});
  tl.fromTo("#ccyn", { x: 8 },  { x: 2,  duration: 0.4, ease: "power2.out" }, ${C.toFixed(3)});

  // ===== hold life: interference bands climb, rotateY oscillates, glow breathes, loom
  tl.set("#abands", { opacity: 0.55, backgroundPosition: "0px 0px" }, I + 0.24);
  tl.to("#abands", { backgroundPosition: "0px -${6 * bandH}px", duration: ${holdD}, ease: "none" }, I + 0.24);
  tl.to("#apx", { keyframes: { rotationY: [0, 6, -7, 5, 0] }, duration: ${holdD}, ease: "none" }, I + 0.24);
  tl.to("#aglow", { keyframes: { opacity: [0.55, 0.4, 0.52, 0.38, 0.5] }, duration: ${holdD2}, ease: "none" }, I + 0.29);
  tl.to("#apxcone", { keyframes: { opacity: [0.95, 0.78, 0.9, 0.76, 0.88] }, duration: ${holdD2}, ease: "none" }, I + 0.29);
  tl.to("#apxdot", { keyframes: { scale: [1, 1.4, 1, 1.35, 1, 1.3, 1] }, duration: ${holdD2}, ease: "none" }, I + 0.29);
  ${loomD >= 0.2 ? `tl.to("#apx", { scale: 1.028, duration: ${loomD}, ease: "sine.inOut" }, I + 0.74);` : ""}
${
  doPass
    ? `
  // ===== a hand "passes through" the projection: pure projection logic
  const P = ${passT.toFixed(3)};
  tl.set("#apx", { skewX: 6 }, P);
  tl.set("#apxclip", { filter: "blur(2.5px) brightness(1.35)" }, P);
  tl.set("#cmag", { x: -7 }, P);
  tl.set("#ccyn", { x: 7 },  P);
  tl.set("#apx", { skewX: -4 }, P + 0.042);
  tl.set("#apxclip", { filter: "blur(1px)" }, P + 0.083);
  tl.set("#apx", { skewX: 0 }, P + 0.125);
  tl.set("#apxclip", { filter: "none" }, P + 0.125);
  tl.to("#cmag", { x: -2, duration: 0.18, ease: "power2.out" }, P + 0.05);
  tl.to("#ccyn", { x: 2,  duration: 0.18, ease: "power2.out" }, P + 0.05);
  tl.set("#apx", { scaleY: 0.97 }, P + 0.13);
  tl.to("#apx", { scaleY: 1, duration: 0.3, ease: "elastic.out(1, 0.4)" }, P + 0.172);`
    : ""
}

  // ===== EXIT: projector cut — collapse to a horizontal line, then to the base dot
  tl.set("#apxclip", { filter: "brightness(2.4)" }, CUT - 0.02);
  tl.to("#apx", { scaleY: 0.018, duration: 0.09, ease: "power3.in" }, CUT);
  tl.to("#apx", { scaleX: 0.015, duration: 0.075, ease: "power3.in" }, CUT + 0.10);
  tl.set("#apx", { opacity: 0 }, CUT + 0.18);
  tl.to("#apxcone", { opacity: 0, duration: 0.16, ease: "power1.in" }, CUT + 0.02);
  tl.set("#apxdot", { scale: 2.2 }, CUT + 0.12);
  tl.to("#apxdot", { opacity: 0, duration: 0.09, ease: "power2.in" }, CUT + 0.18);`;
  return { css, html, js };
}

function setpieceBiobloom() {
  // BIOLUME apex: a bioluminescent BLOOM behind the subject. The abyss grade
  // (params.mood) + a breathing vignette sit on the room from t=0 — the world
  // IS underwater before anything speaks. A creature pre-glow gathers with a
  // double-beat anticipation and crushes to a point; a dark pocket scrim
  // (plate.scrim) opens behind the word zone; the word CONDENSES in (scale
  // 2.35 crush power4.in 0.11s, blur 15→2, brightness 2.6→1.7) → 2-frame
  // contact squash → elastic settle while cyan light SPILLS onto the scene;
  // 6 luminous tendrils (blurred SVG strokes, cyan/violet alternating) draw
  // outward from the word rect, staggered; through the hold the glow breathes
  // 8–14%, two counter-drifting caustic gradients shimmer over the text zone
  // and the bloom drifts slowly with the current (params.drift). Exit: the
  // light SINKS — every glowing thing drifts down while dimming. Tendril
  // geometry is hand-authored from the demo and baked at compile time as
  // offsets from the word center (halfW/x × fontPx/120 units, frame-clamped).
  const p = dna.hero.params || {},
    I = heroIn;
  const A = dna.palette.accent,
    VIO = dna.palette.violet || "#9d7bff",
    ICE = dna.palette.ice || "#8fe8ff",
    CORE = dna.palette.core || "#f2fffd";
  const fpx = HG.fontPx,
    halfW = Math.round(HG.halfW || heroText.length * 0.33 * fpx),
    cx = HG.x,
    cy = HG.y;
  const ky = fpx / 120; // demo scale unit (Fredoka 700 @ 120px)
  const demoHalf = (wordPx("RECOVER", dna.fonts.hero, 120, 0.01) || 562) / 2;
  const kx = halfW / demoHalf;
  const TX = (x) => Math.round(Math.max(16, Math.min(W - 16, cx + (x - 560) * kx)));
  const TY = (y) => Math.round(Math.max(16, Math.min(H - 16, cy + (y - 300) * ky)));
  // 6 tendril cubics: anchors on the word rect edge, filaments reaching out
  const TENDRILS = [
    [
      [340, 268],
      [255, 230],
      [195, 185],
      [135, 110],
    ],
    [
      [525, 244],
      [500, 185],
      [560, 140],
      [520, 70],
    ],
    [
      [725, 262],
      [795, 222],
      [850, 180],
      [915, 112],
    ],
    [
      [365, 332],
      [280, 372],
      [215, 425],
      [150, 492],
    ],
    [
      [580, 352],
      [605, 415],
      [545, 465],
      [585, 530],
    ],
    [
      [715, 335],
      [790, 380],
      [855, 425],
      [920, 475],
    ],
  ].map((pts) => {
    const q = pts.map(([x, y]) => TX(x) + " " + TY(y));
    return `M ${q[0]} C ${q[1]}, ${q[2]}, ${q[3]}`;
  });
  const TCOL = [A, VIO, ICE, A, VIO, A];
  const C = +(I + (p.crush ?? 0.11)).toFixed(3); // contact
  const SINK = +Math.min(heroOut - 0.2, DUR - 0.3).toFixed(3);
  // tendril stagger auto-clamps so the last filament completes before the sink
  const stag = +Math.max(0.02, Math.min(0.05, (SINK - 0.46 - (C + 0.02)) / 5)).toFixed(3);
  const breatheD = +Math.min(0.72, Math.max(0.3, DUR - 0.06 - (C + 0.12))).toFixed(2);
  const flickD = +Math.min(0.7, SINK - 0.02 - (C + 0.34)).toFixed(2);
  const shimD = +Math.min(0.5, Math.max(0.2, SINK + 0.03 - (C + 0.21))).toFixed(2);
  const driftD = +Math.min(0.64, Math.max(0.2, SINK - (I + 0.21))).toFixed(2);
  const wordFlickT = I + 0.59 + 0.32 <= SINK + 0.05 ? +(I + 0.59).toFixed(3) : null;
  // geometry derived from the word rect
  const pkW = Math.round(2 * halfW + 280),
    pkH = Math.round(2.5 * fpx);
  const pgW = Math.round(1.083 * fpx),
    pgH = Math.round(0.717 * fpx);
  const cxP = ((cx / W) * 100).toFixed(1),
    cyP = ((cy / H) * 100).toFixed(1);
  const css = `
  #abyss { position:absolute; inset:0; opacity:${p.mood ?? 0.92};
           background:linear-gradient(180deg, rgba(3,11,26,0.52) 0%, rgba(3,11,26,0.30) 30%,
                       rgba(4,9,20,0.10) 52%, rgba(2,7,16,0.18) 78%, rgba(1,5,12,0.34) 100%); }
  #vig { position:absolute; inset:0; opacity:0.75;
         background:radial-gradient(120% 110% at 50% 46%, rgba(0,0,0,0) 36%,
                     rgba(2,8,20,0.45) 66%, rgba(1,4,12,0.95) 100%); }
  #pocket { position:absolute; left:${cx - Math.round(pkW / 2)}px; top:${cy - Math.round(pkH / 2)}px;
            width:${pkW}px; height:${pkH}px; opacity:0;
            background:radial-gradient(50% 50% at 50% 50%, rgba(1,8,18,0.72) 0%,
                        rgba(1,8,18,0.40) 55%, rgba(1,8,18,0) 78%); }
  #spill { position:absolute; inset:0; opacity:0;
           background:radial-gradient(58% 46% at ${cxP}% ${cyP}%, ${A}80 0%,
                       ${A}2b 42%, ${A}00 74%); }
  .cau { position:absolute; left:${Math.round(W * 0.0625)}px; top:${Math.round(H * 0.069)}px;
         width:${Math.round(W * 0.781)}px; height:${Math.round(H * 0.75)}px; opacity:0; }
  #cauA { background:radial-gradient(46% 42% at 42% 46%, ${ICE}d9 0%, ${ICE}00 68%); }
  #cauB { background:radial-gradient(44% 46% at 58% 52%, ${VIO}bf 0%, ${VIO}00 66%); }
  #preglow { position:absolute; left:${cx}px; top:${cy}px; width:${pgW}px; height:${pgH}px;
             margin-left:-${Math.round(pgW / 2)}px; margin-top:-${Math.round(pgH / 2)}px;
             border-radius:50%; opacity:0;
             background:radial-gradient(50% 50% at 50% 50%, rgba(170,255,242,0.95) 0%,
                         ${A}80 45%, ${A}00 75%);
             filter:blur(${Math.max(6, Math.round(10 * ky))}px); }
  #tndwrap { position:absolute; inset:0; }
  #tnd { position:absolute; inset:0; width:${W}px; height:${H}px; }
  #gGlow { filter:blur(5px); }
  #gCore { filter:blur(1.1px); }
  #tnd path { fill:none; stroke-linecap:round; opacity:0; }
  #apex { position:absolute; left:${cx}px; top:${cy}px; opacity:0; }
  .aw { position:absolute; left:0; top:0; white-space:nowrap;
        font-family:'${dna.fonts.hero}', sans-serif; font-weight:700; font-size:${fpx}px;
        line-height:1; letter-spacing:0.01em; }
  #aglow { color:${A}; filter:blur(${Math.max(10, Math.round(18 * ky))}px); opacity:0; }
  #aword { color:${CORE};
           text-shadow: 0 0 ${Math.round(18 * ky)}px ${A}f2, 0 0 ${Math.round(46 * ky)}px ${A}8c,
                        0 0 ${Math.round(100 * ky)}px ${VIO}80, 0 4px ${Math.round(22 * ky)}px rgba(1,8,16,0.7); }`;
  const html = `      <div id="abyss"></div>
      <div id="vig"></div>
      <div id="pocket"></div>
      <div id="spill"></div>
      <div class="cau" id="cauA"></div>
      <div class="cau" id="cauB"></div>
      <div id="preglow"></div>
      <div id="tndwrap">
        <svg id="tnd" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">
          <g id="gGlow"></g>
          <g id="gCore"></g>
        </svg>
      </div>
      <div id="apex">
        <div class="aw" id="aglow">${esc(heroText)}</div>
        <div class="aw" id="aword">${esc(heroText)}</div>
      </div>`;
  const js = `
  // ---- setpiece: BIOBLOOM (pre-glow gathers → word condenses → tendrils draw → light sinks) ----
  const BI = ${I.toFixed(3)}, BC = ${C.toFixed(3)}, SINK = ${SINK.toFixed(3)};
  gsap.set(["#aglow", "#aword"], { xPercent: -50, yPercent: -50 });
  gsap.set("#apex", { transformOrigin: "0px 0px" });
  gsap.set("#preglow", { scale: 0.6 });

  // ===== the abyss breathes (whole clip) =====
  tl.to("#vig", { keyframes: { opacity: [0.8, 0.72, 0.83, 0.74, 0.81, 0.76] },
                  duration: ${(DUR - 0.09).toFixed(2)}, ease: "sine.inOut" }, 0.03);

  // ===== light tendrils (SVG, drawn outward from the word rect) =====
  const TND = ${J(TENDRILS)};
  const TCOL = ${J(TCOL)};
  const NS = "http://www.w3.org/2000/svg";
  const gGlow = document.getElementById("gGlow");
  const gCore = document.getElementById("gCore");
  const tpaths = [];
  TND.forEach((d, i) => {
    const mk = (g, w) => {
      const pp = document.createElementNS(NS, "path");
      pp.setAttribute("d", d); pp.setAttribute("stroke", TCOL[i]);
      pp.setAttribute("stroke-width", w); g.appendChild(pp);
      const L = pp.getTotalLength();
      gsap.set(pp, { strokeDasharray: L, strokeDashoffset: L });
      return pp;
    };
    tpaths.push([mk(gGlow, ${Math.max(6, Math.round(9 * ky))}), mk(gCore, ${Math.max(2, Math.round(3 * ky))})]);
  });

  // ===== anticipation: a creature approaches — double-beat pre-glow =====
  tl.to("#preglow", { keyframes: { opacity: [0.34, 0.14, 0.46, 0.34],
                                   scale:   [0.9, 0.72, 1.08, 0.95] },
                      duration: 0.19, ease: "sine.inOut" }, ${Math.max(0.05, I - 0.31).toFixed(3)});
  tl.to("#preglow", { scale: 0.32, opacity: 0.95, duration: 0.1, ease: "power4.in" }, ${Math.max(0.17, I - 0.115).toFixed(3)});
  tl.set("#preglow", { opacity: 0 }, BI);
  tl.to("#pocket", { opacity: ${dna.plate.scrim ?? 0.55}, duration: 0.22, ease: "power2.out" }, BI - 0.03);

  // ===== bloom arrival: crush -> contact squash (2f) -> elastic settle =====
  tl.set("#apex", { opacity: 1 }, BI);
  tl.fromTo("#apex", { scale: 2.35 }, { scale: 1.0, duration: ${(C - I).toFixed(3)}, ease: "power4.in" }, BI);
  tl.fromTo("#aword", { filter: "blur(15px) brightness(2.6)" },
            { filter: "blur(2px) brightness(1.7)", duration: ${(C - I).toFixed(3)}, ease: "power4.in" }, BI);
  tl.set("#apex", { scaleX: 1.09, scaleY: 0.93 }, BC);
  tl.set("#aword", { filter: "blur(0px) brightness(1.85)" }, BC);
  tl.set("#aglow", { opacity: 0.9 }, BC);
  tl.set("#spill", { opacity: 0.55 }, BC);
  tl.to("#apex", { scaleX: 1, scaleY: 1, duration: 0.5, ease: "elastic.out(1, 0.42)" }, BC + 0.085);
  tl.to("#aword", { filter: "blur(0px) brightness(1.45)", duration: 0.34, ease: "power2.out" }, BC + 0.1);
  tl.to("#spill", { opacity: 0.2, duration: 0.12, ease: "expo.out" }, BC + 0.07);

  // ===== tendrils draw outward, staggered =====
  tpaths.forEach(([pg, pc], i) => {
    const t0 = BC + 0.02 + i * ${stag};
    tl.set(pg, { opacity: 0.5 }, t0);
    tl.set(pc, { opacity: 0.95 }, t0);
    tl.to([pg, pc], { strokeDashoffset: 0, duration: 0.42, ease: "power2.out" }, t0);
  });
${
  flickD >= 0.25
    ? `  tl.to("#gGlow", { keyframes: { opacity: [0.78, 1, 0.72, 1, 0.82] },
                    duration: ${flickD}, ease: "sine.inOut" }, BC + 0.34);`
    : ""
}

  // ===== hold: glow breathes strongly (8-14%), caustics counter-drift =====
  tl.to("#aglow", { keyframes: { scale: [1.1, 1.04, 1.13, 1.07],
                                 opacity: [0.78, 0.9, 0.7, 0.85] },
                    duration: ${breatheD}, ease: "sine.inOut" }, BC + 0.12);
${
  wordFlickT != null
    ? `  tl.to("#aword", { keyframes: { filter: ["blur(0px) brightness(1.6)", "blur(0px) brightness(1.4)",
                                          "blur(0px) brightness(1.62)", "blur(0px) brightness(1.5)"] },
                    duration: 0.32, ease: "sine.inOut" }, ${wordFlickT.toFixed(3)});`
    : ""
}
  tl.to("#cauA", { opacity: 0.06, duration: 0.3, ease: "power1.out" }, BC + 0.02);
  tl.to("#cauB", { opacity: 0.06, duration: 0.3, ease: "power1.out" }, BC + 0.07);
  tl.fromTo("#cauA", { x: -45 }, { x: 85, duration: ${Math.min(0.95, DUR - 0.06 - (C + 0.02)).toFixed(2)}, ease: "none" }, BC + 0.02);
  tl.fromTo("#cauB", { x: 55 }, { x: -75, duration: ${Math.min(0.95, DUR - 0.06 - (C + 0.02)).toFixed(2)}, ease: "none" }, BC + 0.02);
  tl.to("#spill", { keyframes: { opacity: [0.13, 0.19, 0.15] },
                    duration: ${shimD}, ease: "sine.inOut" }, BC + 0.21);
  // deep-sea current: the bloom drifts slowly, revealing letters from behind
  // the subject (x channel otherwise unused on #apex)
  tl.to("#apex", { x: ${p.drift ?? -30}, duration: ${driftD}, ease: "sine.out" }, BI + 0.21);

  // ===== exit: the light SINKS back into the deep =====
  tl.to("#tndwrap", { y: 14, opacity: 0, duration: 0.2, ease: "power2.in" }, SINK);
  tl.to(["#cauA", "#cauB"], { opacity: 0, duration: 0.18, ease: "power1.in" }, SINK + 0.04);
  tl.to(["#pocket", "#spill"], { opacity: 0, duration: 0.22, ease: "power1.in" }, SINK + 0.04);
  tl.to("#apex", { y: 26, opacity: 0, duration: 0.2, ease: "power2.in" }, SINK + 0.06);
  tl.to("#aword", { filter: "blur(6px) brightness(1.2)", duration: 0.2, ease: "power1.in" }, SINK + 0.06);
  tl.set("#apex", { display: "none" }, ${Math.min(SINK + 0.28, DUR - 0.02).toFixed(3)});`;
  return { css, html, js };
}

function setpieceSilkribbon() {
  // ---- setpiece: SILKRIBBON (aurora silk written into the night sky) ----
  // The hero word is WRITTEN behind the subject as a silk ribbon: sequential
  // Hershey-stroke reveal (one <path> per pen stroke, constant pen speed) in
  // a blurred halo + tight core, BOTH stroked by ONE userSpaceOnUse aurora
  // linearGradient (rose→teal→violet, reflect spread). SIGNATURE: the
  // gradient's x1/x2 translate f(t) from I−0.07 to the end so the aurora
  // colors FLOW THROUGH the drawn ribbon during the hold. A soft light mote
  // (blurred halo + core dot) rides the pen, hops at lifts, and blooms out
  // at the final stroke. Scene reaction: two huge aurora blobs drift the sky
  // f(t) from t=0 (~params.wash) and DEEPEN as the ribbon writes; a top
  // scrim (plate.scrim) charges in at I−0.34 (sky washout guard); a light
  // spill tints the sky once the ribbon completes, flickering gently; the
  // halo breathes through the hold. Gentle — no shake, no contact squash:
  // the apex verb is the writing itself.
  const h = dna.hero,
    p = h.params,
    I = heroIn;
  // stroke path baked at COMPILE time — any word, zero tuning (drawon pattern)
  const fontPath = path.join(SKILL, "assets/strokefonts", dna.fonts.strokeFont);
  const gen = path.join(SKILL, "scripts/gen-stroke-path.py");
  const tw = HG.ribbonW || Math.min(p.targetWidth || 880, W - 240);
  const svgW = tw + 130,
    svgH = 280;
  const D = execFileSync(
    "python3",
    [
      gen,
      fontPath,
      heroDisplay.toLowerCase(),
      String(tw),
      "200",
      String(Math.round((svgW - tw) / 2)),
    ],
    { encoding: "utf8" },
  ).trim();
  const ENDT = +(DUR - 0.04).toFixed(3);
  // writing window: keep the demo's 0.68s unless the hero lands too late to
  // finish + hold before the end of the clip
  const WIN = Math.min(p.window || 0.68, Math.max(0.36, ENDT - 0.42 - I));
  const ROSE = dna.palette.rose,
    TEAL = dna.palette.accent,
    VIO = dna.palette.violet,
    DEEP = dna.palette.deep || "#070C24";
  const scrim = (dna.plate || {}).scrim ?? 0.72;
  // aurora gradient span rides the ribbon ink (demo: 560 over 880, shift 1.5×span)
  const gx1 = Math.round((svgW - tw) / 2),
    span = Math.round(tw * 0.6364);
  const spillW = tw + 60,
    spillH = Math.round((tw + 60) * 0.51);
  const css = `
  .aur { position:absolute; border-radius:50%; opacity:0; will-change:transform; }
  #aurA { left:${Math.round(-0.141 * W)}px; top:${Math.round(-0.278 * H)}px; width:${Math.round(0.859 * W)}px; height:${Math.round(0.778 * H)}px;
          background: radial-gradient(50% 50% at 50% 50%, ${TEAL}d9 0%, ${TEAL}00 70%); }
  #aurB { left:${Math.round(0.406 * W)}px; top:${Math.round(-0.361 * H)}px; width:${Math.round(0.922 * W)}px; height:${Math.round(0.833 * H)}px;
          background: radial-gradient(50% 50% at 50% 50%, ${ROSE}cc 0%, ${ROSE}00 70%); }
  #apexScrim { position:absolute; left:0; top:0; width:${W}px; height:${Math.round(0.653 * H)}px; opacity:0;
               background: linear-gradient(to bottom, ${DEEP}${Math.round(scrim * 255)
                 .toString(16)
                 .padStart(2, "0")} 0%, ${DEEP}${Math.round(scrim * 0.694 * 255)
                 .toString(16)
                 .padStart(2, "0")} 55%, ${DEEP}00 100%); }
  #spill { position:absolute; left:${HG.x}px; top:${HG.y}px; width:${spillW}px; height:${spillH}px;
           margin-left:-${Math.round(spillW / 2)}px; margin-top:-${Math.round(spillH / 2)}px; opacity:0;
           background: radial-gradient(50% 50% at 50% 50%, ${TEAL}80 0%, ${VIO}47 45%, ${ROSE}00 75%); }
  #silk { position:absolute; left:${HG.x}px; top:${HG.y}px; margin-left:-${Math.round(svgW / 2)}px;
          margin-top:-${Math.round(svgH / 2)}px; overflow:visible; }
  #coreG { filter: drop-shadow(0 0 6px rgba(255,255,255,0.55)); }`;
  const html = `      <div class="aur" id="aurA"></div>
      <div class="aur" id="aurB"></div>
      <div id="apexScrim"></div>
      <div id="spill"></div>
      <svg id="silk" width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}">
        <defs>
          <linearGradient id="agrad" gradientUnits="userSpaceOnUse"
                          x1="${gx1}" y1="0" x2="${gx1 + span}" y2="0" spreadMethod="reflect">
            <stop offset="0"   stop-color="${ROSE}"/>
            <stop offset="0.5" stop-color="${TEAL}"/>
            <stop offset="1"   stop-color="${VIO}"/>
          </linearGradient>
          <filter id="haloblur" x="-60%" y="-60%" width="220%" height="220%">
            <feGaussianBlur stdDeviation="7"/>
          </filter>
        </defs>
        <g id="haloG" filter="url(#haloblur)" opacity="0.85"></g>
        <g id="coreG"></g>
        <circle id="nib" r="7" fill="#fff" opacity="0" filter="url(#haloblur)"/>
        <circle id="nibCore" r="3.5" fill="#fff" opacity="0"/>
      </svg>`;
  const js = `
  // ---- setpiece: SILKRIBBON (sequential silk-stroke reveal, flowing aurora gradient) ----
  const I9 = ${I.toFixed(3)}, WIN = ${WIN.toFixed(3)}, DRAWN = I9 + WIN, ENDT = ${ENDT};
  const D = ${J(D)};

  // aurora wash: 2 huge soft blobs drifting f(t) (baked keyframes, ease none)
  tl.to("#aurA", { opacity: ${p.wash ?? 0.12}, duration: 0.5, ease: "sine.out" }, 0.05);
  tl.to("#aurB", { opacity: ${p.wash ?? 0.12}, duration: 0.5, ease: "sine.out" }, 0.15);
  tl.to("#aurA", { keyframes: { x: [0, 34, 58, 44, 16, -8, 0], y: [0, 10, 22, 30, 22, 8, 0] },
                   duration: ENDT, ease: "none" }, 0);
  tl.to("#aurB", { keyframes: { x: [0, -28, -52, -60, -38, -12, 0], y: [0, 14, 8, -6, -16, -8, 0] },
                   duration: ENDT, ease: "none" }, 0);
  // sky reaction: the aurora deepens while the ribbon writes
  tl.to("#aurA", { opacity: ${p.deepen ?? 0.2}, duration: 0.5, ease: "sine.inOut" }, I9 - 0.1);
  tl.to("#aurB", { opacity: ${p.deepen ?? 0.2}, duration: 0.5, ease: "sine.inOut" }, I9 - 0.05);

  // apex scrim charges in before the ribbon (sky washout guard)
  tl.to("#apexScrim", { opacity: 1, duration: 0.38, ease: "sine.inOut" }, I9 - 0.34);

  // silk ribbon: sequential stroke draw-on, halo + core share the gradient
  const subs = D.split(/(?=M )/).map(s => s.trim()).filter(Boolean);
  const SVGNS = "http://www.w3.org/2000/svg";
  function mk(parent, d, w) {
    const el = document.createElementNS(SVGNS, "path");
    el.setAttribute("d", d); el.setAttribute("fill", "none");
    el.setAttribute("stroke", "url(#agrad)"); el.setAttribute("stroke-width", w);
    el.setAttribute("stroke-linecap", "round"); el.setAttribute("stroke-linejoin", "round");
    el.setAttribute("opacity", "0");  // hidden until its turn (kills round-cap dot)
    document.getElementById(parent).appendChild(el);
    return el;
  }
  const strokes = subs.map((d) => ({ halo: mk("haloG", d, ${p.haloWidth ?? 18}),
                                     core: mk("coreG", d, ${p.coreWidth ?? 6}) }));
  let total = 0;
  strokes.forEach((s) => { s.len = s.core.getTotalLength(); total += s.len; });
  let t9 = I9;
  strokes.forEach((s) => {
    const d = WIN * s.len / total;
    gsap.set([s.core, s.halo], { strokeDasharray: s.len, strokeDashoffset: s.len });
    tl.set([s.core, s.halo], { opacity: 1 }, t9);
    tl.fromTo([s.core, s.halo], { strokeDashoffset: s.len },
              { strokeDashoffset: 0, duration: d, ease: "none" }, t9);
    // nib (a soft light mote) rides each stroke, hops at pen lifts
    const n = Math.max(4, Math.round(s.len / 14)), xs = [], ys = [];
    for (let k = 0; k <= n; k++) {
      const pt = s.core.getPointAtLength(s.len * k / n);
      xs.push(pt.x); ys.push(pt.y);
    }
    tl.set(["#nib", "#nibCore"], { x: xs[0], y: ys[0] }, t9);
    tl.to("#nib", { keyframes: { x: xs, y: ys }, duration: d, ease: "none" }, t9);
    tl.to("#nibCore", { keyframes: { x: xs, y: ys }, duration: d, ease: "none" }, t9);
    t9 += d;
  });
  gsap.set(["#nib", "#nibCore"], { attr: { cx: 0, cy: 0 } });
  tl.set("#nib", { opacity: 0.85 }, I9 - 0.01);
  tl.set("#nibCore", { opacity: 1 }, I9 - 0.01);
  tl.to("#nib", { scale: 2.4, opacity: 0, duration: 0.22, ease: "sine.in",
                  transformOrigin: "50% 50%" }, DRAWN);
  tl.to("#nibCore", { scale: 2.4, opacity: 0, duration: 0.16, ease: "sine.in",
                      transformOrigin: "50% 50%" }, DRAWN);

  // SIGNATURE: the gradient FLOWS through the drawn ribbon — translate the
  // userSpaceOnUse gradient (x1/x2 together); reflect spread makes the aurora
  // colors march continuously along the silk during the hold
  tl.fromTo("#agrad", { attr: { x1: ${gx1}, x2: ${gx1 + span} } },
            { attr: { x1: ${gx1 - Math.round(1.5 * span)}, x2: ${gx1 + span - Math.round(1.5 * span)} },
              duration: ENDT - (I9 - 0.07), ease: "none" }, I9 - 0.07);

  // light spill tints the sky as the ribbon completes
  tl.to("#spill", { opacity: ${p.spill ?? 0.3}, duration: 0.7, ease: "sine.out" }, I9 + 0.1);
  if (ENDT - (I9 + 0.8) > 0.15) {
    const SP = ${p.spill ?? 0.3};
    tl.to("#spill", { keyframes: { opacity: [SP, SP - 0.05, SP + 0.01, SP - 0.04, SP] },
                      duration: ENDT - (I9 + 0.8), ease: "none" }, I9 + 0.8);
  }
  // hold life: the halo breathes gently (aurora shimmer)
  if (ENDT - DRAWN > 0.1)
    tl.to("#haloG", { keyframes: { opacity: [0.85, 0.7, 0.9, 0.72, 0.86] },
                      duration: ENDT - DRAWN, ease: "none" }, DRAWN);`;
  return { css, html, js };
}

function setpieceScopetrace() {
  // ---- setpiece: SCOPETRACE (the trace leaps off the scope and WRITES the word) ----
  // A surge column jumps from the scope band up to the pen-start point, then
  // the waveform WRITES the hero word in a thin phosphor stroke (sequential
  // Hershey stroke reveal — core only, no halo: it is a trace, not a tube).
  // The phosphor FILL flicker-arrives 2 frames + a 5-step blink AFTER the
  // write completes (scope persistence), hums + breathes 1.6% through the
  // hold, then the word COLLAPSES to a flatline (scaleY crush + horizontal
  // flash) clearing before the next spoken word. Scene reaction: phosphor
  // scrim + green spill behind the subject; a 1-frame green pulse answers the
  // final transcript word (the scope-death beat the paradigm owns up front).
  const h = dna.hero,
    p = h.params,
    I = heroIn;
  const fontPath = path.join(SKILL, "assets/strokefonts", dna.fonts.strokeFont);
  const gen = path.join(SKILL, "scripts/gen-stroke-path.py");
  const tw = HG.traceW;
  // strip punctuation for the pen (the demo wrote "pixel size", period dropped)
  const traceText = heroDisplay.toLowerCase().replace(/[^\p{L}\p{N} ']/gu, "");
  const svgW = tw + 140,
    svgH = 320,
    BY = 215, // local baseline; ink center ≈ BY − 28 (demo registration)
    CY = BY - 28;
  const D = execFileSync(
    "python3",
    [gen, fontPath, traceText, String(tw), String(BY), String(70)],
    { encoding: "utf8" },
  ).trim();
  // surge column lands at the pen-start point (first M of the path)
  const m0 = D.match(/M ([\d.]+) ([\d.]+)/);
  const surgeX = Math.round(HG.x - svgW / 2 + (m0 ? +m0[1] : 70));
  const WIN = p.window || 0.6;
  const FLICK = +(I + WIN + (p.flickGap ?? 0.12)).toFixed(3);
  // exit: collapse to flatline CLEAR of the next spoken word (the demo's
  // 3.33 before "It's" 3.545); falls back to the hero window end
  const nextT = tWords
    .filter((w) => w.start > hero.end + 0.01)
    .map((w) => w.start)
    .sort((a, b) => a - b)[0];
  let EXIT = nextT != null ? nextT - (p.clearGap ?? 0.215) : heroOut - 0.2;
  EXIT = +Math.max(FLICK + 0.45, Math.min(EXIT, heroOut)).toFixed(3);
  const A = dna.palette.accent,
    BRT = dna.palette.bright || "#d6ffe4";
  const scrim = dna.plate.scrim ?? 0.62;
  const ox = ((HG.x / W) * 100).toFixed(1),
    oy = ((HG.y / H) * 100).toFixed(1);
  const spillW = tw + 260,
    spillH = Math.round((tw + 260) * 0.47);
  const css = `
  #scdim { position:absolute; inset:0; opacity:0;
           background: radial-gradient(90% 75% at ${ox}% ${oy}%, rgba(1,14,7,${scrim}) 0%, rgba(1,10,5,${(scrim * 0.55).toFixed(2)}) 60%, rgba(1,8,4,${(scrim * 0.19).toFixed(2)}) 100%); }
  #scspill { position:absolute; left:${HG.x}px; top:${HG.y}px; width:${spillW}px; height:${spillH}px;
             margin-left:-${Math.round(spillW / 2)}px; margin-top:-${Math.round(spillH / 2)}px; border-radius:50%; opacity:0;
             background: radial-gradient(50% 50% at 50% 50%, ${A}33 0%, ${A}00 70%); }
  #scsurge { position:absolute; left:${surgeX}px; top:0; width:3px; height:${H}px; opacity:0;
             background: linear-gradient(180deg, ${A}00 0%, ${BRT}f2 45%, ${A}80 100%);
             box-shadow: 0 0 12px ${A}cc; }
  #scblk { position:absolute; inset:0; }
  #scsvg { position:absolute; left:${HG.x}px; top:${HG.y}px; margin-left:-${Math.round(svgW / 2)}px;
           margin-top:-${CY}px; overflow:visible; }
  #schaloG { filter: blur(7px); opacity:0.85; }
  #sccoreG { filter: drop-shadow(0 0 4px ${BRT}e6) drop-shadow(0 0 14px ${A}); }
  #scpen { filter: drop-shadow(0 0 7px #fff) drop-shadow(0 0 20px ${A}); }
  #scflat { position:absolute; left:${Math.round(HG.x - (tw + 36) / 2)}px; top:${HG.y - 2}px;
            width:${tw + 36}px; height:4px; opacity:0;
            background:${BRT}; box-shadow: 0 0 18px ${A}, 0 0 42px ${A}b3; }
  #scpulse { position:absolute; inset:0; opacity:0;
             background: radial-gradient(70% 60% at 50% 60%, ${A}38 0%, rgba(0,0,0,0) 70%); }`;
  const html = `      <div id="scdim"></div>
      <div id="scspill"></div>
      <div id="scsurge"></div>
      <div id="scblk">
        <svg id="scsvg" width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}">
          <g id="schaloG"></g>
          <g id="sccoreG"></g>
          <circle id="scpen" r="5" fill="#fff" opacity="0"/>
        </svg>
      </div>
      <div id="scflat"></div>
      <div id="scpulse"></div>`;
  const js = `
  // ---- setpiece: SCOPETRACE (surge → pen write → fill flicker → hum → flatline) ----
  const I = ${I.toFixed(3)}, WIN = ${WIN}, DRAWN = I + WIN, FLICK = ${FLICK.toFixed(3)}, EXIT = ${EXIT.toFixed(3)};
  const D = ${J(D)};

  // scene reaction: phosphor scrim + spill while the trace owns the room
  tl.fromTo("#scdim", { opacity: 0 }, { opacity: 1, duration: 0.22, ease: "power2.in" }, I - 0.18);
  tl.to("#scdim", { opacity: 0, duration: 0.30, ease: "power1.in" }, EXIT + 0.05);
  tl.fromTo("#scspill", { opacity: 0 }, { opacity: ${p.spill ?? 0.55}, duration: WIN, ease: "power1.in" }, I);
  tl.to("#scspill", { opacity: 0, duration: 0.18, ease: "power2.in" }, EXIT);

  // surge: the trace leaps up from the scope band to the pen-start point
  tl.fromTo("#scsurge", { opacity: 0, scaleY: 0, transformOrigin: "50% 100%" },
            { opacity: 1, scaleY: 1, duration: 0.10, ease: "power3.in" }, I - 0.12);
  tl.set("#scsurge", { opacity: 0 }, I + 0.04);

  // sequential stroke draw-on (one <path> per pen lift; core writes, halo waits)
  const subs = D.split(/(?=M )/).map(s => s.trim()).filter(Boolean);
  const SVGNS = "http://www.w3.org/2000/svg";
  function mkP(parent, d, stroke, w) {
    const el = document.createElementNS(SVGNS, "path");
    el.setAttribute("d", d); el.setAttribute("fill", "none");
    el.setAttribute("stroke", stroke); el.setAttribute("stroke-width", w);
    el.setAttribute("stroke-linecap", "round"); el.setAttribute("stroke-linejoin", "round");
    el.setAttribute("opacity", "0");
    document.getElementById(parent).appendChild(el);
    return el;
  }
  const strokes = subs.map((d) => ({
    halo: mkP("schaloG", d, ${J(A)}, ${p.haloWidth || 14}),
    core: mkP("sccoreG", d, ${J(BRT)}, ${p.coreWidth || 4}),
  }));
  let total = 0;
  strokes.forEach((s) => { s.len = s.core.getTotalLength(); total += s.len; });
  const scpen = document.getElementById("scpen");
  let t = I;
  strokes.forEach((s) => {
    const d = WIN * s.len / total;
    gsap.set([s.core, s.halo], { strokeDasharray: s.len, strokeDashoffset: s.len });
    gsap.set(s.halo, { opacity: 0 });           // halo arrives at FLICK, not during write
    tl.set(s.core, { opacity: 1 }, t);
    tl.fromTo(s.core, { strokeDashoffset: s.len },
              { strokeDashoffset: 0, duration: d, ease: "none" }, t);
    tl.fromTo(s.halo, { strokeDashoffset: s.len },
              { strokeDashoffset: 0, duration: d, ease: "none" }, t);
    const n = Math.max(4, Math.round(s.len / 14)), xs = [], ys = [];
    for (let k = 0; k <= n; k++) {
      const pt = s.core.getPointAtLength(s.len * k / n);
      xs.push(pt.x); ys.push(pt.y);
    }
    tl.set(scpen, { x: xs[0], y: ys[0] }, t);
    tl.to(scpen, { keyframes: { x: xs, y: ys }, duration: d, ease: "none" }, t);
    t += d;
  });
  gsap.set(scpen, { attr: { cx: 0, cy: 0 } });
  tl.set(scpen, { opacity: 1 }, I - 0.01);
  tl.to(scpen, { scale: 2.4, opacity: 0, duration: 0.12, ease: "power2.in",
                 transformOrigin: "50% 50%" }, DRAWN);

  // fill flicker-on (halo = phosphor "fill"; full within 2 frames)
  tl.set("#schaloG", { opacity: 0 }, 0);
  const haloPaths = strokes.map(s => s.halo);
  tl.set(haloPaths, { opacity: 1 }, FLICK - 2 * F);      // pre-arm strokes
  [[1, 0], [0.25, 1], [1, 2], [0.55, 3], [1, 4]].forEach(([o, k]) => {
    tl.set("#schaloG", { opacity: o }, FLICK + k * F);
  });
  tl.set("#sccoreG", { opacity: 0.55 }, FLICK + F);
  tl.set("#sccoreG", { opacity: 1 }, FLICK + 2 * F);

  // hold life: glow hum + 1.6% breathe (breathe ends exactly at the collapse)
  const humStart = FLICK + 5 * F, humDur = Math.max(0.2, EXIT - humStart);
  const NH = Math.max(6, Math.round(humDur / (2 * F)));
  const humVals = [];
  for (let k = 0; k <= NH; k++) {
    const tt = k / NH * humDur;
    humVals.push(0.80 + 0.10 * Math.sin(2 * Math.PI * 7 * tt) + 0.06 * Math.sin(2 * Math.PI * 11.3 * tt + 0.9));
  }
  tl.to("#schaloG", { keyframes: { opacity: humVals }, duration: humDur, ease: "none" }, humStart);
  tl.fromTo("#scblk", { scale: 1, transformOrigin: "${ox}% ${oy}%" },
            { scale: 1.016, duration: humDur, ease: "sine.inOut" }, humStart);

  // exit: trace collapses back to a flatline (the scope takes the word back)
  tl.to("#scblk", { scaleY: 0.02, duration: 0.14, ease: "power3.in",
                    transformOrigin: "${ox}% ${oy}%" }, EXIT);
  tl.set("#scblk", { opacity: 0 }, EXIT + 0.15);
  tl.set("#scflat", { opacity: 1 }, EXIT + 0.13);
  tl.to("#scflat", { opacity: 0, scaleX: 0.1, duration: 0.16, ease: "power2.in",
                     transformOrigin: "50% 50%" }, EXIT + 0.15);

  // second beat: 1-frame phosphor pulse behind the subject on the death word
  tl.set("#scpulse", { opacity: 1 }, ${Math.min(LASTWORD.start, DUR - 0.15).toFixed(3)});
  tl.set("#scpulse", { opacity: 0.4 }, ${Math.min(LASTWORD.start + F, DUR - 0.1).toFixed(3)});
  tl.set("#scpulse", { opacity: 0 }, ${Math.min(LASTWORD.start + 2 * F, DUR - 0.05).toFixed(3)});`;
  return { css, html, js };
}

function setpiecePapermat() {
  // STOP-MOTION PAPERCUT EMBED: a torn-edge kraft mat drops onto the scene
  // (steps(2) slap), then one torn cream chip per char DROPS with steps(3)
  // gravity + 1-frame overshoot, lands crooked (seeded ±3° ±4px), settles in
  // three 12fps poses and holds an 8fps wobble (baked set chain) until the
  // FINAL word bumps the table and the chips SCATTER off down-right
  // (disperse-on-last-word). Scene reaction: warm vignette charges with the
  // landing. All geometry derives from HG (papermat branch).
  const p = dna.hero.params || {},
    I = heroIn;
  const N = heroText.length;
  const STEP = p.step ?? 0.09;
  const tileW = HG.tileW,
    tileH = HG.tileH,
    fpx = HG.fontPx,
    pitch = HG.pitch,
    matW = HG.matW,
    matH = HG.matH;
  const matT = +Math.max(0.02, I - 0.212).toFixed(3);
  // scatter trigger: the final word bumps the table (hero.exitAt overrides);
  // the whole exit (chips 0.30 + mat sweep 0.26 at +0.21) lands inside DUR
  const scatT = +Math.min(
    Math.max(
      I + (N - 1) * STEP + 0.5,
      Math.min(
        theme.hero.exitAt ?? LASTWORD.start + 0.02,
        DUR - 0.48,
        DUR - 0.32 - (N - 1) * 0.045,
      ),
    ),
    DUR - 0.48,
  ).toFixed(3);
  const VIG = p.vig ?? 0.3;
  const dropM = -(HG.y + matH / 2 + 50); // mat fully offscreen above
  const dropL = -(HG.y + tileH / 2 + 100); // chips fully offscreen above
  // tableau breathe windows (camera push feel), clamped before the scatter
  const T1 = +(I + 0.83).toFixed(3);
  const D1 = +Math.min(2.2, scatT - 0.3 - T1).toFixed(2);
  const T2 = +(T1 + Math.max(0, D1)).toFixed(3);
  const D2 = +Math.min(1.9, scatT - 0.15 - T2).toFixed(2);
  const vigSettleT = +Math.min(matT + 0.79, scatT - 0.85).toFixed(3);
  const css = `
  #vigP { position:absolute; inset:0; opacity:0;
          background: radial-gradient(120% 105% at ${((HG.x / W) * 100).toFixed(0)}% ${((HG.y / H) * 100).toFixed(0)}%, rgba(0,0,0,0) 38%, rgba(26,16,5,0.85) 100%); }
  #apx  { position:absolute; left:${HG.x}px; top:${HG.y}px; width:0; height:0; }
  #mat  { position:absolute; left:${-Math.round(matW / 2)}px; top:${-Math.round(matH / 2)}px; width:${matW}px; height:${matH}px; }
  #mat .pap  { position:absolute; inset:0; background:linear-gradient(178deg,#bd9d6c 0%,#b08f5d 55%,#a17f4c 100%); }
  #mat .pap2 { position:absolute; inset:7px; background:rgba(140,110,62,0.35); }
  #matshad { position:absolute; left:${-Math.round(matW / 2) + 2}px; top:${Math.round(matH / 2) - 18}px; width:${matW + 6}px; height:30px; opacity:0;
             background: radial-gradient(50% 50% at 50% 50%, rgba(25,15,5,0.55) 0%, rgba(25,15,5,0) 72%);
             filter: blur(5px); }
  .lshad { position:absolute; width:${Math.round(tileW * 1.08)}px; height:24px; opacity:0;
           background: radial-gradient(50% 50% at 50% 50%, rgba(28,17,6,0.6) 0%, rgba(28,17,6,0) 70%);
           filter: blur(4px); }
  .lchip { position:absolute; width:${tileW}px; height:${tileH}px; }
  .lchip .pap  { position:absolute; inset:0; background:${dna.palette.paper || "#f8f0dd"}; }
  .lchip .pap2 { position:absolute; inset:5px; background:${dna.palette.paper2 || "#efe3c6"}; }
  .lchip .gl { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
               font-family:'${dna.fonts.hero}', sans-serif; font-size:${fpx}px; line-height:1; color:${dna.palette.ink || "#2b241b"}; }`;
  const html = `      <div id="vigP"></div>
      <div id="apx">
        <div id="matshad"></div>
        <div id="mat"><div class="pap"></div><div class="pap2"></div></div>
      </div>`;
  const js = `
  // ---- setpiece: PAPERMAT (mat slap → stepped-gravity chip drops → 8fps wobble → table-bump scatter) ----
  const I = ${I.toFixed(3)}, STEP = ${STEP}, SCAT = ${scatT};
  const prnd = mulberry32(${p.seed || 151515});
  const apx = document.getElementById("apx");
  // seeded torn-paper polygon (px coords on a w×h box)
  function torn(w, h, jag, sx, sy, rr){
    const pts = [], j = () => Math.min(jag, Math.max(0, jag*0.5 + (rr()-0.5)*jag));
    for (let i=0; i<=sx; i++) pts.push([(w/sx)*i, j()]);
    for (let i=1; i<=sy; i++) pts.push([w - j(), (h/sy)*i]);
    for (let i=sx-1; i>=0; i--) pts.push([(w/sx)*i, h - j()]);
    for (let i=sy-1; i>=1; i--) pts.push([j(), (h/sy)*i]);
    return "polygon(" + pts.map(q => q[0].toFixed(1)+"px "+q[1].toFixed(1)+"px").join(",") + ")";
  }
  document.querySelector("#mat .pap").style.clipPath  = torn(${matW}, ${matH}, 12, 12, 4, prnd);
  document.querySelector("#mat .pap2").style.clipPath = torn(${matW - 14}, ${matH - 14}, 10, 12, 4, prnd);

  // ===== MAT: the animator slaps the backdrop down first =====
  gsap.set("#mat", { transformOrigin: "50% 100%" });
  tl.fromTo("#mat", { y: ${dropM}, rotation: -5 },
            { y: 6, rotation: -1.2, duration: 0.14, ease: "steps(2)" }, ${matT});
  tl.set("#mat", { scaleY: 0.97, scaleX: 1.02 }, ${(matT + 0.14).toFixed(3)});
  tl.set("#mat", { y: 0, scaleY: 1, scaleX: 1 }, ${(matT + 0.223).toFixed(3)});
  tl.set("#matshad", { opacity: 0.32 }, ${(matT + 0.14).toFixed(3)});
  for (let t = ${(matT + 0.54).toFixed(3)}; t < ${(scatT - 0.08).toFixed(3)}; t += 0.25)
    tl.set("#mat", { x: (prnd()-0.5)*1.6, y: (prnd()-0.5)*1.6, rotation: -1.2 + (prnd()-0.5)*0.7 }, t);
  // mat leaves last, swept off down-right
  tl.to("#mat", { x: 430, y: ${H - 40}, rotation: 13, duration: 0.26, ease: "steps(3)" }, ${(scatT + 0.21).toFixed(3)});
  tl.set("#matshad", { opacity: 0 }, ${(scatT + 0.25).toFixed(3)});

  // ===== SCENE REACTION: warm vignette charges with the landing =====
  tl.fromTo("#vigP", { opacity: 0 }, { opacity: ${VIG}, duration: 0.24, ease: "power2.in" }, ${matT});
  tl.to("#vigP", { opacity: ${(VIG * 0.4).toFixed(2)}, duration: 0.7, ease: "power2.out" }, ${vigSettleT});
  tl.to("#vigP", { opacity: 0, duration: 0.35, ease: "power1.in" }, ${(scatT - 0.11).toFixed(3)});

  // ===== LETTERS: torn chips drop with stepped gravity =====
  const GLYPHS = ${J([...heroText])};
  GLYPHS.forEach((ch, i) => {
    if (ch === " ") return;
    const cx = (i - ${((N - 1) / 2).toFixed(1)}) * ${pitch} + (prnd()-0.5)*8;  // seeded ±4px misalignment
    const jy = (prnd()-0.5)*8;
    const r0 = (prnd()-0.5)*6;             // seeded ±3° crooked landing
    const C  = I + i * STEP;

    const shad = document.createElement("div");
    shad.className = "lshad";
    shad.style.left = (cx - ${Math.round((tileW * 1.08) / 2)}) + "px";
    shad.style.top  = (${Math.round(tileH / 2) + 3} + jy*0.3) + "px";
    apx.appendChild(shad);

    const chip = document.createElement("div");
    chip.className = "lchip";
    chip.style.left = (cx - ${Math.round(tileW / 2)}) + "px";
    chip.style.top  = (${-Math.round(tileH / 2)} + jy) + "px";
    const pap  = document.createElement("div"); pap.className  = "pap";
    const pap2 = document.createElement("div"); pap2.className = "pap2";
    const gl   = document.createElement("div"); gl.className   = "gl"; gl.textContent = ch;
    pap.style.clipPath  = torn(${tileW}, ${tileH}, 8, 6, 7, prnd);
    pap2.style.clipPath = torn(${tileW - 10}, ${tileH - 10}, 6, 6, 7, prnd);
    chip.appendChild(pap); chip.appendChild(pap2); chip.appendChild(gl);
    apx.appendChild(chip);
    gsap.set(chip, { transformOrigin: "50% 100%" });

    // anticipation shadow blob appears on the landing spot 3 frames early
    tl.set(shad, { opacity: 0.16, scaleX: 1.35 }, C - 0.125);
    // DROP: two airborne poses then contact — steps(3) gravity, 1-frame overshoot
    tl.fromTo(chip, { y: ${dropL}, rotation: r0 - 8 },
              { y: 8, rotation: r0, duration: 0.21, ease: "steps(3)" }, C - 0.21);
    tl.set(chip, { scaleX: 1.07, scaleY: 0.90 }, C);                  // contact squash
    tl.set(shad, { opacity: 0.50, scaleX: 1 }, C);                    // shadow grows on land
    tl.set(chip, { y: -4, scaleY: 1.05, scaleX: 0.97 }, C + 0.0833);  // 12fps settle pose 1
    tl.set(chip, { y: 2,  scaleY: 0.98, scaleX: 1.01 }, C + 0.1667);  // pose 2
    tl.set(chip, { y: 0,  scaleY: 1,    scaleX: 1    }, C + 0.25);    // rest
    tl.to(shad, { opacity: 0.34, duration: 0.3, ease: "power1.out" }, C + 0.30);

    // HOLD: 8fps wobble — baked sets every 3 frames (the stop-motion heartbeat)
    for (let t = C + 0.50; t < SCAT - 0.13; t += 0.125)
      tl.set(chip, { rotation: r0 + (prnd()-0.5)*1.4, x: (prnd()-0.5)*1.4, y: (prnd()-0.5)*1.4 }, t);

    // SCATTER on the final word — chips slide off-table down-right, steps(4)
    const S = SCAT + i*0.045;
    tl.to(chip, { x: 250 + prnd()*200, y: ${H - 110} + prnd()*110, rotation: r0 + 28 + prnd()*24,
                  duration: 0.30, ease: "steps(4)" }, S);
    tl.set(shad, { opacity: 0 }, S + 0.06);
  });
${
  D1 >= 0.4
    ? `
  // hold loom: the whole tableau breathes very slowly (camera push feel)
  tl.to("#apx", { scale: 1.016, duration: ${D1}, ease: "sine.inOut" }, ${T1});
${D2 >= 0.3 ? `  tl.to("#apx", { scale: 1.004, duration: ${D2}, ease: "sine.inOut" }, ${T2});` : ""}`
    : ""
}
  // table bump, 1 frame before the scatter begins
  tl.set("#apx", { y: 4 }, ${(scatT - 0.041).toFixed(3)});
  tl.set("#apx", { y: 0 }, ${(scatT + 0.001).toFixed(3)});`;
  return { css, html, js };
}

function setpieceCenterfold() {
  // POP-UP BOOK CENTERFOLD: blue + cream 12-point paper starbursts unfold on a
  // baseline hinge (scaleY 0.03→1 back.out, staggered), then the hero word
  // pops up in hinged clip-path segments (rotationX 85→0, the body motif at
  // full amplitude), kraft struts lean in, backdrop + word wobble-settle
  // TOGETHER (one wrapper owns rotationX), breathe through the hold, and the
  // whole centerfold folds flat at heroOut (the book closing). A dusk scrim
  // charges behind it (bright-sky insurance). Geometry derives from HG.
  const p = dna.hero.params || {},
    I = heroIn;
  const segs = p.segs || 3,
    stag = p.segStagger ?? 0.07;
  const u = HG.burstR / 220; // prop scale (demo burst R 220)
  const boxW = HG.boxW,
    boxH = HG.boxH;
  const cx = Math.round(boxW / 2),
    cy = Math.round(boxH / 2);
  const lineH = Math.round(HG.fontPx * 1.1667);
  const wordTop = Math.round(cy - 24 * u - lineH / 2); // word center 24u above box center
  const R1 = HG.burstR,
    r1 = Math.round(0.673 * R1),
    R2 = Math.round(0.9 * R1),
    r2 = Math.round(0.555 * R1);
  const strokeW = Math.max(2, Math.round(3 * u));
  // exit start: fold flat before the next sentence (demo 3.28 = heroOut−0.33),
  // never before the pop + wobble have read, always complete by DUR−0.08
  const XO = +Math.max(I + (segs - 1) * stag + 0.45, Math.min(heroOut - 0.33, DUR - 0.39)).toFixed(
    3,
  );
  const scrimT = +Math.max(0.02, I - 0.272).toFixed(3);
  const blueT = +Math.max(0.04, I - 0.272).toFixed(3);
  const wobT = +(I + 0.428).toFixed(3);
  const wobD = +Math.min(0.7, XO - 0.05 - (I + 0.428)).toFixed(2);
  const brT = +(I + 1.178).toFixed(3);
  const brD = +Math.min(1.7, XO - 0.15 - (I + 1.178)).toFixed(2);
  const segClips = Array.from({ length: segs }, (_, i) => {
    const lp = ((i * 100) / segs).toFixed(1),
      rp = (((segs - 1 - i) * 100) / segs).toFixed(1);
    return `  #cfsg${i} { clip-path: inset(0 ${rp}% 0 ${lp}%); }`;
  }).join("\n");
  const css = `
  #cfscrim { position:absolute; inset:0; opacity:0;
             background: radial-gradient(46% 54% at ${((HG.x / W) * 100).toFixed(0)}% ${(((HG.y + 24 * u) / H) * 100).toFixed(0)}%,
               rgba(16,24,42,0.62) 0%, rgba(16,24,42,0.34) 55%, rgba(16,24,42,0) 100%); }
  #cf { position:absolute; left:${HG.x}px; top:${Math.round(HG.y + 24 * u)}px; width:${boxW}px; height:${boxH}px; }
  #cfWob { position:absolute; inset:0; }
  .cfburst { position:absolute; inset:0; opacity:0; }
  #cfShadow { position:absolute; left:50%; top:${Math.round(cy + 172 * u)}px; width:${Math.round(380 * u)}px; height:${Math.round(30 * u)}px;
              margin-left:${-Math.round(190 * u)}px; opacity:0; filter:blur(6px); border-radius:50%;
              background: radial-gradient(closest-side, rgba(22,14,5,0.55), rgba(22,14,5,0)); }
  .cfstrut { position:absolute; width:${Math.round(36 * u)}px; height:${Math.round(30 * u)}px; opacity:0;
             background: linear-gradient(135deg,${dna.palette.kraft1 || "#a98f5e"} 0%, ${dna.palette.kraft2 || "#cdbb92"} 45%, ${dna.palette.kraft3 || "#e6d6af"} 100%);
             filter: drop-shadow(0 2px 2px rgba(40,26,10,0.35));
             clip-path: polygon(0 100%, 100% 100%, 100% 0); }
  #cfs1 { left:${Math.round(cx - 134 * u)}px; top:${Math.round(cy + 28 * u)}px; }
  #cfs2 { left:${Math.round(cx + 98 * u)}px; top:${Math.round(cy + 28 * u)}px; transform:scaleX(-1); }
  #cfword { position:absolute; left:0; right:0; top:${wordTop}px; height:${lineH}px; }
  .cfseg { position:absolute; inset:0; opacity:0; text-align:center;
           font-family:'${dna.fonts.hero}', sans-serif; font-weight:800; font-size:${HG.fontPx}px;
           line-height:${lineH}px; letter-spacing:0.02em; color:${dna.palette.ink || dna.palette.body};
           text-shadow: 0 3px 10px rgba(56,38,14,0.32); }
${segClips}`;
  const html = `      <div id="cfscrim"></div>
      <div id="cf">
        <div id="cfWob">
          <svg class="cfburst" id="cfbBlue" width="${boxW}" height="${boxH}" viewBox="0 0 ${boxW} ${boxH}">
            <polygon id="cfpB" fill="${dna.palette.accent}" stroke="${dna.palette.accentEdge || "#3f7fae"}" stroke-width="${strokeW}"/>
          </svg>
          <svg class="cfburst" id="cfbCream" width="${boxW}" height="${boxH}" viewBox="0 0 ${boxW} ${boxH}">
            <polygon id="cfpC" fill="${dna.palette.cream || "#f6ecd4"}" stroke="${dna.palette.creamEdge || "#8a6f45"}" stroke-width="${strokeW}"/>
          </svg>
          <div id="cfShadow"></div>
          <div class="cfstrut" id="cfs1"></div>
          <div class="cfstrut" id="cfs2"></div>
          <div id="cfword">
${Array.from({ length: segs }, (_, i) => `            <div class="cfseg" id="cfsg${i}"><span>${esc(heroText)}</span></div>`).join("\n")}
          </div>
        </div>
      </div>`;
  const js = `
  // ---- setpiece: CENTERFOLD (starburst unfold → hinged segment pop → wobble-settle → fold flat) ----
  const I = ${I.toFixed(3)}, XO = ${XO};
  // deterministic starburst geometry (paper sun)
  function starPts(cx, cy, n, R, r, rot) {
    const p = [];
    for (let i = 0; i < n * 2; i++) {
      const a = rot + Math.PI * i / n, rad = (i % 2 === 0) ? R : r;
      p.push((cx + Math.cos(a) * rad).toFixed(1) + "," + (cy + Math.sin(a) * rad).toFixed(1));
    }
    return p.join(" ");
  }
  document.getElementById("cfpB").setAttribute("points", starPts(${cx}, ${cy}, 12, ${R1}, ${r1}, -Math.PI/2 + 0.13));
  document.getElementById("cfpC").setAttribute("points", starPts(${cx}, ${cy}, 12, ${R2}, ${r2}, -Math.PI/2));

  gsap.set("#cf", { xPercent: -50, yPercent: -50 });
  gsap.set("#cfWob", { transformPerspective: 900, transformOrigin: "50% 82%" });
  gsap.set(".cfburst", { transformOrigin: "50% 90%", scaleY: 0.03 });
  gsap.set(".cfseg", { transformPerspective: 800, transformOrigin: "50% 100%", rotationX: 85 });
  gsap.set(".cfstrut", { transformOrigin: "50% 100%", scaleY: 0 });
  gsap.set("#cfShadow", { scaleX: 0.3 });

  // scrim charges before the unfold (bright sky insurance)
  tl.to("#cfscrim", { opacity: ${p.scrim ?? 0.42}, duration: 0.28, ease: "power2.out" }, ${scrimT});

  // backdrop unfolds FIRST — baseline hinge, paper overshoot
  tl.set("#cfbBlue", { opacity: 1 }, ${blueT});
  tl.to("#cfbBlue", { scaleY: 1, duration: 0.26, ease: "back.out(1.6)" }, ${blueT});
  tl.set("#cfbCream", { opacity: 1 }, ${(+blueT + 0.06).toFixed(3)});
  tl.to("#cfbCream", { scaleY: 1, duration: 0.26, ease: "back.out(1.6)" }, ${(+blueT + 0.06).toFixed(3)});
  tl.to("#cfShadow", { opacity: 0.45, scaleX: 1, duration: 0.30, ease: "power2.out" }, ${(+blueT + 0.03).toFixed(3)});

  // the word pops up in hinged segments (left→right), staggered
  for (let i = 0; i < ${segs}; i++) {
    const t0 = I + i * ${stag}, el = "#cfsg" + i;
    tl.set(el, { opacity: 1 }, t0);                                  // 1 within 2 frames
    tl.to(el, { rotationX: 0, duration: 0.26, ease: "back.out(1.4)" }, t0);
  }

  // visible paper struts lean in behind the word
  tl.set(".cfstrut", { opacity: 1 }, ${(I + 0.328).toFixed(3)});
  tl.to("#cfs1", { scaleY: 1, duration: 0.12, ease: "back.out(2)" }, ${(I + 0.328).toFixed(3)});
  tl.to("#cfs2", { scaleY: 1, duration: 0.12, ease: "back.out(2)" }, ${(I + 0.378).toFixed(3)});
${
  wobD >= 0.2
    ? `
  // backdrop + word wobble-settle TOGETHER (one wrapper owns it)
  tl.to("#cfWob", { keyframes: { rotationX: [0, -3.6, 2.4, -1.3, 0.6, 0] },
                    duration: ${wobD}, ease: "sine.inOut" }, ${wobT});`
    : ""
}
${
  brD >= 0.5
    ? `
  // hold life: slow paper breathing through the silence
  tl.to("#cfWob", { keyframes: { rotationX: [0, 1.5, -1.1, 1.3, 0] },
                    duration: ${brD}, ease: "sine.inOut" }, ${brT});
  tl.to("#cfShadow", { keyframes: { scaleX: [1, 1.05, 0.98, 1.04, 1] },
                       duration: ${brD}, ease: "sine.inOut" }, ${brT});`
    : ""
}

  // EXIT — the centerfold folds flat (book closing)
  for (let i = 0; i < ${segs}; i++) {
    const ft = XO + i * 0.05, el = "#cfsg" + i;
    tl.to(el, { rotationX: 88, duration: 0.14, ease: "power2.in" }, ft);
    tl.set(el, { opacity: 0 }, ft + 0.15);
  }
  tl.to(".cfstrut", { scaleY: 0, duration: 0.10, ease: "power2.in" }, XO + 0.02);
  tl.to("#cfbCream", { scaleY: 0.03, duration: 0.13, ease: "power2.in" }, XO + 0.12);
  tl.set("#cfbCream", { opacity: 0 }, XO + 0.26);
  tl.to("#cfbBlue", { scaleY: 0.03, duration: 0.12, ease: "power2.in" }, XO + 0.18);
  tl.set("#cfbBlue", { opacity: 0 }, XO + 0.31);
  tl.to("#cfShadow", { opacity: 0, scaleX: 0.3, duration: 0.16, ease: "power1.in" }, XO + 0.14);
  tl.to("#cfscrim", { opacity: 0, duration: 0.28, ease: "power1.in" }, XO + 0.02);`;
  return { css, html, js };
}

function setpieceChalkwrite() {
  // ---- setpiece: CHALKWRITE (the board grows and a hand WRITES the word) ----
  // The lower chalk band grows into a FULL deep-green board behind the subject
  // 0.3s before the apex (scaleY power3.out from the bottom edge, the room
  // dims with it); the hero word is HAND-WRITTEN in chalk — sequential Hershey
  // stroke reveal (drawon pattern), dry/rough via seeded ±1px jitter baked
  // onto every pen point, a blurred halo under a tight core, a chalk-stick
  // nib riding the pen — while dust specks fall from the nib. On completion a
  // hand-slap CLAP shakes the board 1 frame (the dust burst lives in the
  // rail, in FRONT of the subject: fx.chalkclap), then TWO violent
  // accelerating underlines slash beneath (nib rides them, dust kicks at the
  // ends), the chalk flicks away, and the halo breathes through the hold with
  // two slow motes drifting off the letters.
  const h = dna.hero,
    p = h.params,
    I = heroIn;
  const fontPath = path.join(SKILL, "assets/strokefonts", dna.fonts.strokeFont);
  const gen = path.join(SKILL, "scripts/gen-stroke-path.py");
  const tw = HG.chalkW;
  const x0 = Math.round(HG.x - tw / 2);
  let D = execFileSync(
    "python3",
    [gen, fontPath, heroDisplay.toLowerCase(), String(tw), "450", String(x0)],
    { encoding: "utf8" },
  ).trim();
  // register the measured ink box on HG (ink CENTER lands on HG.y), then the
  // board rect, underlines and the fg clap all derive from the same box
  const pts = [...D.matchAll(/[ML] ([-\d.]+) ([-\d.]+)/g)].map((m) => [+m[1], +m[2]]);
  const ys0 = pts.map((q) => q[1]);
  const dy = +(HG.y - (Math.min(...ys0) + Math.max(...ys0)) / 2).toFixed(1);
  D = D.replace(/([ML]) ([-\d.]+) ([-\d.]+)/g, (m, c, x, y) => `${c} ${x} ${(+y + dy).toFixed(1)}`);
  const inkTop = Math.min(...ys0) + dy,
    inkBot = Math.max(...ys0) + dy,
    inkH = inkBot - inkTop;
  const inkL = Math.min(...pts.map((q) => q[0])),
    inkR = Math.max(...pts.map((q) => q[0]));
  // board: full strip width, grows from the bottom edge up past the ink
  // (demo: ink top 320, board top 248 → gap 0.554 × ink height 130)
  const stripW = (dna.body.strip && dna.body.strip.w) || 1200;
  const SX = (dna.body.strip && dna.body.strip.x) ?? Math.round((W - stripW) / 2);
  const boardTop = Math.max(40, Math.round(inkTop - (p.boardGap ?? 0.554) * Math.max(inkH, 90)));
  const boardH = H - boardTop;
  const kw = stripW / 1200,
    kb = boardH / 472; // board furniture scales with the demo rect (1200×472)
  const ENDT = +(DUR - 0.04).toFixed(3);
  // writing window: keep the demo's 0.78s unless the hero lands too late to
  // finish + underline + hold before the end of the clip
  const WIN = Math.min(p.window || 0.78, Math.max(0.4, ENDT - 0.95 - I));
  const DRAWN = +(I + WIN).toFixed(3);
  // hand-off to the fg dust clap: the pen's final contact point, at write end
  HG.clapX = Math.round(pts[pts.length - 1][0]);
  HG.clapY = Math.round(pts[pts.length - 1][1] + dy);
  HG.clapT = DRAWN;
  const CHALK = dna.palette.chalk || dna.palette.accent || "#fbf9ef";
  const DUST = dna.palette.dust || "#f6f4e9";
  const css = `
  #cwDim { position:absolute; inset:0; background:${p.dimColor || "rgba(4,14,9,0.95)"}; opacity:0; }
  #cwVig { position:absolute; inset:0; opacity:0;
           background: radial-gradient(75% 70% at 50% 45%, rgba(0,0,0,0) 55%, rgba(2,10,6,0.55) 100%); }
  #cwUnit { position:absolute; inset:0; }
  #cwBoard { position:absolute; left:${SX}px; top:${boardTop}px; width:${stripW}px; height:${boardH}px;
             background: linear-gradient(180deg, ${dna.palette.boardHi || "#22392c"} 0%, ${dna.palette.board || "#1d3328"} 40%, ${dna.palette.boardLo || "#182b22"} 100%);
             border-radius:6px 6px 0 0;
             box-shadow: inset 0 0 90px rgba(0,0,0,0.55), 0 6px 24px rgba(0,0,0,0.45); }
  #cwRim { position:absolute; top:0; left:0; right:0; height:7px; opacity:0.85;
           border-radius:6px 6px 0 0;
           background: linear-gradient(180deg, ${dna.palette.rim || "#8a6a48"} 0%, ${dna.palette.rimLo || "#5f452c"} 100%); }
  #cwBoard .bsm { position:absolute; border-radius:50%; }
  #cwBsm1 { left:${Math.round(120 * kw)}px; top:${Math.round(90 * kb)}px; width:${Math.round(430 * kw)}px; height:${Math.round(150 * kb)}px; transform:rotate(-5deg);
            background: radial-gradient(50% 50% at 50% 50%, rgba(214,226,214,0.08) 0%, rgba(214,226,214,0) 70%); }
  #cwBsm2 { left:${Math.round(660 * kw)}px; top:${Math.round(230 * kb)}px; width:${Math.round(380 * kw)}px; height:${Math.round(130 * kb)}px; transform:rotate(4deg);
            background: radial-gradient(50% 50% at 50% 50%, rgba(214,226,214,0.07) 0%, rgba(214,226,214,0) 70%); }
  #cwSvg { position:absolute; left:0; top:0; overflow:visible; }
  #cwHaloG { opacity:${p.halo ?? 0.18}; }
  #cwCoreG { opacity:0.92; }`;
  const html = `      <div id="cwDim"></div>
      <div id="cwVig"></div>
      <div id="cwUnit">
        <div id="cwBoard">
          <div id="cwRim"></div>
          <div class="bsm" id="cwBsm1"></div>
          <div class="bsm" id="cwBsm2"></div>
        </div>
        <svg id="cwSvg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
          <defs>
            <filter id="chalkblur" x="-40%" y="-40%" width="180%" height="180%">
              <feGaussianBlur stdDeviation="3.2"/>
            </filter>
          </defs>
          <g id="cwHaloG" filter="url(#chalkblur)"></g>
          <g id="cwCoreG"></g>
          <g id="cwDustG"></g>
          <rect id="cwNib" x="-9" y="-3" width="18" height="6" rx="2.5" fill="#fffef6" opacity="0"/>
        </svg>
      </div>`;
  const js = `
  // ---- setpiece: CHALKWRITE (board grows; the hero word is hand-written in chalk) ----
  const I8 = ${I.toFixed(3)}, WIN8 = ${WIN.toFixed(3)}, DRAWN = ${DRAWN}, ENDT = ${ENDT};
  const D8 = ${J(D)};
  const crnd = mulberry32(${p.seed ?? 7});

  // scene reaction: vignette early; the room dims as the board grows
  tl.fromTo("#cwVig", { opacity: 0 }, { opacity: ${p.vig ?? 0.35}, duration: 0.35, ease: "power1.out" }, 0.10);
  tl.fromTo("#cwDim", { opacity: 0 }, { opacity: ${p.dim ?? 0.22}, duration: 0.30, ease: "power1.in" }, ${(I - 0.3).toFixed(3)});

  // the board grows taller 0.3s before the writing
  gsap.set("#cwBoard", { scaleY: 0, transformOrigin: "50% 100%" });
  tl.to("#cwBoard", { scaleY: 1, duration: 0.30, ease: "power3.out" }, ${(I - 0.3).toFixed(3)});

  // chalk strokes: dry/rough = baked ±1px y jitter on every pen point
  const jrnd = mulberry32(${p.jitterSeed ?? 21});
  const jD = D8.replace(/([ML]) ([-\\d.]+) ([-\\d.]+)/g,
    (m, c, x, y) => c + " " + x + " " + (parseFloat(y) + (jrnd()*2 - 1)).toFixed(1));
  const subs = jD.split(/(?=M )/).map(s => s.trim()).filter(Boolean);
  const SVGNS8 = "http://www.w3.org/2000/svg";
  function mk(parent, d, stroke, w) {
    const el = document.createElementNS(SVGNS8, "path");
    el.setAttribute("d", d); el.setAttribute("fill", "none");
    el.setAttribute("stroke", stroke); el.setAttribute("stroke-width", w);
    el.setAttribute("stroke-linecap", "round"); el.setAttribute("stroke-linejoin", "round");
    el.setAttribute("opacity", "0");
    document.getElementById(parent).appendChild(el);
    return el;
  }
  const strokes = subs.map((d) => ({
    halo: mk("cwHaloG", d, "#ffffff", ${p.haloWidth ?? 14}),
    core: mk("cwCoreG", d, ${J(CHALK)}, ${p.coreWidth ?? 7}),
  }));
  let total = 0;
  strokes.forEach((s) => { s.len = s.core.getTotalLength(); total += s.len; });
  const nib = document.getElementById("cwNib");
  gsap.set(nib, { rotation: -36, transformOrigin: "50% 50%" });
  let acc = 0;
  const cum = [];                                      // cumulative starts per stroke
  strokes.forEach((s) => { cum.push(acc); acc += s.len; });
  strokes.forEach((s, si) => {
    const d = WIN8 * s.len / total, st = I8 + WIN8 * cum[si] / total;
    gsap.set([s.core, s.halo], { strokeDasharray: s.len, strokeDashoffset: s.len });
    tl.set([s.core, s.halo], { opacity: 1 }, st);
    tl.fromTo([s.core, s.halo], { strokeDashoffset: s.len },
              { strokeDashoffset: 0, duration: d, ease: "none" }, st);
    const n = Math.max(4, Math.round(s.len / 14)), xs = [], ys = [];
    for (let k = 0; k <= n; k++) {
      const pt = s.core.getPointAtLength(s.len * k / n);
      xs.push(pt.x); ys.push(pt.y);
    }
    tl.set(nib, { x: xs[0], y: ys[0] }, st);
    tl.to(nib, { keyframes: { x: xs, y: ys }, duration: d, ease: "none" }, st);
  });
  tl.set(nib, { opacity: 1 }, I8 - 0.01);

  // ${p.dustN ?? 8} dust specks fall from the nib along the path
  function speck(parent, x, y, r, fill) {
    const c = document.createElementNS(SVGNS8, "circle");
    c.setAttribute("cx", x); c.setAttribute("cy", y); c.setAttribute("r", r);
    c.setAttribute("fill", fill); c.setAttribute("opacity", "0");
    document.getElementById(parent).appendChild(c);
    return c;
  }
  for (let k = 0; k < ${p.dustN ?? 8}; k++) {
    const f = (k + 0.6) / ${p.dustN ?? 8}, target = f * total;
    let si = 0; while (si < strokes.length - 1 && cum[si + 1] <= target) si++;
    const pt = strokes[si].core.getPointAtLength(target - cum[si]);
    const c = speck("cwDustG", pt.x, pt.y + 4, 1.6 + crnd() * 1.4, ${J(DUST)});
    const ts = I8 + f * WIN8;
    tl.set(c, { opacity: 0.9 }, ts);
    tl.to(c, { y: 26 + crnd() * 20, x: crnd() * 14 - 7, duration: 0.5, ease: "power1.in" }, ts);
    tl.to(c, { opacity: 0, duration: 0.42, ease: "power1.in" }, ts + 0.08);
  }

  // hand-slap CLAP: 1-frame board shake (dust burst lives in the rail, in front)
  tl.set("#cwUnit", { y: 3  }, DRAWN);
  tl.set("#cwUnit", { y: -2 }, DRAWN + F);
  tl.set("#cwUnit", { y: 1  }, DRAWN + 2*F);
  tl.set("#cwUnit", { y: 0  }, DRAWN + 3*F);

  // two violent underlines (fast accelerating slashes), seeded waviness
  const UL = [
    { x1: ${Math.round(inkL + 14)}, x2: ${Math.round(inkR - 4)},  y: ${Math.round(inkBot + 30)}, n: 4, t: DRAWN + 0.09, dur: 0.10 },
    { x1: ${Math.round(inkL + 58)}, x2: ${Math.round(inkR - 44)}, y: ${Math.round(inkBot + 50)}, n: 3, t: DRAWN + 0.23, dur: 0.09 },
  ];
  UL.forEach((u) => {
    if (u.t + u.dur > ENDT - 0.1) return;
    let ud = "M " + u.x1 + " " + (u.y + (crnd()*9 - 4.5)).toFixed(1);
    for (let k = 1; k <= u.n; k++)
      ud += " L " + (u.x1 + (u.x2 - u.x1) * k / u.n).toFixed(1) + " " + (u.y + (crnd()*9 - 4.5)).toFixed(1);
    const halo = mk("cwHaloG", ud, "#ffffff", ${p.haloWidth ?? 14});
    const core = mk("cwCoreG", ud, ${J(CHALK)}, ${(p.coreWidth ?? 7) + 1});
    const len = core.getTotalLength();
    gsap.set([core, halo], { strokeDasharray: len, strokeDashoffset: len });
    tl.set([core, halo], { opacity: 1 }, u.t);
    tl.fromTo([core, halo], { strokeDashoffset: len },
              { strokeDashoffset: 0, duration: u.dur, ease: "power3.in" }, u.t);
    // nib rides the slash
    const n = 8, xs = [], ys = [];
    for (let k = 0; k <= n; k++) { const pt = core.getPointAtLength(len * k / n); xs.push(pt.x); ys.push(pt.y); }
    tl.set(nib, { x: xs[0], y: ys[0] }, u.t);
    tl.to(nib, { keyframes: { x: xs, y: ys }, duration: u.dur, ease: "none" }, u.t);
    // dust kicked up where the slash ends
    for (let k = 0; k < 3; k++) {
      const c = speck("cwDustG", xs[n] - 4 + crnd() * 8, ys[n], 1.4 + crnd(), ${J(DUST)});
      tl.set(c, { opacity: 0.85 }, u.t + u.dur);
      tl.to(c, { y: 16 + crnd() * 14, x: crnd() * 18 - 6, opacity: 0, duration: 0.38, ease: "power1.in" }, u.t + u.dur);
    }
  });
  // chalk flicks away after the second underline
  tl.to(nib, { x: "+=30", y: "+=22", scale: 2, opacity: 0, duration: 0.14, ease: "power2.in" },
        Math.min(DRAWN + 0.35, ENDT - 0.15));

  // hold life: halo breathe (baked f(t), deterministic)
  const humStart = Math.min(DRAWN + 0.46, ENDT - 0.3), humDur = ENDT - humStart;
  const N8 = Math.max(6, Math.round(humDur / (2 * F)));
  const humVals = [];
  for (let k = 0; k <= N8; k++) {
    const tt = k / N8 * humDur;
    humVals.push(${p.halo ?? 0.18} + 0.05 * Math.sin(2 * Math.PI * 1.6 * tt) + 0.02 * Math.sin(2 * Math.PI * 3.7 * tt + 0.8));
  }
  tl.to("#cwHaloG", { keyframes: { opacity: humVals }, duration: humDur, ease: "none" }, humStart);
  // two slow motes drifting off the letters during the hold
  [[humStart + 0.10, ${Math.round(inkL + 0.43 * (inkR - inkL))}], [humStart + 0.50, ${Math.round(inkL + 0.71 * (inkR - inkL))}]].forEach(([ts, mx]) => {
    if (ts + 0.5 > ENDT) return;
    const c = speck("cwDustG", mx + crnd() * 30, ${Math.round((inkTop + inkBot) / 2 + 42)}, 1.5 + crnd(), ${J(DUST)});
    tl.set(c, { opacity: 0.6 }, ts);
    tl.to(c, { y: 30, x: crnd() * 10 - 5, opacity: 0, duration: 0.5, ease: "power1.in" }, ts);
  });`;
  return { css, html, js };
}

function setpieceSpraytag() {
  // ---- setpiece: SPRAYTAG (a writer steps up and TAGS the hero word) ----
  // The dusk wall dims + a radial scrim charges as the writer steps up; the
  // can RATTLES at the start point (seeded set-chain nib jitter), then the
  // hero word is spray-written stroke by stroke — sequential Hershey dash
  // reveal (drawon pattern) — as a TRIPLE stack per stroke: fat dark
  // under-stroke (sky-contrast armor) + blurred overspray halo + wet paint
  // core, the glowing nib riding each stroke. Seeded overspray dots pop near
  // the nib as it passes; the nib throws off at the finish (the magenta splat
  // lives in the rail, in FRONT of the subject: fx.paintsplat). Through the
  // hold, seeded DRIPS grow from painted low points (signature) and the halo
  // hums while the whole tag slowly looms.
  const h = dna.hero,
    p = h.params,
    I = heroIn;
  const fontPath = path.join(SKILL, "assets/strokefonts", dna.fonts.strokeFont);
  const gen = path.join(SKILL, "scripts/gen-stroke-path.py");
  const tw = HG.sprayW;
  const x0 = Math.round(HG.x - tw / 2);
  let D = execFileSync(
    "python3",
    [gen, fontPath, heroDisplay.toLowerCase(), String(tw), "450", String(x0)],
    { encoding: "utf8" },
  ).trim();
  // register the measured ink box on HG (ink CENTER lands on HG.y); the loom
  // origin and the fg paint splat derive from the same box
  const pts = [...D.matchAll(/[ML] ([-\d.]+) ([-\d.]+)/g)].map((m) => [+m[1], +m[2]]);
  const ys0 = pts.map((q) => q[1]);
  const dy = +(HG.y - (Math.min(...ys0) + Math.max(...ys0)) / 2).toFixed(1);
  D = D.replace(/([ML]) ([-\d.]+) ([-\d.]+)/g, (m, c, x, y) => `${c} ${x} ${(+y + dy).toFixed(1)}`);
  const ENDT = +(DUR - 0.04).toFixed(3);
  // writing window: keep the demo's 0.58s unless the hero lands too late to
  // finish + splat + drip before the end of the clip
  const WIN = Math.min(p.window || 0.58, Math.max(0.35, ENDT - 0.55 - I));
  const DRAWN = +(I + WIN).toFixed(3);
  // hand-off to the fg paint splat: the pen-finish point, at write end
  HG.splatX = Math.round(pts[pts.length - 1][0]);
  HG.splatY = Math.round(pts[pts.length - 1][1] + dy);
  HG.splatT = DRAWN;
  const PAINT = dna.palette.accent || "#ffd23f";
  const UNDER = dna.palette.under || "#241008";
  const NIB = dna.palette.nib || "#fff6d9";
  const css = `
  #stDim   { position:absolute; inset:0; opacity:0; background:${p.dimColor || "#0a0608"}; }
  #stScrim { position:absolute; inset:0; opacity:0;
             background: radial-gradient(92% 78% at 50% 36%, rgba(0,0,0,0) 28%, rgba(10,4,12,0.78) 100%); }
  #stWrap { position:absolute; inset:0; }
  #stSvg  { position:absolute; left:0; top:0; overflow:visible; }
  #stCoreG { filter: drop-shadow(0 3px 7px rgba(0,0,0,0.55)); }
  #stNib { filter: blur(0.8px) drop-shadow(0 0 9px ${PAINT}e6); }
  .stdrip { position:absolute; height:0; opacity:0;
            background: linear-gradient(180deg, ${PAINT} 0%, ${dna.palette.dripMid || "#eab928"} 78%, ${dna.palette.dripLo || "#d8a51d"} 100%);
            border-radius: 0 0 4px 4px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.35); }`;
  const html = `      <div id="stDim"></div><div id="stScrim"></div>
      <div id="stWrap">
        <svg id="stSvg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
          <defs>
            <filter id="sprayblur" x="-40%" y="-40%" width="180%" height="180%">
              <feGaussianBlur stdDeviation="7"/>
            </filter>
          </defs>
          <g id="stUnderG" opacity="0.9"></g>
          <g id="stHaloG" filter="url(#sprayblur)" opacity="${p.haloOpacity ?? 0.5}"></g>
          <g id="stCoreG"></g>
          <g id="stOsprayG"></g>
          <circle id="stNib" r="9" fill="${NIB}" opacity="0"/>
        </svg>
      </div>`;
  const js = `
  // ---- setpiece: SPRAYTAG (rattle → sequential spray write → drips + hum) ----
  const Ig = ${I.toFixed(3)}, WINg = ${WIN.toFixed(3)}, DRAWNg = ${DRAWN}, ENDTg = ${ENDT};
  const Dg = ${J(D)};
  const grnd = mulberry32(${p.seed ?? 140987});

  // scene reaction: the wall darkens as the writer steps up (charge)
  tl.fromTo("#stDim",   { opacity: 0 }, { opacity: ${p.dim ?? 0.22}, duration: 0.40, ease: "power2.in" }, ${(I - 0.42).toFixed(3)});
  tl.fromTo("#stScrim", { opacity: 0 }, { opacity: ${p.scrim ?? 0.62}, duration: 0.40, ease: "power2.in" }, ${(I - 0.42).toFixed(3)});

  // per-stroke TRIPLE stack (split at pen lifts) — sequential dash reveal
  const subsG = Dg.split(/(?=M )/).map(s => s.trim()).filter(Boolean);
  const SVGNSG = "http://www.w3.org/2000/svg";
  function mkG(parent, d, stroke, w) {
    const el = document.createElementNS(SVGNSG, "path");
    el.setAttribute("d", d); el.setAttribute("fill", "none");
    el.setAttribute("stroke", stroke); el.setAttribute("stroke-width", w);
    el.setAttribute("stroke-linecap", "round"); el.setAttribute("stroke-linejoin", "round");
    el.setAttribute("opacity", "0");
    document.getElementById(parent).appendChild(el);
    return el;
  }
  const strokesG = subsG.map((d) => ({
    under: mkG("stUnderG", d, ${J(UNDER)}, ${p.underWidth ?? 21}),  // dark edge — sky-contrast armor
    halo:  mkG("stHaloG",  d, ${J(PAINT)}, ${p.haloWidth ?? 36}),   // fat soft overspray spread
    core:  mkG("stCoreG",  d, ${J(PAINT)}, ${p.coreWidth ?? 13}),   // wet paint core
  }));
  let totalG = 0;
  strokesG.forEach((s) => { s.len = s.core.getTotalLength(); totalG += s.len; });

  // helper: point at a global arc-length along the whole word
  function pointAtTotal(L) {
    let acc = 0;
    for (const s of strokesG) {
      if (L <= acc + s.len) return s.core.getPointAtLength(L - acc);
      acc += s.len;
    }
    return strokesG[strokesG.length - 1].core.getPointAtLength(strokesG[strokesG.length - 1].len);
  }

  // ANTICIPATION: the can RATTLES at the start point before painting
  const nibG = document.getElementById("stNib");
  gsap.set(nibG, { attr: { cx: 0, cy: 0 } });
  const p0g = pointAtTotal(0);
  tl.set(nibG, { opacity: 1, x: p0g.x, y: p0g.y }, Ig - 0.30);
  for (let k = 0; k < 6; k++) {                      // set-chain rattle (sanctioned jitter)
    tl.set(nibG, { x: p0g.x + (grnd() - 0.5) * 10, y: p0g.y + (grnd() - 0.5) * 7 }, Ig - 0.28 + k * 2 * F);
  }
  tl.set(nibG, { x: p0g.x, y: p0g.y }, Ig - 0.02);

  // SEQUENTIAL spray write: constant pen speed, nib rides each stroke
  let tg = Ig;
  strokesG.forEach((s) => {
    const d = WINg * s.len / totalG;
    gsap.set([s.under, s.halo, s.core], { strokeDasharray: s.len, strokeDashoffset: s.len });
    tl.set([s.under, s.halo, s.core], { opacity: 1 }, tg);
    tl.fromTo([s.under, s.halo, s.core], { strokeDashoffset: s.len },
              { strokeDashoffset: 0, duration: d, ease: "none" }, tg);
    const n = Math.max(4, Math.round(s.len / 14)), xs = [], ys = [];
    for (let k = 0; k <= n; k++) {
      const pt = s.core.getPointAtLength(s.len * k / n);
      xs.push(pt.x); ys.push(pt.y);
    }
    tl.set(nibG, { x: xs[0], y: ys[0] }, tg);
    tl.to(nibG, { keyframes: { x: xs, y: ys }, duration: d, ease: "none" }, tg);
    tg += d;
  });
  // nib throw-off at the finish (the splat ring itself lives on the rail layer)
  tl.to(nibG, { scale: 2.6, opacity: 0, duration: 0.12, ease: "power2.in",
                transformOrigin: "50% 50%" }, DRAWNg);

  // OVERSPRAY: seeded dots popping near the nib as it passes
  for (let i = 0; i < ${p.osprayN ?? 6}; i++) {
    const c = document.createElementNS(SVGNSG, "circle");
    c.setAttribute("r", (2 + grnd() * 2.4).toFixed(1));
    c.setAttribute("fill", ${J(PAINT)}); c.setAttribute("opacity", "0");
    document.getElementById("stOsprayG").appendChild(c);
    const f = 0.08 + 0.84 * grnd();
    const ti = Ig + WINg * f;
    const pt = pointAtTotal(totalG * f);
    const ox = (grnd() < 0.5 ? -1 : 1) * (9 + grnd() * 18);
    const oy = (grnd() < 0.5 ? -1 : 1) * (7 + grnd() * 14);
    tl.set(c, { opacity: 0.85 }, ti);
    tl.fromTo(c, { attr: { cx: pt.x + ox, cy: pt.y + oy } },
              { attr: { cx: pt.x + ox * 1.9, cy: pt.y + oy * 1.9 },
                duration: 0.30, ease: "power2.out" }, ti);
    tl.to(c, { opacity: 0, duration: 0.26, ease: "power1.in" }, ti + 0.05);
  }

  // DRIPS (signature): seeded drips grow from painted low points and KEEP
  // growing through the hold
  const samplesG = [];
  { let acc = 0;
    strokesG.forEach((s) => {
      const n = Math.max(6, Math.round(s.len / 10));
      for (let k = 0; k <= n; k++) {
        const L = s.len * k / n;
        const pt = s.core.getPointAtLength(L);
        samplesG.push({ x: pt.x, y: pt.y, L: acc + L });
      }
      acc += s.len;
    });
  }
  const maxYg = Math.max(...samplesG.map(q => q.y));
  const lowsG = samplesG.filter(q => q.y > maxYg - 8).sort((a, b) => a.x - b.x);
  const pickedG = [];
  for (let tries = 0; tries < 60 && pickedG.length < ${p.drips ?? 3}; tries++) {
    const cand = lowsG[Math.floor(grnd() * lowsG.length)];
    if (cand && pickedG.every(q => Math.abs(q.x - cand.x) > 110)) pickedG.push(cand);
  }
  const stageG = document.getElementById("stage");
  pickedG.forEach((q) => {
    const el = document.createElement("div");
    el.className = "stdrip"; stageG.appendChild(el);
    const w = 6 + Math.round(grnd() * 3);
    gsap.set(el, { left: q.x - w / 2, top: q.y - 4, width: w });
    const paintT = Ig + WINg * (q.L / totalG);
    const start = Math.min(paintT + 0.22 + grnd() * 0.22, ENDTg - 0.3);
    const dur = Math.min(1.25, ENDTg - start);
    const hh = (36 + grnd() * 36) * Math.max(0.55, dur / 1.25);
    tl.set(el, { opacity: 1 }, start);
    tl.to(el, { height: hh, duration: dur, ease: "power1.in" }, start);
  });

  // HOLD LIFE: halo hum (baked f(t), deterministic) + slow loom on the whole tag
  const humStartG = DRAWNg + 0.06, humDurG = Math.max(0.2, ENDTg - DRAWNg - 0.06);
  const NG = Math.max(6, Math.round(humDurG / (2 * F)));
  const humValsG = [];
  for (let k = 0; k <= NG; k++) {
    const tt = k / NG * humDurG;
    humValsG.push(${p.haloOpacity ?? 0.5} + 0.07 * Math.sin(2 * Math.PI * 7 * tt) + 0.05 * Math.sin(2 * Math.PI * 11 * tt + 0.9));
  }
  tl.to("#stHaloG", { keyframes: { opacity: humValsG }, duration: humDurG, ease: "none" }, humStartG);
  gsap.set("#stWrap", { transformOrigin: "${HG.x}px ${HG.y}px" });
  tl.to("#stWrap", { scale: 1.014, duration: 0.5, ease: "power1.inOut" }, DRAWNg + 0.08);`;
  return { css, html, js };
}

function setpieceBrushwrite() {
  // ---- setpiece: BRUSHWRITE (sumi-e — ONE violent calligraphic gesture) ----
  // A warm paper wash charges behind the word; the brush SLAPS down at the
  // first pen point (start-splat: irregular ink blob + seeded satellites —
  // the fg fleck pass rides fx.brushflecks), then the hero word is written in
  // ONE sequential gesture — Hershey dash reveal at constant pen speed — as a
  // FOUR-layer stack per stroke (blurred bleed under fat/mid/core widths =
  // pressure-tapered wet brush), the round ink nib riding the pen and
  // flicking off at the finish. Spatter droplets eject perpendicular at the
  // sharpest-curvature points of the gesture. Through the hold the ink
  // BLEEDS into the paper (the blurred under-layer swells visible) and the
  // whole work breathes; then the vermilion SEAL chop stamps beside the ink
  // (power4 crush → 2-frame squash → elastic settle, red flecks kicked) —
  // the plate punch lands on the chop contact.
  const h = dna.hero,
    p = h.params,
    I = heroIn;
  const fontPath = path.join(SKILL, "assets/strokefonts", dna.fonts.strokeFont);
  const gen = path.join(SKILL, "scripts/gen-stroke-path.py");
  const tw = HG.brushW;
  const x0 = Math.round(HG.x - tw / 2);
  let D = execFileSync(
    "python3",
    [gen, fontPath, heroDisplay.toLowerCase(), String(tw), "450", String(x0)],
    { encoding: "utf8" },
  ).trim();
  // register the measured ink box on HG (ink CENTER lands on HG.y); the wash,
  // breathe origin, seal chop and the fg flecks all derive from the same box
  const pts = [...D.matchAll(/[ML] ([-\d.]+) ([-\d.]+)/g)].map((m) => [+m[1], +m[2]]);
  const ys0 = pts.map((q) => q[1]);
  const dy = +(HG.y - (Math.min(...ys0) + Math.max(...ys0)) / 2).toFixed(1);
  D = D.replace(/([ML]) ([-\d.]+) ([-\d.]+)/g, (m, c, x, y) => `${c} ${x} ${(+y + dy).toFixed(1)}`);
  const inkBot = Math.max(...ys0) + dy;
  const inkR = Math.max(...pts.map((q) => q[0]));
  const ENDT = +(DUR - 0.04).toFixed(3);
  // writing window: keep the demo's 0.45s unless the gesture lands too late to
  // finish + seal + bleed before the end of the clip
  const WIN = Math.min(p.window || 0.45, Math.max(0.3, ENDT - 0.9 - I));
  const DRAWN = +(I + WIN).toFixed(3);
  // hand-off to the fg ink flecks: the brush's FIRST contact point, at slap-down
  HG.fleckX = Math.round(pts[0][0]);
  HG.fleckY = Math.round(pts[0][1] + dy);
  HG.fleckT = +(I + 0.01).toFixed(3);
  // seal chop: below-right of the ink (demo offsets), kept on frame
  const SEALC = +(DRAWN + (p.sealDelay ?? 0.04)).toFixed(3);
  const sealS = p.sealSize ?? 64;
  const sealX = Math.min(W - 48, Math.max(60, Math.round(inkR + (p.sealDx ?? -103))));
  const sealY = Math.min(H - 80, Math.round(inkBot + (p.sealDy ?? 72)));
  const INK = dna.palette.ink || "#19120b";
  const WASH = dna.palette.wash || "#f6f0e2";
  const WASH2 = dna.palette.wash2 || "#f4edde";
  const washW = tw + 380,
    washH = p.washH ?? 420;
  const bleedT = +(DRAWN + 0.1).toFixed(3);
  const bleedDur = +Math.max(0.4, Math.min(1.7, ENDT - DRAWN - 0.1)).toFixed(2);
  const breT = +Math.min(SEALC, ENDT - 0.4).toFixed(3);
  const breDur = +Math.max(0.3, Math.min(1.78, ENDT - breT)).toFixed(2);
  const breN = Math.max(8, Math.round(breDur / (2 * F)));
  const css = `
  #bwWash { position:absolute; left:${HG.x}px; top:${HG.y}px; width:${washW}px; height:${washH}px;
            margin-left:-${Math.round(washW / 2)}px; margin-top:-${Math.round(washH / 2)}px; opacity:0;
            background: radial-gradient(50% 50% at 50% 50%,
              ${WASH}d9 0%, ${WASH2}73 52%, ${WASH2}00 75%); }
  #bwSvg { position:absolute; left:0; top:0; overflow:visible; }
  #bwSeal { position:absolute; left:${sealX}px; top:${sealY}px; width:${sealS}px; height:${sealS}px; opacity:0;
            border-radius:6px;
            background: radial-gradient(70% 70% at 42% 38%, ${dna.palette.seal || "#d24a32"} 0%, ${dna.palette.sealMid || "#c03422"} 62%, ${dna.palette.sealLo || "#a92a1a"} 100%);
            box-shadow: 0 3px 10px rgba(20,10,6,0.45), inset 0 0 9px rgba(255,140,110,0.35); }
  #bwSeal span { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
                 font-family:'${dna.fonts.seal || dna.fonts.body}', serif; font-weight:700;
                 font-size:${Math.round(sealS * 0.656)}px; color:${dna.palette.sealText || "#f7efe2"}; }
  .bwfleck { position:absolute; border-radius:50%; background:${dna.palette.sealMid || "#c03422"}; opacity:0; }`;
  const html = `      <div id="bwWash"></div>
      <svg id="bwSvg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
        <defs>
          <filter id="bwbleedblur" x="-40%" y="-40%" width="180%" height="180%">
            <feGaussianBlur stdDeviation="6"/>
          </filter>
        </defs>
        <g id="bwInkAll">
          <g id="bwBleedG" filter="url(#bwbleedblur)" opacity="0"></g>
          <g id="bwFatG"></g>
          <g id="bwMidG"></g>
          <g id="bwCoreG"></g>
        </g>
        <g id="bwSplatG" opacity="0"></g>
        <g id="bwSpatG"></g>
        <circle id="bwNib" r="7.5" fill="${dna.palette.nib || "#1a130c"}" opacity="0"/>
      </svg>
      <div id="bwSeal"><span>${esc(p.sealGlyph || "観")}</span></div>`;
  const js = `
  // ---- setpiece: BRUSHWRITE (slap-down → one gesture → bleed + seal chop) ----
  const Ib = ${I.toFixed(3)}, WINb = ${WIN.toFixed(3)}, DRAWNb = ${DRAWN}, ENDTb = ${ENDT};
  const Db = ${J(D)};
  const wrnd = mulberry32(${p.seed ?? 380611});

  // scene reaction: the paper wash charges in just before the gesture
  tl.fromTo("#bwWash", { opacity: 0 }, { opacity: ${p.wash ?? 0.34}, duration: 0.22, ease: "power2.in" }, ${(I - 0.18).toFixed(3)});
${
  I + 1.3 < ENDT
    ? `  tl.to("#bwWash", { opacity: ${p.washSettle ?? 0.27}, duration: ${Math.min(1.2, ENDT - I - 1.0).toFixed(2)}, ease: "power1.out" }, ${(I + 1.0).toFixed(3)});`
    : ""
}

  // strokes split at pen lifts; FOUR stacked widths = pressure-tapered wet brush
  const subsB = Db.split(/(?=M )/).map(s => s.trim()).filter(Boolean);
  const SVGNSB2 = "http://www.w3.org/2000/svg";
  function mkB(parent, d, w, op) {
    const el = document.createElementNS(SVGNSB2, "path");
    el.setAttribute("d", d); el.setAttribute("fill", "none");
    el.setAttribute("stroke", ${J(INK)}); el.setAttribute("stroke-width", w);
    el.setAttribute("stroke-opacity", op);
    el.setAttribute("stroke-linecap", "round"); el.setAttribute("stroke-linejoin", "round");
    el.setAttribute("opacity", "0");
    document.getElementById(parent).appendChild(el);
    return el;
  }
  const strokesB = subsB.map((d) => ({
    bleed: mkB("bwBleedG", d, ${p.bleedWidth ?? 16}, 1),
    fat:   mkB("bwFatG",   d, ${p.fatWidth ?? 18}, 0.25),
    mid:   mkB("bwMidG",   d, ${p.midWidth ?? 10}, 0.6),
    core:  mkB("bwCoreG",  d, ${p.coreWidth ?? 5}, 1),
  }));
  let totalB = 0;
  strokesB.forEach((s) => { s.len = s.core.getTotalLength(); totalB += s.len; });

  // start-splat: irregular ink blob at the first pen point (the brush SLAPS down)
  const SXB = ${Math.round(pts[0][0])}, SYB = ${Math.round(pts[0][1] + dy)};
  (function buildSplat() {
    const g = document.getElementById("bwSplatG");
    const main = document.createElementNS(SVGNSB2, "ellipse");
    main.setAttribute("cx", SXB - 4); main.setAttribute("cy", SYB + 2);
    main.setAttribute("rx", 24); main.setAttribute("ry", 15);
    main.setAttribute("transform", "rotate(-24 " + (SXB - 4) + " " + (SYB + 2) + ")");
    main.setAttribute("fill", ${J(INK)}); main.setAttribute("fill-opacity", "0.9");
    g.appendChild(main);
    for (let i = 0; i < 5; i++) {
      const c = document.createElementNS(SVGNSB2, "circle");
      const a = wrnd() * Math.PI * 2, dist = 16 + wrnd() * 26;
      c.setAttribute("cx", SXB - 4 + Math.cos(a) * dist);
      c.setAttribute("cy", SYB + 2 + Math.sin(a) * dist * 0.7);
      c.setAttribute("r", (1.8 + wrnd() * 3.4).toFixed(1));
      c.setAttribute("fill", ${J(INK)}); c.setAttribute("fill-opacity", "0.85");
      g.appendChild(c);
    }
  })();
  tl.set("#bwSplatG", { opacity: 1 }, Ib);
  tl.fromTo("#bwSplatG", { scale: 0.35, svgOrigin: "${Math.round(pts[0][0])} ${Math.round(pts[0][1] + dy)}" },
            { scale: 1, duration: 0.12, ease: "power3.out" }, Ib);

  // ONE sequential gesture: constant pen speed, the round ink nib riding
  const nibB = document.getElementById("bwNib");
  let tb = Ib, accB = 0;
  const curvB = [];   // spatter candidates: {x,y,ang,dA,te}
  strokesB.forEach((s) => {
    const d = WINb * s.len / totalB;
    const layers = [s.bleed, s.fat, s.mid, s.core];
    layers.forEach(pp => gsap.set(pp, { strokeDasharray: s.len, strokeDashoffset: s.len }));
    tl.set(layers, { opacity: 1 }, tb);
    tl.fromTo(layers, { strokeDashoffset: s.len },
              { strokeDashoffset: 0, duration: d, ease: "none" }, tb);
    const n = Math.max(6, Math.round(s.len / 12)), xs = [], ys = [];
    let prevAng = null;
    for (let k = 0; k <= n; k++) {
      const pt = s.core.getPointAtLength(s.len * k / n);
      xs.push(pt.x); ys.push(pt.y);
      if (k > 0) {
        const ang = Math.atan2(ys[k] - ys[k-1], xs[k] - xs[k-1]);
        if (prevAng !== null) {
          let dA = Math.abs(ang - prevAng); if (dA > Math.PI) dA = 2 * Math.PI - dA;
          if (dA > 0.55)
            curvB.push({ x: xs[k-1], y: ys[k-1], ang: prevAng, dA,
                         te: Ib + WINb * (accB + s.len * (k-1) / n) / totalB });
        }
        prevAng = ang;
      }
    }
    tl.set(nibB, { x: xs[0], y: ys[0] }, tb);
    tl.to(nibB, { keyframes: { x: xs, y: ys }, duration: d, ease: "none" }, tb);
    tb += d; accB += s.len;
  });
  gsap.set(nibB, { attr: { cx: 0, cy: 0 } });
  tl.set(nibB, { opacity: 1 }, Ib - 0.01);
  // the brush lifts off with a flick at the end of the gesture
  tl.to(nibB, { x: "+=42", y: "-=30", scale: 1.9, opacity: 0, duration: 0.1,
                ease: "power2.in", transformOrigin: "50% 50%" }, DRAWNb);

  // spatter droplets ejected at the sharpest-curvature points of the gesture
  curvB.sort((a, b) => b.dA - a.dA);
  const pickedB = [];
  for (const c of curvB) {
    if (pickedB.length >= ${p.spatterN ?? 6}) break;
    if (pickedB.every(q => Math.abs(q.te - c.te) > 0.045)) pickedB.push(c);
  }
  const spatB = document.getElementById("bwSpatG");
  pickedB.forEach((c) => {
    if (c.te + 0.33 > ENDTb) return;
    const el = document.createElementNS(SVGNSB2, "circle");
    el.setAttribute("cx", c.x); el.setAttribute("cy", c.y);
    el.setAttribute("r", (2.2 + wrnd() * 2.2).toFixed(1));
    el.setAttribute("fill", ${J(INK)}); el.setAttribute("opacity", "0");
    spatB.appendChild(el);
    const side = wrnd() > 0.5 ? 1 : -1;
    const ea = c.ang + side * Math.PI / 2;
    const dist = 34 + wrnd() * 60;
    const dx = Math.cos(ea) * dist, dyv = Math.sin(ea) * dist - 18;
    tl.set(el, { opacity: 1 }, c.te);
    tl.to(el, { keyframes: { x: [0, dx * 0.6, dx], y: [0, dyv * 0.6, dyv + 22],
                             opacity: [1, 0.9, 0] },
                duration: 0.32, ease: "power2.out" }, c.te);
  });

  // HOLD: the ink BLEEDS into the paper (blurred under-layer swells) + breathe
  tl.to("#bwBleedG", { opacity: ${p.bleed ?? 0.38}, duration: ${bleedDur}, ease: "sine.inOut" }, ${bleedT});
  const bNb = ${breN}, bValsB = [];
  for (let k = 0; k <= bNb; k++) {
    const tt = k / bNb * ${breDur};
    bValsB.push(1 + 0.006 * Math.sin(2 * Math.PI * ${p.breatheHz ?? 0.55} * tt));
  }
  tl.to("#bwInkAll", { keyframes: { scale: bValsB }, duration: ${breDur}, ease: "none",
                       svgOrigin: "${HG.x} ${HG.y}" }, ${breT});
${
  SEALC + 0.38 <= ENDT
    ? `
  // vermilion SEAL chop stamps the work — the plate punch lands on the contact
  gsap.set("#bwSeal", { xPercent: -50, yPercent: -50, rotation: -6, transformOrigin: "50% 50%" });
  tl.set("#bwSeal", { opacity: 1, scale: 1.7 }, ${(SEALC - 0.1).toFixed(3)});
  tl.to("#bwSeal", { scale: 1, duration: 0.09, ease: "power4.in" }, ${(SEALC - 0.095).toFixed(3)});
  tl.set("#bwSeal", { scaleX: 1.07, scaleY: 0.93 }, ${SEALC});
  tl.to("#bwSeal", { scaleX: 1, scaleY: 1, duration: 0.28, ease: "elastic.out(1, 0.4)" }, ${(SEALC + 0.08).toFixed(3)});
  { const stg = document.getElementById("stage");
    for (let i = 0; i < 3; i++) {
      const el = document.createElement("div"); el.className = "bwfleck";
      stg.appendChild(el);
      const s = 2 + Math.round(wrnd() * 2);
      gsap.set(el, { width: s, height: s, left: ${sealX}, top: ${sealY} });
      const a = wrnd() * Math.PI * 2, dist = 32 + wrnd() * 38;
      tl.set(el, { opacity: 0.9 }, ${(SEALC + 0.01).toFixed(3)});
      tl.to(el, { x: Math.cos(a) * dist, y: Math.sin(a) * dist + 12, opacity: 0,
                  duration: 0.3, ease: "power2.out" }, ${(SEALC + 0.01).toFixed(3)});
    }
  }`
    : ""
}`;
  return { css, html, js };
}

function setpieceInkbloom() {
  // INKWATER apex: a sumi ink drop falls into still water BEHIND the subject.
  // Scene reaction: paper-white radial washes behind the two text zones — the
  // body rows get a PERSISTENT paper backing from t≈0.15 (the closing lines
  // keep their ground) and the apex zone charges just before the drop — plus
  // a gentle vignette that STILLS the world while the ink blooms. The drop
  // appears above the word point, swells, falls (power2.in, scaleY stretch),
  // squashes on contact (2 frames) and merges into the bloom: 3 stacked
  // blurred irregular ellipses scaling at different rates with counter-
  // rotations + 2 thin concentric ripple rings expanding on the water
  // surface; the hero words RESOLVE out of the blob (opacity snaps, blur
  // 16→0 + scale 1.12→1 carries the bleed) — the first at contact, the
  // following at their own spoken times; through the hold the glyph edges
  // BREATHE (baked blur keyframes), the whole group looms 1.018 and faint
  // ink wisps curl off the word top; exit: RE-DISSOLVE (blur up + spread +
  // fade — the ink disperses back into the water), timed to clear before the
  // next body line needs the reader. Blob / ring / wisp geometry is the
  // demo's, scaled from the measured word rect (kx = halfW/demoHalf,
  // ky = fontPx/132); the rail wash registers on the body row geometry.
  const p = dna.hero.params || {},
    D = heroIn;
  const INK = dna.palette.ink || dna.palette.body;
  const PAP = dna.palette.paper || "#fffaee",
    PAP2 = dna.palette.paper2 || "#fff8e8";
  const WASH = dna.palette.wash || "#fff9ee",
    WAPX = dna.palette.washApex || "#fffaf0";
  const fpx = HG.fontPx,
    halfW = Math.round(HG.halfW || heroDisplay.length * 0.25 * fpx),
    cx = HG.x,
    cy = HG.y;
  const ky = fpx / 132; // demo scale unit (Shippori Mincho 700 @ 132px)
  const demoHalf = (wordPx("pixel size.", dna.fonts.hero, 132, 0.02) || 650) / 2;
  const kx = halfW / demoHalf;
  const SX = (v) => Math.round(v * kx),
    SY = (v) => Math.round(v * ky);
  const C = +(D + (p.fall ?? 0.15)).toFixed(3); // contact with the water surface
  // hero words resolve on their own spoken times (the first at contact)
  const heroWords = heroDisplay.trim().split(/\s+/);
  const RES = heroWords.map((wtx, k) => {
    const tw = tWords[Math.min(hero.idx + k, hero.idx + hero.len - 1)];
    return +Math.max(C + 0.02, k === 0 ? 0 : tw.start).toFixed(3);
  });
  // the bloom re-dissolves before the next body line needs the reader
  const nextL = LINES.find((L) => L.words[0].start > hero.end + 0.05);
  const XOUT = +Math.max(
    C + 0.55,
    Math.min(
      D + (p.maxHold ?? 1.75),
      nextL ? nextL.words[0].start - (p.clearGap ?? 0.24) : DUR - 0.6,
      DUR - 0.6,
    ),
  ).toFixed(3);
  const loomT = +(D + 0.373).toFixed(3),
    loomD = +Math.min(1.3, XOUT - (D + 0.373) - 0.03).toFixed(2);
  const vigOutT = +(XOUT - 0.15).toFixed(3),
    vigOutD = +Math.min(0.6, DUR - 0.05 - (XOUT - 0.15)).toFixed(2);
  // rail wash centers on the body reading rows (middle row)
  const railY = H - ((dna.body && dna.body.bottomPx) ?? 36) - ((dna.body && dna.body.rowGap) ?? 56);
  const wrW = p.washRailW ?? 1180,
    wrH = p.washRailH ?? 300;
  const waW = 2 * halfW + 370,
    waH = p.washApexH ?? 430;
  const blr = (v) => Math.max(8, SY(v));
  const css = `
  #ikvig { position:absolute; inset:0; opacity:0;
           background: radial-gradient(115% 100% at 50% 40%, rgba(0,0,0,0) 42%, rgba(8,6,4,0.55) 100%); }
  #ikwashRail { position:absolute; left:${W / 2}px; top:${railY}px; width:${wrW}px; height:${wrH}px;
                margin-left:-${Math.round(wrW / 2)}px; margin-top:-${Math.round(wrH / 2)}px; opacity:0;
                background: radial-gradient(50% 50% at 50% 50%, ${WASH}7a 0%, ${WASH}3d 48%, ${WASH}00 75%); }
  #ikwashApex { position:absolute; left:${cx}px; top:${cy}px; width:${waW}px; height:${waH}px;
                margin-left:-${Math.round(waW / 2)}px; margin-top:-${Math.round(waH / 2)}px; opacity:0;
                background: radial-gradient(50% 50% at 50% 50%, ${WAPX}4d 0%, ${WAPX}21 50%, ${WAPX}00 76%); }
  #inkG { position:absolute; left:${cx}px; top:${cy}px; opacity:0; }
  #ikdrop { position:absolute; left:${-SY(9)}px; top:${-SY(228)}px; width:${SY(18)}px; height:${SY(18)}px;
            border-radius: 46% 54% 50% 50% / 38% 40% 62% 60%;
            background:${INK}; opacity:0; box-shadow: 0 0 6px ${INK}73; }
  .ikblob { position:absolute; opacity:0; background:${INK}; }
  #ikb1 { left:${SX(-115)}px; top:${SY(-64)}px; width:${SX(230)}px; height:${SY(128)}px;
          border-radius: 47% 53% 44% 56% / 58% 42% 60% 40%; filter: blur(${blr(16)}px); }
  #ikb2 { left:${SX(-205)}px; top:${SY(-44)}px; width:${SX(190)}px; height:${SY(108)}px;
          border-radius: 56% 44% 58% 42% / 44% 60% 40% 56%; filter: blur(${blr(20)}px); }
  #ikb3 { left:${SX(-13)}px; top:${SY(-66)}px; width:${SX(206)}px; height:${SY(96)}px;
          border-radius: 42% 58% 50% 50% / 62% 38% 56% 44%; filter: blur(${blr(24)}px); }
  .ikring { position:absolute; left:${SX(-80)}px; top:${SY(-8)}px; width:${SX(160)}px; height:${SY(44)}px;
            border-radius:50%; opacity:0; }
  #ikr1 { border:2px solid ${INK}cc; filter: blur(1.2px); }
  #ikr2 { border:2px solid ${INK}8c; filter: blur(2.2px); }
  #ikword { position:absolute; left:0; top:0; white-space:nowrap;
            transform: translate(-50%,-50%);
            font-family:'${dna.fonts.hero}', serif; font-weight:700;
            font-size:${fpx}px; line-height:1; letter-spacing:0.02em; color:${INK}; }
  #ikword .ikw { display:inline-block; opacity:0; margin-right:0.30em;
                 text-shadow: 0 0 14px ${PAP}f2, 0 0 36px ${PAP2}b3, 0 2px 4px ${PAP}99; }
  #ikword .ikw:last-child { margin-right:0; }
  .ikwisp { position:absolute; width:${SY(30)}px; height:${SY(38)}px; opacity:0;
            border:4px solid transparent; border-top-color:${INK}80;
            border-radius:50%; filter: blur(2.5px); }
  #ikwisp1 { left:${SX(-210)}px; top:${SY(-118)}px; }
  #ikwisp2 { left:${SX(118)}px; top:${SY(-122)}px; }`;
  const html = `      <div id="ikvig"></div>
      <div id="ikwashRail"></div>
      <div id="ikwashApex"></div>
      <div id="inkG">
        <div class="ikblob" id="ikb2"></div>
        <div class="ikblob" id="ikb3"></div>
        <div class="ikblob" id="ikb1"></div>
        <div class="ikring" id="ikr1"></div>
        <div class="ikring" id="ikr2"></div>
        <div id="ikword">${heroWords.map((t, k) => `<span class="ikw" id="ikw${k}">${esc(t)}</span>`).join("")}</div>
        <div class="ikwisp" id="ikwisp1"></div>
        <div class="ikwisp" id="ikwisp2"></div>
        <div id="ikdrop"></div>
      </div>`;
  const js = `
  // ---- setpiece: INKBLOOM (drop falls → ink blooms → words resolve → re-dissolve) ----
  // paper washes (the scene offers paper to the ink); the rail wash PERSISTS
  tl.fromTo("#ikwashRail", { opacity: 0 }, { opacity: 1, duration: 0.5, ease: "power1.out" }, 0.15);
  tl.fromTo("#ikwashApex", { opacity: 0 }, { opacity: 1, duration: 0.3, ease: "power1.out" }, ${Math.max(0.02, D - 0.077).toFixed(3)});
  tl.to("#ikwashApex", { opacity: 0, duration: 0.5, ease: "power1.in" }, ${XOUT});

  // the world stills while the ink blooms
  tl.to("#ikvig", { opacity: ${p.vig ?? 0.34}, duration: 0.45, ease: "power2.out" }, ${C});
  tl.to("#ikvig", { opacity: 0, duration: ${vigOutD}, ease: "power1.in" }, ${vigOutT});

  // ===== drop: appears, swells, falls, squashes on contact, merges into the blob =====
  tl.set("#inkG", { opacity: 1 }, ${(D - 0.08).toFixed(3)});
  tl.set("#ikdrop", { opacity: 1, transformOrigin: "50% 50%" }, ${(D - 0.08).toFixed(3)});
  tl.fromTo("#ikdrop", { scale: 0.55 }, { scale: 1, duration: 0.08, ease: "power2.out" }, ${(D - 0.08).toFixed(3)});
  tl.to("#ikdrop", { y: ${SY(219)}, duration: ${(C - D).toFixed(3)}, ease: "power2.in" }, ${D.toFixed(3)});
  tl.to("#ikdrop", { scaleY: 1.45, scaleX: 0.8, duration: ${(C - D).toFixed(3)}, ease: "power2.in" }, ${D.toFixed(3)});
  tl.set("#ikdrop", { scaleX: 1.7, scaleY: 0.35 }, ${C});                 // contact squash, 2 frames
  tl.to("#ikdrop", { opacity: 0, duration: 0.07 }, ${(C + 0.083).toFixed(3)});

  // ===== bloom: 3 stacked blurred ellipses, different rates; opacity snaps then decays =====
  const IKB = [["#ikb1", 0.82, 0.50, 3.0, -4,  5, 0,    1.15, 0.18, 1.05],
               ["#ikb2", 0.62, 0.42, 2.4,  7, -7, 0.04, 1.35, 0.22, 1.15],
               ["#ikb3", 0.50, 0.55, 3.8, -9,  8, 0.08, 1.50, 0.30, 1.20]];
  IKB.forEach(([sel, op, s0, s1, r0, r1, dt, gd, ft, fd]) => {
    tl.set(sel, { opacity: op, transformOrigin: "50% 50%" }, ${C} + dt);
    tl.fromTo(sel, { scale: s0, rotation: r0 },
              { scale: s1, rotation: r1, duration: gd, ease: "expo.out" }, ${C} + dt);
    tl.to(sel, { opacity: 0, duration: fd, ease: "power1.in" }, ${C} + ft);
  });

  // ripple rings on the water surface
  tl.set("#ikr1", { opacity: 0.7, transformOrigin: "50% 50%" }, ${(C + 0.02).toFixed(3)});
  tl.fromTo("#ikr1", { scale: 1 }, { scale: 7.2, duration: 1.45, ease: "expo.out" }, ${(C + 0.02).toFixed(3)});
  tl.to("#ikr1", { opacity: 0, duration: 1.25, ease: "power1.in" }, ${(C + 0.22).toFixed(3)});
  tl.set("#ikr2", { opacity: 0.5, transformOrigin: "50% 50%" }, ${(C + 0.16).toFixed(3)});
  tl.fromTo("#ikr2", { scale: 1 }, { scale: 9.6, duration: 1.7, ease: "power2.out" }, ${(C + 0.16).toFixed(3)});
  tl.to("#ikr2", { opacity: 0, duration: 1.4, ease: "power1.in" }, ${(C + 0.4).toFixed(3)});

  // ===== the words resolve out of the blob (opacity snaps, BLUR carries the bleed) =====
  const IKRES = ${J(RES)};
  IKRES.forEach((rt, k) => {
    const el = document.getElementById("ikw" + k);
    const rd = Math.min(0.8, Math.max(0.3, ${XOUT} - rt - 0.25));
    tl.set(el, { opacity: 1, transformOrigin: "50% 60%" }, rt);
    tl.fromTo(el, { scale: 1.12, filter: "blur(16px)" },
              { scale: 1, filter: "blur(0px)", duration: rd, ease: "power2.out" }, rt);
    // hold life: glyph edges breathe (blur 0 → ~0.8 → 0, slow)
    const bt = rt + Math.max(0.82, rd + 0.02), bd = k % 2 ? 0.5 : 0.66;
    if (bt + bd <= ${XOUT} + 0.02) {
      tl.to(el, { keyframes: { filter: ["blur(0px)","blur(0.8px)","blur(0.15px)","blur(0.7px)","blur(0px)"] },
                  duration: bd, ease: "none" }, bt);
    }
  });
${
  loomD >= 0.2
    ? `  // gentle loom on the group through the hold
  tl.to("#inkG", { scale: 1.018, duration: ${loomD}, ease: "power1.inOut", transformOrigin: "50% 50%" }, ${loomT});`
    : ""
}

  // faint ink wisps curl off the word top during the hold
${
  C + 0.573 + 0.6 <= XOUT
    ? `  tl.set("#ikwisp1", { opacity: 0.45, transformOrigin: "50% 50%" }, ${(C + 0.573).toFixed(3)});
  tl.fromTo("#ikwisp1", { y: 0, x: 0, rotation: 14, scaleY: 1 },
            { y: -44, x: 12, rotation: 96, scaleY: 1.6, duration: 0.95, ease: "power1.out" }, ${(C + 0.573).toFixed(3)});
  tl.to("#ikwisp1", { opacity: 0, duration: 0.4, ease: "power1.in" }, ${(C + 1.123).toFixed(3)});`
    : ""
}
${
  C + 0.753 + 0.6 <= XOUT
    ? `  tl.set("#ikwisp2", { opacity: 0.4, transformOrigin: "50% 50%" }, ${(C + 0.753).toFixed(3)});
  tl.fromTo("#ikwisp2", { y: 0, x: 0, rotation: -18, scaleY: 1 },
            { y: -40, x: -14, rotation: -98, scaleY: 1.5, duration: 0.9, ease: "power1.out" }, ${(C + 0.753).toFixed(3)});
  tl.to("#ikwisp2", { opacity: 0, duration: 0.38, ease: "power1.in" }, ${(C + 1.273).toFixed(3)});`
    : ""
}

  // exit: re-dissolve — blur up + spread + fade, ink dispersing
  tl.to("#inkG", { scale: 1.15, filter: "blur(11px)", opacity: 0,
                   duration: 0.52, ease: "power1.in" }, ${XOUT});
  tl.set("#inkG", { display: "none" }, ${Math.min(XOUT + 0.6, DUR - 0.02).toFixed(3)});`;
  return { css, html, js };
}

function setpieceRansomnote() {
  // ---- setpiece: RANSOMNOTE (letter-by-letter cutout slam → collective jolt → ripped off) ----
  // The hero word slams in BEHIND the subject as a ransom note: one cutout
  // chip per letter (4 recipes cycled, seeded ±13% sizes / alternating-sign
  // rotations / misaligned baselines), each slamming in oversized with a
  // flight shadow that POPS tight on contact (power3.in crush → 2-frame
  // squash → elastic settle) at S_i = I + 0.01 + step·i. When the LAST letter
  // lands the whole word takes a collective 1-frame JOLT + a scene pulse
  // (the plate punch anchors here — punchOffset is recomputed from the real
  // letter count so the punch always lands on the jolt). Hold = slow loom
  // keyframes (the crooked grid never straightens). Exit = letters RIPPED
  // off one by one, a 1-frame paper-tear flash under each. Scene reaction =
  // a collage scrim darkens the bright sky for the apex window.
  const p = dna.hero.params || {},
    I = heroIn;
  const STEP = p.step ?? 0.06,
    CRUSH = p.crush ?? 0.09,
    RSTAG = p.ripStag ?? 0.045;
  const BASE = HG.fontPx;
  const kf = BASE / 130; // chip chrome scales with the demo's 130px cut
  const N = [...heroText].filter((c) => c !== " ").length;
  const JOLT = +(I + 0.01 + STEP * (N - 1) + CRUSH).toFixed(3);
  // rip schedule: letters tear off while the closing line speaks (demo:
  // lastWord − 0.19), clamped so the last rip completes on frame and the
  // note holds ≥0.45s after the jolt
  let ripT = Math.min(
    theme.hero.exitAt ?? LASTWORD.start - (p.ripLead ?? 0.19),
    DUR - 0.22 - RSTAG * (N - 1),
  );
  ripT = +Math.max(Math.min(ripT, DUR - 0.22 - RSTAG * (N - 1)), JOLT + 0.45).toFixed(3);
  // the plate punch lands on the collective jolt, whatever the word length
  if (dna.plate && dna.plate.punch) dna.plate.punchOffset = +(JOLT - I).toFixed(3);
  const scrimT = +Math.max(0.02, I - 0.07).toFixed(3);
  const loomT = +(JOLT + 0.1).toFixed(3);
  const loomD = +Math.min(0.95, ripT - 0.08 - loomT).toFixed(2);
  const css = `
  #rscrim { position:absolute; inset:0; opacity:0;
            background: linear-gradient(180deg, rgba(12,8,5,0.66) 0%, rgba(12,8,5,0.48) 38%,
                        rgba(12,8,5,0.22) 62%, rgba(12,8,5,0.05) 100%); }
  #rpulse { position:absolute; inset:0; opacity:0; background:rgba(10,6,3,0.75); }
  #rnote  { position:absolute; left:${HG.x}px; top:${HG.y}px; white-space:nowrap; }
  .aw  { display:inline-block; position:relative; margin:0 3px; }
  .asp { display:inline-block; width:${Math.round(BASE * 0.3)}px; }
  .aflash { position:absolute; inset:-5px; background:${dna.palette.flash || "#fffdf2"}; opacity:0; z-index:0; }
  .awm { display:inline-block; position:relative; z-index:1; opacity:0;
         filter: drop-shadow(0 ${Math.max(4, Math.round(7 * kf))}px 0 rgba(8,6,4,0.55)); }
  .achip { display:inline-block; line-height:1.04; padding:0.05em 0.10em 0.09em; }
${ransomChipCss("0.01em")}`;
  const html = `      <div id="rscrim"></div>
      <div id="rpulse"></div>
      <div id="rnote"></div>`;
  const js = `
  // ---- setpiece: RANSOMNOTE (letter chips slam → collective jolt → ripped off) ----
  const I = ${I.toFixed(3)}, STEP = ${STEP}, CRUSH = ${CRUSH}, RIPT = ${ripT};
  const arnd = mulberry32(${p.seed || 3939});
  ${RANSOM_TEAR}
  const HARDA   = "drop-shadow(0 ${Math.max(4, Math.round(7 * kf))}px 0 rgba(8,6,4,0.55))";
  const FLIGHTA = "drop-shadow(0 ${Math.max(10, Math.round(24 * kf))}px 5px rgba(8,6,4,0.30))";
  const group = document.getElementById("rnote");
  gsap.set(group, { xPercent: -50, yPercent: -50 });

  // scene reacts: collage scrim darkens the bright sky for the apex window
  tl.to("#rscrim", { opacity: ${p.scrim ?? 0.44}, duration: 0.12, ease: "power2.out" }, ${scrimT});

  // ===== letter-by-letter ransom slam: S_k = I + 0.01 + STEP*k, contact = S_k + CRUSH
  const RECSA = ["ca", "cb", "cc", "cd"];
  let k = 0;
  ${J([...heroText])}.forEach((ch) => {
    if (ch === " ") {
      const sp = document.createElement("div"); sp.className = "asp";
      group.appendChild(sp);
      return;
    }
    const aw = document.createElement("div"); aw.className = "aw";
    const fl = document.createElement("div"); fl.className = "aflash";
    fl.style.clipPath = tearPoly(arnd);
    const mv = document.createElement("div"); mv.className = "awm";
    const chip = document.createElement("div"); chip.className = "achip " + RECSA[k % 4];
    chip.style.fontSize = Math.round(${BASE} * (0.85 + arnd() * 0.27)) + "px";
    chip.style.clipPath = tearPoly(arnd);
    chip.textContent = ch;
    mv.appendChild(chip); aw.appendChild(fl); aw.appendChild(mv); group.appendChild(aw);
    // seeded crooked grid (demo grammar): rot sign alternates per letter,
    // baseline shove dy leans the other way, the rip throws with the rot sign
    const sgn = k % 2 ? 1 : -1;
    const rot = sgn * (2 + arnd() * 3);
    const dy = -sgn * (2 + arnd() * 4);
    const rr = sgn * (15 + arnd() * 7);
    const S = I + 0.01 + STEP * k, C = S + CRUSH;
    tl.set(mv, { opacity: 1, scale: 1.5, rotation: rot * 1.8, y: dy - 20, filter: FLIGHTA }, S);
    tl.to(mv,  { scale: 1, rotation: rot, y: dy, duration: 0.085, ease: "power3.in" }, S + 0.005);
    tl.set(mv, { filter: HARDA }, C);                             // shadow pop: snaps tight on contact
    tl.set(mv, { scaleX: 1.07, scaleY: 0.93 }, C + 0.004);        // contact squash, held 2 frames
    tl.to(mv,  { scaleX: 1, scaleY: 1, duration: 0.3, ease: "elastic.out(1, 0.4)" }, C + 0.087);
    // ===== exit: RIPPED off one by one, tear flash 1 frame under each
    const R = RIPT + ${RSTAG} * k;
    tl.set(fl, { opacity: 0.85 }, R);
    tl.set(fl, { opacity: 0 }, R + 0.045);
    tl.to(mv, { rotation: rot + rr, y: dy - 34, opacity: 0,
                duration: 0.16, ease: "power2.in" }, R);
    k++;
  });

  // collective 1-frame jolt when the last letter lands + scene pulse
  tl.set("#rnote", { y: 6 }, ${JOLT});
  tl.to("#rnote",  { y: 0, duration: 0.14, ease: "power3.out" }, ${(JOLT + 0.042).toFixed(3)});
  tl.set("#rpulse", { opacity: ${p.pulse ?? 0.2} }, ${JOLT});
  tl.to("#rpulse",  { opacity: 0, duration: 0.13, ease: "power1.out" }, ${(JOLT + 0.042).toFixed(3)});
${
  loomD >= 0.35
    ? `
  // hold life: the pasted word looms (the crooked grid never straightens)
  tl.to("#rnote", { keyframes: { scale: [1, 1.02, 1.006, 1.018, 1] },
                    duration: ${loomD}, ease: "none" }, ${(JOLT + 0.1).toFixed(3)});`
    : ""
}
  tl.to("#rscrim", { opacity: 0, duration: 0.3, ease: "power1.in" }, ${(ripT + 0.06).toFixed(3)});`;
  return { css, html, js };
}

const SETPIECES = {
  detonation: setpieceDetonation,
  decode: setpieceDecode,
  drawon: setpieceDrawon,
  cpslam: setpieceCpslam,
  settle: setpieceSettle,
  flapboard: setpieceFlapboard,
  ledwipe: setpieceLedwipe,
  vhsosd: setpieceVhsosd,
  bossintro: setpieceBossintro,
  rubberstamp: setpieceRubberstamp,
  lasercage: setpieceLasercage,
  boltstrike: setpieceBoltstrike,
  holoboot: setpieceHoloboot,
  biobloom: setpieceBiobloom,
  silkribbon: setpieceSilkribbon,
  scopetrace: setpieceScopetrace,
  papermat: setpiecePapermat,
  centerfold: setpieceCenterfold,
  chalkwrite: setpieceChalkwrite,
  spraytag: setpieceSpraytag,
  brushwrite: setpieceBrushwrite,
  inkbloom: setpieceInkbloom,
  ransomnote: setpieceRansomnote,
};

if (!PARADIGMS[dna.body.paradigm])
  throw new Error("[make-theme] unknown body paradigm: " + dna.body.paradigm);
const body = PARADIGMS[dna.body.paradigm]();

let setp = { css: "", html: "", js: "" };
if (!heroInline && !HEROLESS) {
  if (!SETPIECES[dna.hero.setpiece])
    throw new Error("[make-theme] unknown setpiece: " + dna.hero.setpiece);
  setp = SETPIECES[dna.hero.setpiece]();
}
const fx = frontFx();

// bg file: plate reaction + embedded setpiece (+ body if body.layer === "bg")
const bodyInBg = dna.body.layer === "bg";
const poemScrimInBg = dna.body.paradigm === "poem" && dna.body.scrim;
let bgExtra = { css: "", html: "", js: "" };
if (poemScrimInBg) {
  // poem keeps its scrim BEHIND the subject (subject stays lit) while the poem
  // itself rides the fg alpha layer
  const bgScrimSide = typeof CLEARER !== "undefined" && CLEARER === "right" ? "right" : "left";
  bgExtra.css = `
  #pscrimBg { position:absolute; inset:0; opacity:0;
            background: linear-gradient(${bgScrimSide === "left" ? "100deg" : "260deg"},
              rgba(4,8,16,${dna.body.scrim.opacity}) 0%, rgba(4,8,16,${(dna.body.scrim.opacity * 0.6).toFixed(2)}) 38%, rgba(4,8,16,0) 62%); }`;
  bgExtra.html = `      <div id="pscrimBg"></div>`;
  bgExtra.js = `
  tl.fromTo("#pscrimBg", { opacity: 0 }, { opacity: 1, duration: 0.6, ease: "power1.out" }, 0.15);
  tl.to("#pscrimBg", { opacity: 0, duration: 0.4, ease: "power1.in" }, ${(LASTWORD.start + 0.15).toFixed(3)});`;
}

const bgHtml = bgSkeleton(
  [bgExtra.html, setp.html, bodyInBg ? body.html : ""].filter(Boolean).join("\n"),
  [bgExtra.css, setp.css, bodyInBg ? body.css : ""].filter(Boolean).join("\n"),
  [bgExtra.js, setp.js, bodyInBg ? body.js : ""].filter(Boolean).join("\n"),
);
fs.writeFileSync(path.join(PROJECT, "index.html"), bgHtml);

// fg file (rail.html): body paradigm (when fg) + setpiece fg parts + front fx
// (a setpiece may emit fgHtml/fgCss/fgJs — e.g. a verbatim caption chip that
// must ride ABOVE the matte while the setpiece scenery stays embedded behind)
if (!bodyInBg || fx.html || fx.js || setp.fgHtml) {
  const fgHtml = fgSkeleton(
    [!bodyInBg ? body.html : "", setp.fgHtml || "", fx.html].filter(Boolean).join("\n"),
    [!bodyInBg ? body.css : "", setp.fgCss || "", fx.css].filter(Boolean).join("\n"),
    [!bodyInBg ? body.js : "", setp.fgJs || "", fx.js].filter(Boolean).join("\n"),
  );
  fs.writeFileSync(path.join(PROJECT, "rail.html"), fgHtml);
} else if (fs.existsSync(path.join(PROJECT, "rail.html"))) {
  fs.unlinkSync(path.join(PROJECT, "rail.html"));
}

// _postfx.sh: plate reaction after the matte composite (subject+text move as one)
const P = HEROLESS ? { grain: (dna.plate || {}).grain || 5 } : dna.plate || {};
// punchOffset: themes whose impact is NOT the hero onset (e.g. flapboard's
// lock-complete clack) shift the plate punch anchor; default keeps onset+2f
const anchorT = (heroIn + (P.punchOffset ?? 0.045)).toFixed(3);
let filter;
// zoompan resamples EVERY frame (even z=1) -> run it 2x supersampled and
// lanczos back down so the always-on resample costs no sharpness (shake amps
// are in pixels -> doubled at 2x)
const SS = 2;
if (P.punch) {
  const minor = P.minorPunch && LINES.find((L) => L.words.some((w) => w.minor));
  const minorT = minor ? (minor.words.find((w) => w.minor).start + 0.01).toFixed(3) : null;
  filter = `scale=${W * SS}:${H * SS}:flags=lanczos,zoompan=
    z='1+${P.punch}*exp(-${P.punchDecay || 9}*(time-${anchorT}))*between(time,${anchorT},${anchorT}+${P.shakeWindow || 0.6})${minorT ? `+${P.minorPunch}*exp(-10*(time-${minorT}))*between(time,${minorT},${minorT}+0.4)` : ""}':
    x='iw/2-(iw/zoom/2)${P.shakeAmpX ? `+${P.shakeAmpX * SS}*exp(-${P.shakeDecay || 7}*(time-${anchorT}))*sin(2*PI*${P.shakeHz || 12}*(time-${anchorT}))*between(time,${anchorT},${anchorT}+${P.shakeWindow || 0.6})` : ""}':
    y='ih/2-(ih/zoom/2)${P.shakeAmpY ? `+${P.shakeAmpY * SS}*exp(-${P.shakeDecay || 7}*(time-${anchorT}))*cos(2*PI*${((P.shakeHz || 12) * 1.31).toFixed(2)}*(time-${anchorT}))*between(time,${anchorT},${anchorT}+${P.shakeWindow || 0.6})` : ""}':
    d=1:s=${W * SS}x${H * SS}:fps=${FPS},scale=${W}:${H}:flags=lanczos`;
} else if (P.pushIn) {
  filter = `scale=${W * SS}:${H * SS}:flags=lanczos,zoompan=z='1+${P.pushIn}*time/${DUR.toFixed(2)}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=1:s=${W * SS}x${H * SS}:fps=${FPS},scale=${W}:${H}:flags=lanczos`;
} else {
  filter = `null`;
}
const rgba = P.rgbashift
  ? `,rgbashift=rh=-${P.rgbashift}:bh=${P.rgbashift}:enable='between(t,${anchorT},${(heroIn + 0.125).toFixed(3)})+between(t,${(LASTWORD.start + 0.04).toFixed(3)},${(LASTWORD.start + 0.17).toFixed(3)})',format=yuv420p`
  : "";
fs.writeFileSync(
  path.join(PROJECT, "_postfx.sh"),
  `#!/usr/bin/env bash
# generated by make-theme.cjs — plate reaction for theme "${dna.name}"
set -euo pipefail
cd "$(dirname "$0")"
ffmpeg -y -v error -i final.mp4 -filter_complex "
  [0:v]${filter}${rgba},
  noise=alls=${P.grain || 5}:allf=t+u[v]" \\
  -map "[v]" -map 0:a -c:v libx264 -crf 14 -preset slow -profile:v high -c:a copy \\
  final_fx.mp4
echo "[postfx] ${dna.name} → final_fx.mp4"
`,
);

console.log(
  `[make-theme] ${dna.name}: index.html${!bodyInBg || fx.html ? " + rail.html" : ""} + _postfx.sh`,
);
console.log(
  `[make-theme]   body=${dna.body.paradigm}(${dna.body.layer}) hero=${HEROLESS ? "none (pure body)" : heroInline ? "inline:" + dna.hero.setpiece : dna.hero.setpiece + " @" + heroIn.toFixed(2) + "s"} lines=${LINES.length} dur=${DUR}s`,
);
scripts/matte.cjs
#!/usr/bin/env node
/*
 * matte.cjs — subject matting via hyperframes' built-in `remove-background`
 * (rembg-equivalent u2net_human_seg, Apache-2.0, 320×320 input, ~9 fps on
 * CoreML). Replaces the previous bundled PP-MattingV2 ONNX (34 MB asset +
 * an onnxruntime inference loop in this script): one engine, zero bundled
 * weights — the model auto-downloads once (~168 MB) to ~/.cache/hyperframes/.
 *
 * Semantics note (validated 2026-06-12 on 6 scenes + a cold-start E2E): a
 * HUMAN segmenter by intent, not surgically. Thin offset furniture (mic boom
 * arms) is usually excluded — captions render over it, behind the person —
 * but large salient objects near the subject (a telescope rig) can still
 * leak into the matte and occlude captions. Objects HELD by the subject
 * (products, phones) may drop out intermittently, letting captions pass in
 * front. Never assume: sample frames_fg/ before placing the hero.
 *
 * Pipeline:
 *   source.mp4 → hyperframes remove-background → ProRes 4444 .mov (lossless
 *   alpha; temp, deleted) → ffmpeg fps=<matte.fps> → frames_fg/f_%04d.png.
 *   frames_bg/ is extracted at the same rate (preview tooling reads it).
 *
 *   node matte.cjs <project-dir>
 * Reads:  <project>/source.mp4 (any video in the project dir is adopted)
 * Writes: <project>/frames_fg/f_%04d.png (RGBA, subject opaque),
 *         <project>/frames_bg/f_%04d.png, <project>/matte.fps
 * Env:    HYPERFRAMES_ROOT — hyperframes checkout (default ~/Downloads/hyperframes)
 */
const path = require("path");
const fs = require("fs");
const os = require("os");
const cp = require("child_process");

function hfCli() {
  const roots = [
    process.env.HYPERFRAMES_ROOT,
    path.resolve(__dirname, "..", "..", ".."), // skills/embedded-captions/scripts → repo root if in-repo
    path.join(os.homedir(), "Downloads", "hyperframes"),
  ].filter(Boolean);
  for (const root of roots) {
    const cli = path.join(root, "packages", "cli", "dist", "cli.js");
    if (fs.existsSync(cli)) return cli;
  }
  console.error("[matte] cannot find hyperframes cli — set HYPERFRAMES_ROOT to a built checkout");
  process.exit(3);
}

function ensureSource(project) {
  const src = path.join(project, "source.mp4");
  if (!fs.existsSync(src)) {
    const found = fs
      .readdirSync(project)
      .filter((f) => /\.(mp4|mov|webm|mkv)$/i.test(f) && !f.startsWith("_"))
      .map((f) => path.join(project, f))[0];
    if (!found) return src;
    try {
      fs.symlinkSync(path.basename(found), src);
    } catch {
      fs.copyFileSync(found, src);
    }
    console.log(`[matte] resolved source.mp4 -> ${path.basename(found)}`);
  }
  return src;
}

function rateOf(expr) {
  const [n, d] = String(expr || "").split("/");
  const f = parseFloat(n) / parseFloat(d || "1");
  return Number.isFinite(f) && f > 0 ? f : 0;
}

// Prefer avg_frame_rate (frames/duration — the truth) over r_frame_rate (the
// container's nominal tick rate, which lies on VFR sources: a 24fps screen
// recording can carry r_frame_rate=60 and would 2.5x-desync the matte).
function probeRates(src) {
  try {
    const out = cp
      .execFileSync("ffprobe", [
        "-v",
        "0",
        "-select_streams",
        "v:0",
        "-show_entries",
        "stream=r_frame_rate,avg_frame_rate",
        "-of",
        "default=nk=1:nw=1",
        "--",
        src,
      ])
      .toString()
      .trim()
      .split("\n");
    return { r: rateOf(out[0]), avg: rateOf(out[1]) };
  } catch {
    return { r: 0, avg: 0 };
  }
}

function probeFps(src) {
  const { r, avg } = probeRates(src);
  const f = avg || r;
  return f > 0 ? Math.max(1, Math.round(f)) : 24;
}

// VFR when nominal and actual disagree by >5% — the remove-background engine
// mishandles VFR timestamps (observed: 2251 fg frames vs 902 bg on one clip),
// so VFR sources get normalized to CFR before matting.
function isVfr(src) {
  const { r, avg } = probeRates(src);
  return r > 0 && avg > 0 && Math.abs(r - avg) / avg > 0.05;
}

function extractFrames(src, dst, fps, extra = []) {
  fs.mkdirSync(dst, { recursive: true });
  if (fs.readdirSync(dst).some((f) => f.endsWith(".png"))) return false;
  cp.execFileSync(
    "ffmpeg",
    ["-y", "-i", src, "-vf", `fps=${fps}`, ...extra, path.join(dst, "f_%04d.png")],
    { stdio: "ignore" },
  );
  return true;
}

function countPngs(dir) {
  try {
    return fs.readdirSync(dir).filter((f) => f.endsWith(".png")).length;
  } catch {
    return 0;
  }
}

async function main() {
  const project = path.resolve(process.argv[2] || "");
  if (!process.argv[2]) {
    console.error("usage: matte.cjs <project-dir>");
    process.exit(1);
  }
  const src = ensureSource(project);
  if (!fs.existsSync(src)) {
    console.error(`[matte] no source video found in ${project}`);
    process.exit(2);
  }

  const fpsFile = path.join(project, "matte.fps");
  // read-with-catch (no exists-then-read TOCTOU): a missing/corrupt fps file
  // simply falls through to probing the source.
  let fps = 0;
  try {
    fps = parseInt(fs.readFileSync(fpsFile, "utf8").replace(/\D/g, ""), 10) || 0;
  } catch {
    /* no cached fps — probe below */
  }
  if (!fps) fps = probeFps(src);
  fs.writeFileSync(fpsFile, String(fps));

  const framesBg = path.join(project, "frames_bg");
  const framesFg = path.join(project, "frames_fg");
  if (extractFrames(src, framesBg, fps))
    console.log(`[matte] source fps=${fps} → frames_bg extracted`);

  const want = countPngs(framesBg);
  if (want > 0 && countPngs(framesFg) >= want) {
    console.log(`[matte] frames_fg already complete (${want} frames) — nothing to do`);
    return;
  }

  // 1) subject matte via hyperframes (ProRes 4444 keeps the alpha lossless).
  //    VFR sources are normalized to CFR first — remove-background trusts
  //    timestamps and emits a desynced frame count on VFR input (the ghost
  //    double-subject bug: the pasted matte runs at the wrong speed).
  let matteSrc = src;
  if (isVfr(src)) {
    const cfr = path.join(project, "_src_cfr.mp4");
    if (!fs.existsSync(cfr)) {
      console.log(
        `[matte] VFR source detected (nominal != actual fps) → normalizing to ${fps}fps CFR for matting`,
      );
      cp.execFileSync(
        "ffmpeg",
        [
          "-y",
          "-i",
          src,
          "-fps_mode",
          "cfr",
          "-r",
          String(fps),
          "-c:v",
          "libx264",
          "-crf",
          "16",
          "-preset",
          "fast",
          "-an",
          cfr,
        ],
        { stdio: "ignore" },
      );
    }
    matteSrc = cfr;
  }
  const mov = path.join(project, "_matte_tmp.mov");
  const t0 = Date.now();
  const cached = fs.existsSync(
    path.join(
      os.homedir(),
      ".cache",
      "hyperframes",
      "background-removal",
      "models",
      "u2net_human_seg.onnx",
    ),
  );
  console.log(
    `[matte] hyperframes remove-background (u2net_human_seg${cached ? "" : "; first run downloads ~168 MB"})… model load takes ~1-2 min with no output — not hung`,
  );
  const r = cp.spawnSync("node", [hfCli(), "remove-background", matteSrc, "-o", mov], {
    stdio: ["ignore", "pipe", "pipe"],
    encoding: "utf8",
  });
  if (r.status !== 0 || !fs.existsSync(mov)) {
    console.error("[matte] remove-background FAILED:");
    console.error((r.stderr || r.stdout || "").split("\n").slice(-8).join("\n"));
    process.exit(4);
  }

  // 2) burst to RGBA pngs at the project rate
  fs.mkdirSync(framesFg, { recursive: true });
  cp.execFileSync(
    "ffmpeg",
    ["-y", "-i", mov, "-vf", `fps=${fps}`, "-pix_fmt", "rgba", path.join(framesFg, "f_%04d.png")],
    { stdio: "ignore" },
  );
  fs.rmSync(mov, { force: true });

  // 3) count parity with frames_bg. Composite overlays fg by INDEX at matte.fps,
  //    so any fg/bg count mismatch is a time desync (subject ghosting). Handle
  //    BOTH directions: pad when short, linearly remap when long — and shout
  //    when the mismatch is big enough to mean broken timestamps upstream.
  let got = countPngs(framesFg);
  const tol = Math.max(2, Math.round(want * 0.01));
  if (Math.abs(got - want) > tol) {
    console.error(
      `[matte] WARN frame-count desync: fg=${got} vs bg=${want} (tolerance ${tol}). ` +
        `Reconciling by remap — if the source is VFR this run predates the CFR fix; ` +
        `delete frames_fg/ frames_bg/ matte.fps and re-run matte.cjs.`,
    );
  }
  if (got > want && want > 0) {
    // keep want frames sampled evenly across got (re-times fg onto the bg timeline)
    const keep = [];
    for (let j = 1; j <= want; j++)
      keep.push(Math.min(got, Math.max(1, Math.round((j - 0.5) * (got / want)))));
    const tmp = path.join(project, "_fg_remap");
    fs.mkdirSync(tmp, { recursive: true });
    keep.forEach((srcIdx, k) => {
      fs.copyFileSync(
        path.join(framesFg, `f_${String(srcIdx).padStart(4, "0")}.png`),
        path.join(tmp, `f_${String(k + 1).padStart(4, "0")}.png`),
      );
    });
    fs.rmSync(framesFg, { recursive: true, force: true });
    fs.renameSync(tmp, framesFg);
    got = countPngs(framesFg);
  }
  while (got < want && got > 0) {
    fs.copyFileSync(
      path.join(framesFg, `f_${String(got).padStart(4, "0")}.png`),
      path.join(framesFg, `f_${String(got + 1).padStart(4, "0")}.png`),
    );
    got++;
  }
  console.log(
    `[matte] done in ${((Date.now() - t0) / 1000).toFixed(1)}s → fg=${got} bg=${want} @ ${fps}fps${got === want ? " (parity ok)" : ""}`,
  );
}

main().catch((e) => {
  console.error("[matte]", e.message);
  process.exit(1);
});
scripts/measure-layout.cjs
#!/usr/bin/env node
/**
 * measure-layout.cjs — pixel-perfect bbox measurement using headless Chromium.
 *
 * Loads the compiled index.html, seeks the GSAP timeline to specified sample
 * times, queries every .cap container + every .w word span via
 * getBoundingClientRect(), writes results to _layout.json.
 *
 * check-occlusion.cjs then reads _layout.json + frames_fg/*.png and computes
 * per-word occlusion against the actual subject silhouette (matte alpha via
 * sharp) — pixel accurate, no char_ratio guessing.
 *
 * Usage:
 *   node measure-layout.cjs <project-dir> [times...]
 * If no times given, samples groups['in', 'out'] midpoints.
 */
const path = require("path");
const fs = require("fs");
const os = require("os");

// Locate hyperframes' bundled puppeteer. render-and-composite.sh exports
// HYPERFRAMES_ROOT; standalone we also try the in-repo path + ~/Downloads, and
// accept ANY puppeteer@* the bun store holds (not a pinned version).
const HF_ROOTS = [
  process.env.HYPERFRAMES_ROOT,
  path.resolve(__dirname, "../../.."), // skills/embedded-captions/scripts → repo root if in-repo
  path.join(os.homedir(), "Downloads", "hyperframes"),
].filter(Boolean);
let puppeteer = null;
for (const root of HF_ROOTS) {
  const cands = [path.join(root, "node_modules", "puppeteer")];
  const bunDir = path.join(root, "node_modules", ".bun");
  try {
    if (fs.existsSync(bunDir)) {
      for (const d of fs.readdirSync(bunDir)) {
        if (d.startsWith("puppeteer@"))
          cands.push(path.join(bunDir, d, "node_modules", "puppeteer"));
      }
    }
  } catch {
    /* ignore */
  }
  for (const p of cands) {
    try {
      if (fs.existsSync(p)) {
        puppeteer = require(p);
        break;
      }
    } catch {
      /* try next */
    }
  }
  if (puppeteer) break;
}
if (!puppeteer) {
  console.error(
    "[measure] could not locate puppeteer — set HYPERFRAMES_ROOT to a built hyperframes checkout",
  );
  process.exit(3);
}

// Resolve hyperframes' bundled GSAP. The templates load GSAP from a CDN
// (cdn.jsdelivr.net), but in headless Chromium that request can be slow or
// blocked — the page's inline `gsap.timeline()` then throws "gsap is not
// defined" and the occlusion gate hard-fails. We inject this local copy on
// every new document (before any page script runs) so window.gsap always
// exists, and abort the CDN request so the parser never stalls on it. The
// render path is unaffected — this is measurement-only.
let gsapSource = null;
for (const root of HF_ROOTS) {
  const cands = [path.join(root, "node_modules", "gsap", "dist", "gsap.min.js")];
  const bunDir = path.join(root, "node_modules", ".bun");
  try {
    if (fs.existsSync(bunDir)) {
      for (const d of fs.readdirSync(bunDir)) {
        if (d.startsWith("gsap@"))
          cands.push(path.join(bunDir, d, "node_modules", "gsap", "dist", "gsap.min.js"));
      }
    }
  } catch {
    /* ignore */
  }
  for (const p of cands) {
    try {
      if (fs.existsSync(p)) {
        gsapSource = fs.readFileSync(p, "utf8");
        break;
      }
    } catch {
      /* try next */
    }
  }
  if (gsapSource) break;
}

async function main() {
  const projectDir = process.argv[2];
  if (!projectDir) {
    console.error("usage: measure-layout.cjs <project-dir> [t1 t2 ...]");
    process.exit(1);
  }
  const indexPath = path.resolve(projectDir, "index.html");
  if (!fs.existsSync(indexPath)) {
    console.error(`[measure] missing ${indexPath} — run make-composition.cjs first`);
    process.exit(2);
  }

  // Load plan to get groups + sample times
  const planPath = path.join(projectDir, "plan.json");
  let plan = null;
  if (fs.existsSync(planPath)) plan = JSON.parse(fs.readFileSync(planPath, "utf8"));

  // Determine sample times: per group, sample multiple points across [in, out]
  // (catches subject motion within block, multi-frame validation).
  const explicitTimes = process.argv.slice(3).map(Number).filter(Number.isFinite);
  let sampleTimes = explicitTimes;
  if (sampleTimes.length === 0 && plan?.groups) {
    const allTimes = new Set();
    for (const g of plan.groups) {
      const dur = g.out - g.in;
      // 4 samples per group: 15%, 40%, 65%, 90% through window — covers entry/peak/exit
      [0.15, 0.4, 0.65, 0.9].forEach((p) => allTimes.add(+(g.in + dur * p).toFixed(3)));
    }
    sampleTimes = [...allTimes].sort((a, b) => a - b);
  }

  // Match render-and-composite's Chrome detection
  const exe =
    process.platform === "darwin"
      ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
      : "/usr/bin/google-chrome";

  const W = plan?.width || 720;
  const H = plan?.height || 1290;
  const FPS = plan?.fps || 24;

  const browser = await puppeteer.launch({
    headless: "new",
    executablePath: fs.existsSync(exe) ? exe : undefined,
    args: [
      "--disable-web-security",
      "--allow-file-access-from-files",
      `--window-size=${W},${H}`,
      "--disable-dev-shm-usage",
    ],
  });
  try {
    const page = await browser.newPage();
    await page.setViewport({ width: W, height: H, deviceScaleFactor: 1 });
    page.on("pageerror", (err) => console.error(`[browser-error] ${err.message}`));

    // Inject local GSAP before any page script + abort the CDN <script> so the
    // page never depends on network for GSAP (see resolver note above). Falls
    // back to the page's own CDN load if no local copy was found.
    if (gsapSource) {
      await page.evaluateOnNewDocument(gsapSource);
      await page.setRequestInterception(true);
      page.on("request", (req) => {
        const u = req.url();
        if (req.resourceType() === "script" && /gsap/i.test(u) && /^https?:/i.test(u)) req.abort();
        else req.continue();
      });
    }

    await page.goto(`file://${indexPath}`, { waitUntil: "load", timeout: 15000 });
    // GSAP is injected locally above; poll for the page's timeline registration.
    const start = Date.now();
    let ready = false;
    while (Date.now() - start < 15000) {
      const r = await page.evaluate(() => !!(window.__timelines && window.__timelines.main));
      if (r) {
        ready = true;
        break;
      }
      await new Promise((res) => setTimeout(res, 200));
    }
    if (!ready) {
      console.error("[measure] GSAP timeline never registered");
      process.exit(4);
    }
    // Inject the skill's bundled @font-face set so headless Chromium measures the SAME
    // glyph metrics the renderer will use. Without this, Inter/etc fall back to system
    // fonts here while the real render uses the true (often wider) face → wrapped line
    // counts differ → slot layout / occlusion verdicts are measured on the wrong text.
    try {
      const fontsCss = path.join(__dirname, "..", "modes", "standard", "fonts", "fonts.css");
      if (fs.existsSync(fontsCss))
        await page.addStyleTag({ content: fs.readFileSync(fontsCss, "utf8") });
    } catch {
      /* best-effort — fonts.css missing just reverts to old behavior */
    }
    // let webfonts settle so measured glyph metrics match the render
    await page.evaluate(async () => {
      try {
        await document.fonts.ready;
      } catch {}
    });

    const samples = [];
    for (const t of sampleTimes) {
      // Seek timeline
      await page.evaluate((t) => {
        const tl = window.__timelines.main;
        tl.seek(t);
        // Force layout flush
        void document.body.offsetHeight;
      }, t);
      // Tiny settle for animations / fonts
      await new Promise((r) => setTimeout(r, 30));

      // Measure every .cap and its .w children
      const measurements = await page.evaluate(() => {
        const caps = [...document.querySelectorAll(".cap")];
        const out = [];
        for (const cap of caps) {
          const cs = getComputedStyle(cap);
          if (cs.opacity === "0" || cs.display === "none") continue;
          const cb = cap.getBoundingClientRect();
          if (cb.width === 0 || cb.height === 0) continue;
          const id = cap.id || "";
          const layer = cap.dataset.layer || "";
          // Per-line via Range over all word spans
          const ws = [...cap.querySelectorAll(".w")];
          const words = [];
          for (const w of ws) {
            const wcs = getComputedStyle(w);
            if (wcs.opacity === "0") continue; // not yet animated in
            const wb = w.getBoundingClientRect();
            if (wb.width === 0) continue;
            words.push({
              text: w.textContent,
              x: +wb.x.toFixed(1),
              y: +wb.y.toFixed(1),
              w: +wb.width.toFixed(1),
              h: +wb.height.toFixed(1),
              opacity: +wcs.opacity,
            });
          }
          // Group by line (same y ± 2px)
          const lines = [];
          for (const w of words) {
            const line = lines.find((l) => Math.abs(l.y - w.y) < 3);
            if (line) {
              line.words.push(w);
              line.x = Math.min(line.x, w.x);
              line.w = Math.max(line.x + line.w, w.x + w.w) - line.x;
              line.h = Math.max(line.h, w.h);
            } else {
              lines.push({ x: w.x, y: w.y, w: w.w, h: w.h, words: [w] });
            }
          }
          out.push({
            id,
            layer,
            cap_bbox: {
              x: +cb.x.toFixed(1),
              y: +cb.y.toFixed(1),
              w: +cb.width.toFixed(1),
              h: +cb.height.toFixed(1),
            },
            opacity: +cs.opacity,
            lines,
            words, // also keep flat list
          });
        }
        return out;
      });
      const frame_idx = Math.max(1, Math.round(t * FPS));
      samples.push({ t, frame_idx, caps: measurements });
    }

    const layout = { width: W, height: H, fps: FPS, samples };
    const outPath = path.join(projectDir, "_layout.json");
    fs.writeFileSync(outPath, JSON.stringify(layout, null, 2));
    console.log(
      `[measure] wrote ${outPath} (${sampleTimes.length} sample frames, ${samples.reduce((a, s) => a + s.caps.length, 0)} cap measurements)`,
    );
  } finally {
    // Chromium occasionally hangs on shutdown. This script runs synchronously
    // inside check-occlusion.cjs, which the render gate blocks on — a hung close
    // would wedge the whole render. Cap the close, then force-exit below.
    await Promise.race([browser.close().catch(() => {}), new Promise((r) => setTimeout(r, 8000))]);
  }
}

// Force a hard exit so a lingering Chromium/libuv handle can't keep the process
// (and the render gate that spawned it) alive indefinitely.
main()
  .then(() => process.exit(0))
  .catch((e) => {
    console.error(e);
    process.exit(1);
  });
scripts/prepare.sh
#!/usr/bin/env bash
# prepare.sh — one-command pre-processing for a captions project.
#
#   bash prepare.sh <project-dir>
#
# Runs the three deterministic pre-steps with maximum parallelism:
#   matte.cjs (CPU-heavy ONNX)  ∥  transcribe.cjs (whisper)   → then safe-zones.cjs
# (matte and transcribe are independent; safe-zones needs the matte's frames_fg.)
# Replaces hand-running steps 2 / 3 / 3b — one call, nothing forgotten, ~the cost
# of the slower of the two instead of their sum.
#
# After this completes the project has: frames_bg/ frames_fg/ matte.fps
# transcript.json safe-zones.json — everything the authoring step needs.
set -euo pipefail

PROJECT="${1:?usage: prepare.sh <project-dir>}"
PROJECT="$(cd "$PROJECT" && pwd)"
SD="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

echo "[prepare] matte ∥ transcribe ∥ envelope …"
MLOG="$PROJECT/_prepare_matte.log"; TLOG="$PROJECT/_prepare_transcribe.log"; ELOG="$PROJECT/_prepare_envelope.log"
node "$SD/matte.cjs" "$PROJECT"      > "$MLOG" 2>&1 &  MPID=$!
node "$SD/transcribe.cjs" "$PROJECT" > "$TLOG" 2>&1 &  TPID=$!
node "$SD/audio-envelope.cjs" "$PROJECT" > "$ELOG" 2>&1 &  EPID=$!

MRC=0; TRC=0
wait "$MPID" || MRC=$?
wait "$TPID" || TRC=$?
wait "$EPID" || echo "[prepare] (envelope skipped — hero amplitude falls back to neutral)" >&2
# surface both logs (they contain the guards' warnings — near-silent audio, tail trim)
sed 's/^/  [matte] /'      "$MLOG" | tail -6
sed 's/^/  [transcribe] /' "$TLOG" | tail -12
if (( MRC != 0 )); then echo "[prepare] matte FAILED (rc=$MRC) — see $MLOG" >&2; exit "$MRC"; fi
if (( TRC != 0 )); then echo "[prepare] transcribe FAILED (rc=$TRC) — see $TLOG" >&2; exit "$TRC"; fi

echo "[prepare] safe-zones …"
node "$SD/safe-zones.cjs" "$PROJECT"

echo "[prepare] ✅ ready: frames_fg/ transcript.json safe-zones.json — author next (see SKILL.md step 4)"
scripts/preview-frames.cjs
#!/usr/bin/env node
/*
 * preview-frames.cjs — APPROXIMATE final frames in seconds, without rendering.
 *
 *   node preview-frames.cjs <project-dir> [t1 t2 ...]
 *
 * For each sample time t: screenshot the caption layer(s) in headless Chromium
 * (index.html seeked to t; rail.html too if present), then composite in sharp:
 *   frames_bg[t]  →  index captions (embed layer)  →  matte frames_fg[t] (subject
 *   occludes the embed)  →  rail captions (in front, like the final alpha-overlay)
 * = a faithful preview of the composite for THAT moment, ~2s per frame instead of
 * a multi-minute render. Writes <project>/preview/t<t>.png + a contact sheet
 * preview/sheet.png.
 *
 * Use it BEFORE rendering: eyeball placement, occlusion, washout, text-on-text —
 * the failure classes the geometric gates can't judge. The QA checklist lives in
 * SKILL.md § Visual QA. (Video elements show poster/first-frame in the screenshot;
 * the REAL a-roll pixels come from frames_bg, so previews stay accurate.)
 *
 * Sample-time default: climax/groups midpoints from standard.json or plan.json,
 * else 25/50/75% of the clip.
 */
const path = require("path");
const fs = require("fs");
const os = require("os");

const HF_ROOTS = [
  process.env.HYPERFRAMES_ROOT,
  path.resolve(__dirname, "../../.."),
  path.join(os.homedir(), "Downloads", "hyperframes"),
].filter(Boolean);
function findInBun(root, pkg, sub) {
  const cands = [path.join(root, "node_modules", pkg)];
  const bunDir = path.join(root, "node_modules", ".bun");
  try {
    if (fs.existsSync(bunDir))
      for (const d of fs.readdirSync(bunDir))
        if (d.startsWith(pkg + "@")) cands.push(path.join(bunDir, d, "node_modules", pkg));
  } catch {}
  for (const c of cands) {
    const p = sub ? path.join(c, sub) : c;
    if (fs.existsSync(p)) return p;
  }
  return null;
}
let puppeteer = null,
  sharp = null,
  gsapSource = null;
for (const r of HF_ROOTS) {
  if (!puppeteer) {
    const p = findInBun(r, "puppeteer");
    if (p)
      try {
        puppeteer = require(p);
      } catch {}
  }
  if (!sharp) {
    const p = findInBun(r, "sharp");
    if (p)
      try {
        sharp = require(p);
      } catch {}
  }
  if (!gsapSource) {
    const g = findInBun(r, "gsap", path.join("dist", "gsap.min.js"));
    if (g) gsapSource = fs.readFileSync(g, "utf8");
  }
}
if (!puppeteer || !sharp) {
  console.error("[preview] need puppeteer+sharp — set HYPERFRAMES_ROOT");
  process.exit(0);
}

async function shotAt(browser, file, W, H, t) {
  const page = await browser.newPage();
  try {
    await page.setViewport({ width: W, height: H, deviceScaleFactor: 1 });
    // Serve the page's own CDN <script src=gsap> request from the local bundle
    // (offline-safe) instead of injecting gsap at document-start: a document-start
    // evaluation runs while document.head is still null, and gsap's init then
    // throws "appendChild of null" — which killed previews for theme projects.
    await page.setRequestInterception(true);
    page.on("request", (req) => {
      const u = req.url();
      if (req.resourceType() === "script" && /gsap/i.test(u) && /^https?:/i.test(u)) {
        if (gsapSource)
          req.respond({ status: 200, contentType: "application/javascript", body: gsapSource });
        else req.continue(); // no local bundle — let the CDN load (online machines)
      } else if (req.resourceType() === "media")
        req.abort(); // a-roll pixels come from frames_bg
      else req.continue();
    });
    await page.goto(`file://${file}`, { waitUntil: "load", timeout: 15000 });
    const t0 = Date.now();
    let tlReady = false;
    while (Date.now() - t0 < 15000) {
      tlReady = await page.evaluate(() => !!(window.__timelines && window.__timelines.main));
      if (tlReady) break;
      await new Promise((r) => setTimeout(r, 120));
    }
    if (!tlReady) throw new Error(`timeline never registered in ${path.basename(file)}`);
    // bundled @font-face → previews show the REAL faces (same set the renderer embeds)
    try {
      const fontsCss = path.join(__dirname, "..", "modes", "standard", "fonts", "fonts.css");
      if (fs.existsSync(fontsCss))
        await page.addStyleTag({ content: fs.readFileSync(fontsCss, "utf8") });
    } catch {}
    await page.evaluate(async () => {
      try {
        await document.fonts.ready;
      } catch {}
    });
    await page.evaluate((t) => {
      const v = document.getElementById("a-roll");
      if (v) v.style.display = "none"; // transparent hole for the bg frame
      document.body.style.background = "transparent";
      document.documentElement.style.background = "transparent";
      window.__timelines.main.seek(t);
      void document.body.offsetHeight;
    }, t);
    await new Promise((r) => setTimeout(r, 60));
    return await page.screenshot({ omitBackground: true }); // RGBA png of caption layer only
  } finally {
    await page.close().catch(() => {});
  }
}

async function main() {
  const project = path.resolve(process.argv[2] || "");
  if (!process.argv[2]) {
    console.error("usage: preview-frames.cjs <project-dir> [times...]");
    process.exit(1);
  }
  const idx = path.join(project, "index.html");
  if (!fs.existsSync(idx)) {
    console.error("[preview] no index.html — compile first");
    process.exit(1);
  }
  const railP = path.join(project, "rail.html");
  const hasRail = fs.existsSync(railP);
  const fgP = path.join(project, "index_fg.html");
  const hasFg = fs.existsSync(fgP); // hybrid: fg caps render ABOVE the matte (like the real composite)

  let fps = 24;
  try {
    const f = parseFloat(
      String(fs.readFileSync(path.join(project, "matte.fps"), "utf8")).replace(/[^\d.]/g, ""),
    );
    if (f > 0) fps = f;
  } catch {}

  // sample times: explicit > climax window + line midpoints > thirds
  let globalFg = false;
  try {
    globalFg =
      JSON.parse(fs.readFileSync(path.join(project, "plan.json"), "utf8")).caption_layer === "fg";
  } catch {}
  let times = process.argv.slice(3).map(Number).filter(Number.isFinite);
  if (!times.length) {
    try {
      const plan = JSON.parse(fs.readFileSync(path.join(project, "plan.json"), "utf8"));
      // heroes get 2 samples each (entrance + hold); every OTHER group gets at least
      // a shot at one midpoint — the old 2-per-group list truncated at 12 and silently
      // dropped whole narration blocks from the sheet (cold-start agents missed bugs there)
      const gs = plan.groups || [];
      const heroes = gs.filter((g) => g.hero === true),
        rest = gs.filter((g) => !g.hero);
      for (const g of heroes) {
        const span = g.out - g.in;
        times.push(+(g.in + span * 0.25).toFixed(2), +(g.in + span * 0.7).toFixed(2));
      }
      const mids = rest.map((g) => +((g.in + g.out) / 2).toFixed(2));
      const budget = Math.max(2, 16 - times.length);
      const step = Math.max(1, Math.ceil(mids.length / budget));
      for (let i = 0; i < mids.length; i += step) times.push(mids[i]);
    } catch {}
  }
  if (!times.length) {
    const n = fs.existsSync(path.join(project, "frames_bg"))
      ? fs.readdirSync(path.join(project, "frames_bg")).length
      : 0;
    const dur = n / fps || 10;
    times = [dur * 0.25, dur * 0.5, dur * 0.75].map((t) => +t.toFixed(2));
  }
  times = [...new Set(times)].slice(0, 16).sort((a, b) => a - b);

  const meta = await sharp(
    path.join(
      project,
      "frames_bg",
      fs
        .readdirSync(path.join(project, "frames_bg"))
        .filter((f) => f.endsWith(".png"))
        .sort()[0],
    ),
  ).metadata();
  const W = meta.width,
    H = meta.height;
  const outDir = path.join(project, "preview");
  fs.mkdirSync(outDir, { recursive: true });

  const exe =
    process.platform === "darwin"
      ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
      : "/usr/bin/google-chrome";
  const browser = await puppeteer.launch({
    headless: "new",
    executablePath: fs.existsSync(exe) ? exe : undefined,
    args: ["--disable-web-security", "--allow-file-access-from-files", "--disable-dev-shm-usage"],
  });
  const outs = [];
  try {
    for (const t of times) {
      const fi = Math.max(1, Math.round(t * fps));
      const bg = path.join(project, "frames_bg", `f_${String(fi).padStart(4, "0")}.png`);
      const fg = path.join(project, "frames_fg", `f_${String(fi).padStart(4, "0")}.png`);
      if (!fs.existsSync(bg)) {
        console.error(`[preview] no bg frame for t=${t}`);
        continue;
      }
      const layers = [{ input: await shotAt(browser, idx, W, H, t) }]; // embed captions
      // global caption_layer:"fg" → captions sit ON TOP of the subject; the matte
      // must NOT be stacked over them (the render skips the overlay too).
      if (!globalFg && fs.existsSync(fg)) layers.push({ input: fg }); // subject occludes embed
      if (hasFg) layers.push({ input: await shotAt(browser, fgP, W, H, t), blend: "screen" }); // hybrid fg caps in front (screen, like the real ffmpeg pass)
      if (hasRail) layers.push({ input: await shotAt(browser, railP, W, H, t) }); // rail in front
      const out = path.join(outDir, `t${String(t).replace(".", "_")}.png`);
      await sharp(bg).composite(layers).png().toFile(out);
      outs.push({ t, out });
      console.log(`[preview] t=${t}s → ${out}`);
    }
    // contact sheet
    if (outs.length) {
      const TW = 480,
        TH = Math.round((TW * H) / W);
      const comps = [];
      for (let i = 0; i < outs.length; i++)
        comps.push({
          input: await sharp(outs[i].out).resize(TW, TH).toBuffer(),
          left: (i % 4) * TW,
          top: Math.floor(i / 4) * TH,
        });
      const rows = Math.ceil(outs.length / 4);
      await sharp({
        create: {
          width: 4 * TW,
          height: rows * TH,
          channels: 3,
          background: { r: 16, g: 16, b: 16 },
        },
      })
        .composite(comps)
        .png()
        .toFile(path.join(outDir, "sheet.png"));
      console.log(
        `[preview] contact sheet → ${path.join(outDir, "sheet.png")}  (${outs.length} frames @ ${times.join(", ")}s)`,
      );
    }
  } finally {
    await Promise.race([browser.close().catch(() => {}), new Promise((r) => setTimeout(r, 8000))]);
  }
}
main()
  .then(() => process.exit(0))
  .catch((e) => {
    console.error("[preview]", e.message);
    process.exit(1);
  });
scripts/render-and-composite.sh
#!/usr/bin/env bash
# End-to-end: hyperframes render + ffmpeg overlay matte → final.mp4
#
# Usage:
#   bash render-and-composite.sh <project-dir> [hyperframes-repo-path]
#
# Env:
#   HYPERFRAMES_ROOT  override the hyperframes checkout location

set -euo pipefail

PROJECT="${1:?usage: render-and-composite.sh <project-dir> [hyperframes-repo]}"
PROJECT="$(cd "$PROJECT" && pwd)"

# Resolve the hyperframes checkout. Candidate order:
#   1. arg 2   2. $HYPERFRAMES_ROOT   3. repo root if this skill ships INSIDE the
#   hyperframes repo (skills/embedded-captions/scripts → ../../..)   4. ~/Downloads/hyperframes
SKILL_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HF=""
for cand in "${2:-}" "${HYPERFRAMES_ROOT:-}" "$(cd "$SKILL_SCRIPT_DIR/../../.." 2>/dev/null && pwd)" "$HOME/Downloads/hyperframes"; do
  if [[ -n "$cand" && -f "$cand/packages/cli/dist/cli.js" ]]; then HF="$cand"; break; fi
done
if [[ -z "$HF" ]]; then
  echo "[render] hyperframes CLI not found. Set HYPERFRAMES_ROOT to your hyperframes" >&2
  echo "         checkout (needs packages/cli/dist/cli.js — 'bun install && bun run build')." >&2
  exit 1
fi
export HYPERFRAMES_ROOT="$HF"   # so the occlusion gate's measure-layout.cjs finds puppeteer too
HF_CLI="$HF/packages/cli/dist/cli.js"
if [[ ! -d "$PROJECT/frames_fg" ]]; then
  echo "[render] missing matte frames at $PROJECT/frames_fg — run matte.cjs first" >&2
  exit 1
fi
# Which compiler owns this project? Cinematic = make-composition.cjs (plan.json).
# (Standard mode retired 2026-06-12 — rail-surface needs are served by theme
# DNAs like "anchor"; legacy standard.json projects re-render from their
# existing index.html or via the archived compiler in embedded-captions-archive.)
compiler_for() {
  echo "make-composition.cjs"
}
if [[ ! -f "$PROJECT/index.html" ]]; then
  if [[ -f "$PROJECT/plan.json" ]]; then
    C="$(compiler_for)"
    echo "[render] no index.html — auto-compiling via $C"
    node "$(dirname "$0")/$C" "$PROJECT"
  elif [[ -f "$PROJECT/cinematic.json" ]]; then
    # cinematic.json-only project: the full compiler lowers blocks → plan.json → html
    echo "[render] no index.html — auto-compiling via make-cinematic.cjs"
    node "$(dirname "$0")/make-cinematic.cjs" "$PROJECT"
  else
    echo "[render] missing $PROJECT/index.html and standard.json/plan.json/cinematic.json — author + compile first" >&2
    exit 1
  fi
elif [[ -f "$PROJECT/cinematic.json" && "$PROJECT/cinematic.json" -nt "$PROJECT/index.html" ]]; then
  echo "[render] cinematic.json newer than index.html — recompiling"
  node "$(dirname "$0")/make-cinematic.cjs" "$PROJECT"
elif [[ -f "$PROJECT/plan.json" && "$PROJECT/plan.json" -nt "$PROJECT/index.html" ]]; then
  C="$(compiler_for)"
  echo "[render] plan.json newer than index.html — recompiling via $C"
  node "$(dirname "$0")/$C" "$PROJECT"
else
  # the COMPILERS themselves may have changed since this project last compiled —
  # rendering stale HTML after a compiler fix silently re-ships the old bug
  C="$(compiler_for)"
  RECOMPILER="$C"
  # for cinematic projects the FULL compiler is make-cinematic (compiler_for points
  # at make-composition, which only re-emits html from an already-compiled plan)
  if [[ -f "$PROJECT/cinematic.json" && "$C" == "make-composition.cjs" ]]; then RECOMPILER="make-cinematic.cjs"; fi
  ENGINE="$(dirname "$0")/../modes/cinematic/engine.html"
  if [[ "$(dirname "$0")/$RECOMPILER" -nt "$PROJECT/index.html" || "$(dirname "$0")/lib-dna.cjs" -nt "$PROJECT/index.html" || ( -f "$ENGINE" && "$ENGINE" -nt "$PROJECT/index.html" ) ]]; then
    echo "[render] compiler/engine newer than index.html — recompiling via $RECOMPILER"
    node "$(dirname "$0")/$RECOMPILER" "$PROJECT"
  fi
fi

# Embed template fonts BEFORE the gates + render. hyperframes only auto-supplies
# its ~18 canonical fonts; every other template family (Anton, Bangers, VT323,
# Press Start 2P, …) silently falls back to a generic font on a clean/offline/CI
# machine — it only "looks right" locally when that font happens to be installed
# as a system font. inject-fonts inlines the @font-face (base64 woff2, from
# modes/standard/fonts/fonts.css) for whatever non-canonical families each HTML
# actually uses, so measure-layout AND the capture see the true glyph metrics.
# Idempotent; a no-op when every font is canonical/system or already declared.
node "$(dirname "$0")/inject-fonts.cjs" "$PROJECT" \
  || echo "[render] (font embed skipped — inject-fonts.cjs/fonts.css unavailable)" >&2

# gate ledger — each gate appends one line; echoed as a summary before "done"
# (verdicts were drowning hundreds of lines up in ffmpeg logs)
GATES="$PROJECT/_gates.txt"; : > "$GATES"

# Gate: plan.json word timings must align with transcript.json within 80ms.
# A caption whose animation fires 500ms before or after the word is spoken
# breaks the "belongs to the scene" illusion — hard fail, not a warning.
# Skip only if transcript.json is missing (custom mode without transcript).
if [[ -f "$PROJECT/plan.json" && -f "$PROJECT/transcript.json" ]]; then
  if ! node "$(dirname "$0")/check-timing.cjs" "$PROJECT" --strict; then
    echo "[render] ABORTED — fix plan.json word timings to match transcript.json, then re-run." >&2
    exit 2
  fi
  echo "timing            PASS (strict)" >> "$GATES"
fi

# Gate: subject occlusion + frame-edge overflow — pixel-perfect via Chromium DOM
# rects (measure-layout.cjs) × the subject-matte alpha (sharp). Template mode only
# (skipped when plan.json is absent; custom mode uses check-overflow.cjs below).
if [[ -f "$PROJECT/plan.json" && -d "$PROJECT/frames_fg" ]]; then
  # check-occlusion prints its verdict, then can intermittently hang on native
  # (sharp/libvips) teardown — which would wedge the whole render. Run it under a
  # watchdog: once the verdict is printed, a stuck exit can't block us. We read the
  # pass/fail from its OUTPUT, not its exit code, so the hang is harmless.
  OCC_LOG="$PROJECT/_occlusion.log"
  node "$(dirname "$0")/check-occlusion.cjs" "$PROJECT" --strict > "$OCC_LOG" 2>&1 &
  OCC_PID=$!; occ_t0=$SECONDS; OCC_RC=""; verdict_at=""
  while kill -0 "$OCC_PID" 2>/dev/null; do
    # mark when the verdict header is printed (analysis done). if-form: a failed grep
    # must NOT trip set -e (it silently killed the whole render on fresh projects).
    if [[ -z "$verdict_at" ]] && grep -qE '\[v2\].*word-fail' "$OCC_LOG" 2>/dev/null; then verdict_at=$SECONDS; fi
    # verdict printed but still alive 8s later → native (sharp) teardown hang; or no
    # verdict after 150s → measure/analysis stuck. Either way: kill + read verdict.
    if { [[ -n "$verdict_at" ]] && (( SECONDS - verdict_at > 8 )); } || (( SECONDS - occ_t0 > 150 )); then
      kill -9 "$OCC_PID" 2>/dev/null
      if grep -q 'cap(s) FAIL' "$OCC_LOG"; then OCC_RC=2; else OCC_RC=0; fi
      echo "[render] occlusion gate hung after verdict (sharp teardown) — killed zombie; verdict rc=$OCC_RC" >&2
      break
    fi
    sleep 2
  done
  if [[ -z "$OCC_RC" ]]; then OCC_RC=0; wait "$OCC_PID" 2>/dev/null || OCC_RC=$?; fi   # capture rc without tripping set -e
  cat "$OCC_LOG"
  if (( OCC_RC != 0 )); then
    echo "[render] ABORTED — fix plan.json layout to reduce subject occlusion / frame-edge overflow, then re-run." >&2
    echo "         Override: OCCLUSION_SKIP=1 bash render-and-composite.sh <project>" >&2
    if [[ "${OCCLUSION_SKIP:-0}" != "1" ]]; then
      exit 2
    fi
    echo "[render] OCCLUSION_SKIP=1 set — continuing despite occlusion/overflow warnings." >&2
    echo "occlusion+overflow OVERRIDDEN (OCCLUSION_SKIP=1 — conscious accept)" >> "$GATES"
  fi
  if (( OCC_RC == 0 )); then echo "occlusion+overflow PASS" >> "$GATES"; fi
fi

# Custom mode (no plan.json) skips the template gates above. Run a lightweight,
# mode-agnostic frame-overflow check as a WARNING only — custom designs may bleed
# off-frame intentionally, so it never aborts, but it surfaces captions that fall
# off the canvas (the failure we otherwise only catch by eye).
if [[ ! -f "$PROJECT/plan.json" && -f "$PROJECT/index.html" && -f "$(dirname "$0")/check-overflow.cjs" ]]; then
  node "$(dirname "$0")/check-overflow.cjs" "$PROJECT" \
    || echo "[render] (overflow check skipped — Chromium/puppeteer unavailable)" >&2
  echo "overflow(index)   checked (custom mode, warning-only)" >> "$GATES"
fi
# rail.html gets NO other automated coverage (the occlusion gate only reads plan.json
# caps) — run its overflow check whenever it exists. Was dead code inside the
# no-plan.json branch: Standard always HAS a derived plan.json, so it never ran.
if [[ -f "$PROJECT/rail.html" && -f "$(dirname "$0")/check-overflow.cjs" ]]; then
  if node "$(dirname "$0")/check-overflow.cjs" "$PROJECT" rail.html; then
    echo "overflow(rail)    PASS" >> "$GATES"
  else
    echo "[render] (rail overflow check skipped — Chromium/puppeteer unavailable)" >&2
    echo "overflow(rail)    skipped (infra)" >> "$GATES"
  fi
fi

# Standard hand-off gate: the PROMOTED climax word must NOT also be revealed in the
# rail during the climax's on-screen window (PIPELINE.md "Rail ↔ climax hand-off").
# Hard-fails on a CONFIRMED duplicate; infra issues (no puppeteer, etc.) exit 0 and
# never block. Override with RAIL_CLIMAX_SKIP=1 for a deliberate exception.
if [[ -f "$PROJECT/rail.html" && -f "$PROJECT/index.html" && -f "$(dirname "$0")/check-rail-climax.cjs" ]]; then
  if ! node "$(dirname "$0")/check-rail-climax.cjs" "$PROJECT"; then
    echo "[render] ABORTED — the promoted climax word is duplicated in the rail." >&2
    echo "         Apply the rail↔climax hand-off (PIPELINE.md), then re-run." >&2
    echo "         Override: RAIL_CLIMAX_SKIP=1 bash render-and-composite.sh <project>" >&2
    if [[ "${RAIL_CLIMAX_SKIP:-0}" != "1" ]]; then
      exit 2
    fi
    echo "[render] RAIL_CLIMAX_SKIP=1 — continuing despite the rail/climax duplicate." >&2
    echo "rail-climax       OVERRIDDEN (RAIL_CLIMAX_SKIP=1)" >> "$GATES"
  else
    echo "rail-climax       PASS (no duplicate reveal)" >> "$GATES"
  fi
fi

# FPS: matte.fps (written by matte.cjs at the source's NATIVE rate) is authoritative
# so the matte overlay stays frame-aligned with the render. Falls back to plan.fps /
# frame-count inference / 24. Warn if plan.json fps disagrees with the matte.
FPS=""
if [[ -f "$PROJECT/matte.fps" ]]; then
  FPS="$(tr -dc '0-9' < "$PROJECT/matte.fps")"
  if [[ -f "$PROJECT/plan.json" ]]; then
    PFPS="$(node -e 'try{process.stdout.write(String(require(process.argv[1]).fps??""))}catch(e){}' "$PROJECT/plan.json" 2>/dev/null || true)"
    if [[ -n "$PFPS" && "$PFPS" != "$FPS" ]]; then
      echo "[render] WARN: plan.json fps=$PFPS != matte fps=$FPS — using matte fps to keep occlusion aligned" >&2
    fi
  fi
fi
if [[ -z "$FPS" || "$FPS" == "0" ]]; then
  if [[ -f "$PROJECT/plan.json" ]]; then
    FPS="$(node -e 'process.stdout.write(String(require(process.argv[1]).fps??24))' "$PROJECT/plan.json")"
  elif [[ -d "$PROJECT/frames_fg" ]]; then
    N="$(ls "$PROJECT/frames_fg" | wc -l | tr -d ' ')"
    DUR="$(grep -oE 'data-duration="[0-9.]+"' "$PROJECT/index.html" | head -1 | grep -oE '[0-9.]+' || echo '')"
    if [[ -n "$DUR" && "$N" -gt 0 ]]; then
      FPS="$(awk "BEGIN{printf \"%d\", $N/$DUR + 0.5}")"
    else
      FPS=24
    fi
  else
    FPS=24
  fi
fi

# Caption layer: "bg" (classic embed — matte overlays subject on top of caps)
# or "fg" (captions always on top — announcement feel, used for 9:16 portraits
# where the subject fills the frame and bg mode loses too much to occlusion).
# Precedence: CLI env flag > plan.json > HTML data attribute > "bg".
CAPTION_LAYER="${CAPTION_LAYER_FLAG:-}"
if [[ -z "$CAPTION_LAYER" && -f "$PROJECT/plan.json" ]]; then
  CAPTION_LAYER="$(node -e 'process.stdout.write(String(require(process.argv[1]).caption_layer??"bg"))' "$PROJECT/plan.json")"
fi
if [[ -z "$CAPTION_LAYER" && -f "$PROJECT/index.html" ]]; then
  ATTR="$(grep -oE 'data-caption-layer="(bg|fg)"' "$PROJECT/index.html" | head -1 | grep -oE '(bg|fg)' || true)"
  if [[ -n "$ATTR" ]]; then CAPTION_LAYER="$ATTR"; fi
fi
CAPTION_LAYER="${CAPTION_LAYER:-bg}"
echo "[render] caption_layer=$CAPTION_LAYER"

BG="$PROJECT/bg_plus_caps.mp4"
FINAL="$PROJECT/final.mp4"

# Snapshot the current index.html + plan.json into history/ so the user
# can recover a prior iteration's design after further edits overwrite it.
HISTORY_DIR="$PROJECT/history"
mkdir -p "$HISTORY_DIR"
STAMP="$(date +%Y%m%d-%H%M%S)"
cp "$PROJECT/index.html" "$HISTORY_DIR/index-${STAMP}.html"
cp "$PROJECT/plan.json"  "$HISTORY_DIR/plan-${STAMP}.json" 2>/dev/null || true
echo "[render] snapshot → history/index-${STAMP}.html"

echo "[render] hyperframes render @ ${FPS}fps"

# Hyperframes occasionally hangs on Chromium shutdown *after* the output file
# is successfully written (seen multiple times on 15–30s clips). Without a
# guard the shell waits forever. This helper enforces a max wall-clock budget,
# and if the output is already on disk when we hit it, treats the run as
# successful and kills the zombie. Tune HF_TIMEOUT_S via env if needed.
# Default SCALES with clip size: two parallel Chromium passes on a long clip
# legitimately exceed a fixed 240s (a 38s/1151-frame render was killed at 244s
# while healthy). ~1.5s per source frame, floor 240s.
N_FRAMES="$(ls "$PROJECT/frames_fg" 2>/dev/null | wc -l | tr -d ' ')"
HF_TIMEOUT_S="${HF_TIMEOUT_S:-$(( N_FRAMES * 3 / 2 > 240 ? N_FRAMES * 3 / 2 : 240 ))}"
# hf_render_dir: render one hyperframes composition.
# args: <output.mp4> <label> <project_dir>
# watches for the Chromium-shutdown-hang; if output file exists and is >1MB
# past timeout, treats as success and kills the zombie.
hf_render_dir() {
  local out="$1" label="$2" proj="$3" fmt="${4:-}"
  # bash 3.2 (macOS) throws on empty-array expansion under `set -u`, so branch
  # explicitly instead of splatting an optional --format array.
  if [[ -n "$fmt" ]]; then
    node "$HF_CLI" render --skill=embedded-captions --dir "$proj" --fps "$FPS" --format "$fmt" --crf 11 -o "$out" &
  else
    node "$HF_CLI" render --skill=embedded-captions --dir "$proj" --fps "$FPS" --crf 11 -o "$out" &
  fi
  local pid=$! start=$SECONDS elapsed
  while kill -0 "$pid" 2>/dev/null; do
    elapsed=$((SECONDS - start))
    if (( elapsed > HF_TIMEOUT_S )); then
      local sz=0
      [[ -f "$out" ]] && sz=$(stat -f%z "$out" 2>/dev/null || echo 0)
      if (( sz > 1000000 )); then
        echo "[render] ${label}: node hung ${elapsed}s after shutdown (output ${sz}B exists, treating as success)"
        kill -9 "$pid" 2>/dev/null
        pkill -9 -f "puppeteer_dev_chrome_profile" 2>/dev/null
        return 0
      fi
      echo "[render] ${label}: hung ${elapsed}s with no output — killing and failing" >&2
      kill -9 "$pid" 2>/dev/null
      pkill -9 -f "puppeteer_dev_chrome_profile" 2>/dev/null
      return 2
    fi
    sleep 5
  done
  wait "$pid" 2>/dev/null
  [[ -f "$out" ]]
}

# Link a project's assets into a shadow render dir EXCEPT the files we manage
# (the HTML we override + render outputs/intermediates). Links every other entry by
# its real name, so the shadow resolves whatever media the HTML references — including
# the ORIGINAL video filename `hyperframes init` scaffolds (e.g. clip.mp4), not just a
# fixed allow-list. Prevents the shadow-render 404 → silent/frozen output → abort.
link_assets() {  # <project> <shadow>
  local proj="$1" sh="$2" b
  for p in "$proj"/*; do
    [[ -e "$p" ]] || continue
    b="$(basename "$p")"
    case "$b" in
      index.html|rail.html|index_fg.html|final.mp4|bg_plus_caps.mp4|fg_caps.mp4|rail.webm|history|_*) continue;;
    esac
    ln -sf "$p" "$sh/$b"
  done
}

# Hybrid renders need 2 independent hyperframes passes. They share no state, so
# we run them in parallel (one in the main PROJECT, one in a shadow dir with
# index_fg.html renamed to index.html). Saves ~half of the Chromium cost.
FG_SHADOW=""
if [[ -f "$PROJECT/index_fg.html" ]]; then
  FG_SHADOW="$PROJECT/_fg_shadow"
  rm -rf "$FG_SHADOW" && mkdir -p "$FG_SHADOW"
  # Link shared assets (any media filename); copy index_fg.html into shadow as index.html.
  link_assets "$PROJECT" "$FG_SHADOW"
  cp "$PROJECT/index_fg.html" "$FG_SHADOW/index.html"

  FG_CAPS="$PROJECT/fg_caps.mp4"
  echo "[render] hybrid fg/bg — launching both passes in parallel"
  hf_render_dir "$BG" "bg_plus_caps" "$PROJECT" &
  BG_PID=$!
  hf_render_dir "$FG_CAPS" "fg_caps" "$FG_SHADOW" &
  FG_PID=$!
  # Wait for both; fail if either fails.
  BG_RC=0; wait "$BG_PID" || BG_RC=$?
  FG_RC=0; wait "$FG_PID" || FG_RC=$?
  rm -rf "$FG_SHADOW"
  if (( BG_RC != 0 )); then echo "[render] bg render failed" >&2; exit 1; fi
  if (( FG_RC != 0 )); then echo "[render] fg render failed" >&2; exit 1; fi
elif [[ -f "$PROJECT/rail.html" ]]; then
  # Standard mode: TWO independent hyperframes passes (base = index.html with the
  # embed; rail = rail.html transparent). Each renders from its own shadow dir (the
  # multiple-root ambiguity), and they share no state — run them IN PARALLEL like
  # the fg-hybrid above (~halves the Chromium wall time). The rail webm is consumed
  # by the composite stage below, which finds it already rendered.
  BASE_SHADOW="$PROJECT/_base_shadow"; rm -rf "$BASE_SHADOW"; mkdir -p "$BASE_SHADOW"
  link_assets "$PROJECT" "$BASE_SHADOW"
  cp "$PROJECT/index.html" "$BASE_SHADOW/index.html"
  RAIL_SHADOW="$PROJECT/_rail_shadow"; rm -rf "$RAIL_SHADOW"; mkdir -p "$RAIL_SHADOW"
  link_assets "$PROJECT" "$RAIL_SHADOW"
  cp "$PROJECT/rail.html" "$RAIL_SHADOW/index.html"
  RAIL_WEBM="$PROJECT/rail.webm"
  echo "[render] standard base + rail — launching both passes in parallel"
  hf_render_dir "$BG" "bg_plus_caps" "$BASE_SHADOW" &
  BASE_PID=$!
  hf_render_dir "$RAIL_WEBM" "rail" "$RAIL_SHADOW" "webm" &
  RAIL_PID=$!
  BASE_RC=0; wait "$BASE_PID" || BASE_RC=$?
  RAIL_RC=0; wait "$RAIL_PID" || RAIL_RC=$?
  rm -rf "$BASE_SHADOW" "$RAIL_SHADOW"
  if (( BASE_RC != 0 )); then echo "[render] bg render failed" >&2; exit 1; fi
  if (( RAIL_RC != 0 )); then echo "[render] rail render failed" >&2; exit 1; fi
else
  hf_render_dir "$BG" "bg_plus_caps" "$PROJECT" \
    || { echo "[render] bg render failed" >&2; exit 1; }
fi

# Probe render dims for ffmpeg scale
W="$(ffprobe -v error -select_streams v:0 -show_entries stream=width  -of default=nw=1:nk=1 -- "$BG")"
H="$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of default=nw=1:nk=1 -- "$BG")"

# Clamp every composite to the matte (= source-video) length. The render uses
# plan.duration / data-duration, which can exceed the source (e.g. Whisper word
# timestamps overrun the clip). Past the source the a-roll is gone (black) but the
# matte overlay repeats its last frame → the tail shows the subject floating on
# black. frames_fg count/fps APPROXIMATES the source length, but fractional-rate
# sources (29.97) get over-extracted by integer-fps rounding (e.g. 544 frames for
# a 542-frame 18.085s clip -> 544/30 = 18.133s -> ~1.5 trailing BLACK frames after
# the a-roll stream ends). The true a-roll duration is authoritative: clamp to
# min(matte frames / fps, source duration).
MATTE_DUR="$(awk "BEGIN{printf \"%.3f\", $(ls "$PROJECT/frames_fg" | wc -l)/$FPS}")"
SRC_DUR="$(ffprobe -v error -show_entries format=duration -of csv=p=0 -- "$PROJECT/source.mp4" 2>/dev/null || true)"
if [[ -n "${SRC_DUR:-}" ]]; then
  MATTE_DUR="$(awk "BEGIN{m=$MATTE_DUR; s=$SRC_DUR; printf \"%.3f\", (s>0 && s<m) ? s : m}")"
fi
echo "[render] clamp output to source/matte length: ${MATTE_DUR}s"

# Bug-1 guard: the background plate ($BG) must be at least the matte/source length,
# else the tail (where the bg ran out but the matte continues) shows ONLY the
# foreground subject on black. Cinematic auto-fixes this (make-composition sets the
# canvas = source length); this catches a hand-authored Standard duration set to the
# last-caption time instead of the clip length. Clamp to the bg length so we never
# ship the only-foreground tail, and tell the author the real fix.
if [[ -f "$BG" ]]; then
  BG_DUR="$(ffprobe -v error -show_entries format=duration -of default=nokey=1:noprint_wrappers=1 -- "$BG" 2>/dev/null | tr -dc '0-9.')"
  if [[ -n "$BG_DUR" ]] && awk "BEGIN{exit !($BG_DUR < $MATTE_DUR - 0.3)}"; then
    echo "[render] ⚠ background plate is ${BG_DUR}s but the clip is ${MATTE_DUR}s — the composition is shorter than the footage." >&2
    echo "         The tail would show ONLY the foreground subject on black. FIX: set the composition" >&2
    echo "         duration to the SOURCE clip length (data-duration on #root/#a-roll); captions may still" >&2
    echo "         end earlier. Clamping output to ${BG_DUR}s for now to avoid the broken tail." >&2
    MATTE_DUR="$BG_DUR"
  fi
fi

# ─────────────────────────────────────────────────────────────────────────────
# STANDARD mode (rail + embed) — detected by rail.html.
#   $BG = index.html rendered (source video + the embed climax).
#   (1) overlay the subject matte so the subject occludes the climax (embed = behind).
#   (2) render rail.html → transparent WebM (the verbatim rail).
#   (3) alpha-composite the rail IN FRONT so it is never occluded (rail = on top).
# The existing Cinematic paths below are untouched.
if [[ -f "$PROJECT/rail.html" ]]; then
  MATTED="$PROJECT/_matted.mp4"
  if [[ "$CAPTION_LAYER" == "fg" ]]; then
    # caption_layer:fg — the climax sits IN FRONT of the subject (no behind-subject
    # embed possible, e.g. a frame-filling 9:16 subject). bg_plus_caps already has the
    # climax drawn over the video, so we SKIP the matte overlay (which would push the
    # subject back in front of it). The rail still overlays on top below.
    echo "[render] STANDARD (rail + FRONT climax) — caption_layer=fg, matte overlay skipped (${W}x${H})"
    cp "$BG" "$MATTED"
  else
    echo "[render] STANDARD (rail + embed) — embed behind subject, rail alpha-overlaid in front (${W}x${H})"
    ffmpeg -y -i "$BG" \
      -framerate "$FPS" -i "$PROJECT/frames_fg/f_%04d.png" \
      -filter_complex "[1:v]scale=${W}:${H},format=yuva420p[m];[0:v][m]overlay=format=auto[v]" \
      -map "[v]" -map 0:a -r "$FPS" -t "$MATTE_DUR" -c:v libx264 -crf 11 -preset medium -c:a copy "$MATTED"
  fi

  # rail.webm was already rendered IN PARALLEL with the base pass above.
  RAIL_WEBM="$PROJECT/rail.webm"
  [[ -f "$RAIL_WEBM" ]] || { echo "[render] rail.webm missing (parallel rail pass failed?)" >&2; exit 1; }

  # alpha-overlay the transparent rail in front of the matted video (force vp9
  # decode so the WebM alpha plane is honoured by overlay)
  ffmpeg -y -i "$MATTED" -c:v libvpx-vp9 -i "$RAIL_WEBM" \
    -filter_complex "[0:v][1:v]overlay=format=auto[v]" \
    -map "[v]" -map 0:a -r "$FPS" -t "$MATTE_DUR" -c:v libx264 -crf 11 -preset medium -c:a copy "$FINAL"
  rm -f "$MATTED"
  if [[ -s "$GATES" ]]; then echo "[render] ── gates ──"; sed 's/^/[render]   /' "$GATES"; fi
echo "[render] done → $FINAL"
  exit 0
fi

# Decide composite mode:
#  - If make-composition emitted index_fg.html (any group has layer:fg),
#    use hybrid regardless of plan-level caption_layer.
#  - Else if caption_layer=fg globally, skip matte.
#  - Else (default), matte embed.
if [[ -f "$PROJECT/index_fg.html" ]]; then
  # Hybrid: bg_plus_caps and fg_caps were both rendered above in parallel.
  # Now composite: bg_plus_caps + matte (subject on top) + fg_caps (screen).
  FG_CAPS="$PROJECT/fg_caps.mp4"
  echo "[render] hybrid — composite (bg_plus_caps + matte + fg_caps[screen blend])"
  # fg_caps is bright caption on pure black. blend=screen makes it behave
  # like CSS mix-blend-mode: screen on the matted video — captions pick up
  # scene luminance, NOT a flat opaque overlay (which looks sticker-like).
  # Use rgb format to avoid YUV-space color drift, then convert back for libx264.
  ffmpeg -y -i "$BG" \
    -framerate "$FPS" -i "$PROJECT/frames_fg/f_%04d.png" \
    -i "$FG_CAPS" \
    -filter_complex "[1:v]scale=${W}:${H},format=yuva420p[matte];[0:v][matte]overlay=format=auto,format=gbrp[matted];[2:v]format=gbrp[fg];[matted][fg]blend=all_mode=screen,format=yuv420p[v]" \
    -map "[v]" -map 0:a \
    -r "$FPS" -t "$MATTE_DUR" -c:v libx264 -crf 12 -preset medium -c:a copy \
    "$FINAL"
elif [[ "$CAPTION_LAYER" == "fg" ]]; then
  # Global FG mode: skip matte overlay entirely. bg_plus_caps.mp4 already
  # has captions on top of a-roll. Re-encode for consistency.
  echo "[render] fg mode (global) — skipping matte, re-encoding ${W}x${H}"
  ffmpeg -y -i "$BG" \
    -r "$FPS" -t "$MATTE_DUR" -c:v libx264 -crf 12 -preset medium -c:a copy \
    "$FINAL"
else
  echo "[render] bg mode — overlay matte (${W}x${H})"
  ffmpeg -y -i "$BG" \
    -framerate "$FPS" -i "$PROJECT/frames_fg/f_%04d.png" \
    -filter_complex "[1:v]scale=${W}:${H},format=yuva420p[fg];[0:v][fg]overlay=format=auto[v]" \
    -map "[v]" -map 0:a \
    -r "$FPS" -t "$MATTE_DUR" -c:v libx264 -crf 12 -preset medium -c:a copy \
    "$FINAL"
fi

if [[ -s "$GATES" ]]; then echo "[render] ── gates ──"; sed 's/^/[render]   /' "$GATES"; fi
echo "[render] done → $FINAL"
scripts/render-theme.sh
#!/usr/bin/env bash
# render-theme.sh — compile + render + plate-reaction for THEME mode projects.
#   bash render-theme.sh <project-dir>
# Project needs: theme.json, transcript.json, source.mp4, frames_fg/, matte.fps
# Output: final_fx.mp4 (final.mp4 = before plate reaction)
set -euo pipefail
PROJECT="${1:?usage: render-theme.sh <project-dir>}"
PROJECT="$(cd "$PROJECT" && pwd)"
SD="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

node "$SD/make-theme.cjs" "$PROJECT"
# Gate honesty (cold-start E2E finding): theme mode enforces word timing at
# COMPILE time (sequential transcript matcher + verbatim completeness gate in
# make-theme.cjs — a mistimed/missing word is a compile error, not a render
# warning). The pixel occlusion gate is Cinematic-only; themes place heroes
# scene-aware (safe-zones) and are covered by overflow + rail-climax + the
# PAGEERROR gate below.
echo "[render-theme] gates: timing PASS (compile-enforced verbatim matcher)"
echo "[render-theme] gates: occlusion = scene-aware placement (pixel gate is Cinematic-only)"
RLOG="$PROJECT/_render-theme.log"
bash "$SD/render-and-composite.sh" "$PROJECT" 2>&1 | tee "$RLOG"
# GATE: a JS error in either generated layer silently yields a blank caption
# layer while every downstream gate still passes — fail loudly instead.
if grep -q "PAGEERROR" "$RLOG"; then
  echo "[render-theme] FAIL — Browser PAGEERROR in a generated layer (see $RLOG)." >&2
  echo "               The caption timeline did not build; the output is missing captions." >&2
  exit 2
fi
if [[ -f "$PROJECT/_postfx.sh" ]]; then
  bash "$PROJECT/_postfx.sh"
else
  cp "$PROJECT/final.mp4" "$PROJECT/final_fx.mp4"
fi
echo "[render-theme] done → $PROJECT/final_fx.mp4   (DELIVERABLE: final_fx.mp4; final.mp4 is the pre-plate-reaction cut)"
scripts/safe-zones.cjs
#!/usr/bin/env node
/*
 * safe-zones.cjs — PRECOMPUTE where captions can safely go, from the subject matte.
 *
 *   node safe-zones.cjs <project-dir>            → safe-zones.json (global + per-sentence windows) + summary
 *   node safe-zones.cjs <project-dir> <in> <out> → just that time window's zones (ad-hoc query)
 *
 * The inverse of check-occlusion: read the silhouette FIRST and hand the author the
 * clean regions + an embed-vs-fg verdict, so layout is right the first time instead of
 * after N occlusion-failure rounds.
 *
 * Uses the per-pixel ALPHA matte (not a bounding box): a cell counts as subject only
 * where the silhouette actually is, so the empty pocket beside the head / above the
 * shoulders stays FREE (a bbox would wrongly claim it). The subject MOVES, so zones are
 * computed PER TIME WINDOW (the union over just that window's frames), not one global box.
 */
const path = require("path");
const fs = require("fs");
const os = require("os");

const THRESH = 30 / 255; // a cell is "subject" if ≥12% covered at any sampled frame in the window
const SAMPLES = 48; // frames cached across the clip (windows aggregate the cached grids)

const HF_ROOTS = [
  process.env.HYPERFRAMES_ROOT,
  path.resolve(__dirname, "../../.."),
  path.join(os.homedir(), "Downloads", "hyperframes"),
].filter(Boolean);
let sharp = null;
for (const root of HF_ROOTS) {
  const cands = [path.join(root, "node_modules", "sharp")];
  const bunDir = path.join(root, "node_modules", ".bun");
  try {
    if (fs.existsSync(bunDir))
      for (const d of fs.readdirSync(bunDir))
        if (d.startsWith("sharp@")) cands.push(path.join(bunDir, d, "node_modules", "sharp"));
  } catch {}
  for (const c of cands) {
    try {
      if (fs.existsSync(c)) {
        sharp = require(c);
        break;
      }
    } catch {}
  }
  if (sharp) break;
}

// largest all-clear (1) rectangle within [c0,c1)×[r0,r1), in CELL units
function largestRect(safe, GW, c0, c1, r0, r1) {
  const cols = c1 - c0;
  const heights = new Array(cols).fill(0);
  let best = { area: 0, x: 0, y: 0, w: 0, h: 0 };
  for (let r = r0; r < r1; r++) {
    for (let cc = 0; cc < cols; cc++) heights[cc] = safe[r * GW + (c0 + cc)] ? heights[cc] + 1 : 0;
    const st = [];
    for (let cc = 0; cc <= cols; cc++) {
      const h = cc < cols ? heights[cc] : 0;
      let start = cc;
      while (st.length && st[st.length - 1].h >= h) {
        const top = st.pop();
        const area = top.h * (cc - top.i);
        if (area > best.area)
          best = { area, x: c0 + top.i, y: r - top.h + 1, w: cc - top.i, h: top.h };
        start = top.i;
      }
      st.push({ i: start, h });
    }
  }
  return best;
}

// turn a max-coverage grid into {coverage, subject, zones, recommendation}
function analyze(occ, GW, GH, W, H, lum, lumSeries) {
  const occCell = new Uint8Array(GW * GH);
  for (let c = 0; c < GW * GH; c++) occCell[c] = occ[c] >= THRESH ? 1 : 0;
  const safe = new Uint8Array(GW * GH); // 1-cell dilation margin around the silhouette
  for (let y = 0; y < GH; y++)
    for (let x = 0; x < GW; x++) {
      let o = 0;
      for (let dy = -1; dy <= 1 && !o; dy++)
        for (let dx = -1; dx <= 1 && !o; dx++) {
          const nx = x + dx,
            ny = y + dy;
          if (nx >= 0 && nx < GW && ny >= 0 && ny < GH && occCell[ny * GW + nx]) o = 1;
        }
      safe[y * GW + x] = o ? 0 : 1;
    }
  const occupied = occCell.reduce((a, b) => a + b, 0);
  const coverage = occupied / (GW * GH);
  let colMin = GW,
    colMax = -1,
    rowMin = GH,
    rowMax = -1;
  for (let x = 0; x < GW; x++)
    for (let y = 0; y < GH; y++)
      if (occCell[y * GW + x]) {
        if (x < colMin) colMin = x;
        if (x > colMax) colMax = x;
        if (y < rowMin) rowMin = y;
        if (y > rowMax) rowMax = y;
      }
  if (colMax < 0) {
    colMin = 0;
    colMax = -1;
    rowMin = 0;
    rowMax = -1;
  }
  const clearerSide = colMin >= GW - 1 - colMax ? "left" : "right";
  const cellW = W / GW,
    cellH = H / GH;
  // HERO anchor — where the ONE big promoted word should sit: ON the subject (centered,
  // crossing it so the head/torso occludes the middle). The OPPOSITE of the clean zones
  // above (those are for narration). A wide band ≈ centered on the subject, vertically
  // crossing the head/upper torso. Only meaningful when there IS a subject.
  let heroAnchor = null;
  if (colMax >= 0) {
    const subjCx = ((colMin + colMax + 1) / 2 / GW) * 100;
    const subjWpct = ((colMax - colMin + 1) / GW) * 100;
    const yTop = (rowMin / GH) * 100,
      yBot = ((rowMax + 1) / GH) * 100;
    const wPct = Math.round(Math.min(92, Math.max(58, subjWpct + 26)));
    const xPct = Math.round(Math.min(98 - wPct, Math.max(2, subjCx - wPct / 2)));
    const yPct = Math.round(yTop + (yBot - yTop) * 0.12); // band crosses the head / upper torso
    let bandLuma = null;
    if (lum) {
      const y0 = Math.round((yPct / 100) * GH),
        y1 = Math.min(GH, y0 + Math.max(2, Math.round(GH * 0.14)));
      const x0 = Math.round((xPct / 100) * GW),
        x1 = Math.min(GW, Math.round(((xPct + wPct) / 100) * GW));
      // luma of the BACKGROUND cells only — that's where the hero's glyphs are visible
      // (the subject-occluded middle doesn't show text; averaging it in hides washout).
      let s2 = 0,
        n = 0;
      for (let y = y0; y < y1; y++)
        for (let x = x0; x < x1; x++) {
          if (occCell[y * GW + x]) continue;
          s2 += lum[y * GW + x];
          n++;
        }
      bandLuma = n ? Math.round(s2 / n) : null;
    }
    heroAnchor = {
      centerXPct: +subjCx.toFixed(1),
      plane: { xPct, yPct, wPct, align: "center" },
      ...(bandLuma != null ? { bandLuma, washoutRisk: bandLuma > 175 } : {}),
      note:
        "Place the ONE big hero here (centered on the subject); the head/torso occludes its middle (~30-55%) — that is the embed. Do NOT put the hero in a clean zone." +
        (bandLuma != null && bandLuma > 175
          ? " ⚠ BAND IS BRIGHT (luma " +
            bandLuma +
            "): cream/screen text will wash out — lower the hero onto the darker subject body, or use a template/mode with opaque text."
          : ""),
    };
  }
  const zoneLuma = (r) => {
    if (!lum || r.area === 0) return null;
    let s2 = 0,
      n = 0;
    for (let y = r.y; y < r.y + r.h; y++)
      for (let x = r.x; x < r.x + r.w; x++) {
        s2 += lum[y * GW + x];
        n++;
      }
    return n ? Math.round(s2 / n) : null;
  };
  // a TIME-AVERAGED map walks a moving minefield: a dark wall swept by a bright
  // moving object (handheld drift, screens) averages "clean" while peaking hot.
  // peakLuma = p95 of the zone's per-sample mean over the window.
  const zoneLumaPeak = (r) => {
    if (!lumSeries || !lumSeries.length || r.area === 0) return null;
    const means = lumSeries
      .map((Lg) => {
        let s2 = 0,
          n = 0;
        for (let y = r.y; y < r.y + r.h; y++)
          for (let x = r.x; x < r.x + r.w; x++) {
            s2 += Lg[y * GW + x];
            n++;
          }
        return n ? s2 / n : 0;
      })
      .sort((a, b) => a - b);
    return Math.round(means[Math.max(0, Math.ceil(means.length * 0.95) - 1)]);
  };
  const toZone = (r) =>
    r.area === 0
      ? null
      : {
          xPct: +((r.x / GW) * 100).toFixed(1),
          yPct: +((r.y / GH) * 100).toFixed(1),
          wPct: +((r.w / GW) * 100).toFixed(1),
          hPct: +((r.h / GH) * 100).toFixed(1),
          areaPct: +(((r.w * r.h) / (GW * GH)) * 100).toFixed(1),
          px: {
            x: Math.round(r.x * cellW),
            y: Math.round(r.y * cellH),
            w: Math.round(r.w * cellW),
            h: Math.round(r.h * cellH),
          },
          ...(zoneLuma(r) != null
            ? {
                meanLuma: zoneLuma(r),
                bright: zoneLuma(r) > 180,
                ...(zoneLumaPeak(r) != null ? { peakLuma: zoneLumaPeak(r) } : {}),
              }
            : {}),
        };
  const zones = {
    largest: toZone(largestRect(safe, GW, 0, GW, 0, GH)),
    left: toZone(largestRect(safe, GW, 0, Math.round(GW / 2), 0, GH)),
    right: toZone(largestRect(safe, GW, Math.round(GW / 2), GW, 0, GH)),
    top: toZone(largestRect(safe, GW, 0, GW, 0, Math.max(2, Math.round(GH * 0.38)))),
  };
  // HUGGING zones — clean strips that ABUT the silhouette (the embed aesthetic wants
  // text NEAR the subject, not parked in the farthest corner). Grown outward from the
  // subject's edge at upper-body height; prefer these for narration.
  const hug = (side) => {
    if (colMax < 0) return null;
    const pad = Math.max(1, Math.round(GW * 0.02));
    const y0 = rowMin,
      y1 = Math.min(GH, rowMin + Math.max(3, Math.round((rowMax - rowMin + 1) * 0.45)));
    let x0, x1;
    if (side === "right") {
      x0 = Math.min(GW - 1, colMax + 1 + pad);
      x1 = GW - pad;
    } else {
      x1 = Math.max(1, colMin - pad);
      x0 = pad;
    }
    if (x1 - x0 < Math.round(GW * 0.1)) return null;
    // shrink until actually clean (≤8% occupied cells)
    let occN = 0,
      tot = 0;
    for (let y = y0; y < y1; y++)
      for (let x = x0; x < x1; x++) {
        tot++;
        if (occCell[y * GW + x]) occN++;
      }
    if (tot === 0 || occN / tot > 0.08) return null;
    const r = { x: x0, y: y0, w: x1 - x0, h: y1 - y0, area: (x1 - x0) * (y1 - y0) };
    const z2 = toZone(r);
    // GLYPHS must hug, not just the plane: in a wide column, text aligned to the far
    // edge parks the words a third of the frame away from the subject. Align TOWARD
    // the silhouette: right-side column → text-align:left (text starts beside the
    // subject); left-side column → text-align:right.
    if (z2) z2.align = side === "right" ? "left" : "right";
    return z2;
  };
  zones.hugLeft = hug("left");
  zones.hugRight = hug("right");
  // HERO BAND PROFILE — per-height predicted occlusion of a centered hero band. The hero
  // WANTS ~30–55% (occlusion IS the embed); fg is the LAST resort, only when no height
  // achieves ≤62%. Even an 88%-coverage frame usually has a feasible band over the hairline.
  let heroBands = null;
  if (colMax >= 0) {
    const bandH = Math.max(2, Math.round(GH * 0.13));
    const hx0 = Math.round(((heroAnchor ? heroAnchor.plane.xPct : 8) / 100) * GW);
    const hx1 = Math.min(
      GW,
      Math.round(((heroAnchor ? heroAnchor.plane.xPct + heroAnchor.plane.wPct : 92) / 100) * GW),
    );
    const profile = [];
    for (let y0 = 0; y0 + bandH <= GH; y0 += Math.max(1, Math.round(GH * 0.02))) {
      let n = 0,
        occN = 0,
        lsum = 0;
      for (let y = y0; y < y0 + bandH; y++)
        for (let x = hx0; x < hx1; x++) {
          n++;
          if (occCell[y * GW + x]) occN++;
          if (lum) lsum += lum[y * GW + x];
        }
      profile.push({
        topPct: +((y0 / GH) * 100).toFixed(1),
        occPct: +((occN / n) * 100).toFixed(1),
        ...(lum ? { bgLuma: Math.round(lsum / n) } : {}),
      });
    }
    const ok = profile.filter((b) => b.occPct >= 12 && b.occPct <= 62);
    const best = (ok.length ? ok : profile).reduce((a, b) =>
      Math.abs(b.occPct - 40) < Math.abs(a.occPct - 40) ? b : a,
    );
    heroBands = { feasible: ok.length > 0, best, profile };
  }
  const big = zones.largest;
  const embeddable = !!big && big.areaPct >= 8 && big.hPct >= 10 && big.wPct >= 18;
  return {
    coverage: +(coverage * 100).toFixed(1),
    subject: {
      colMinPct: +((colMin / GW) * 100).toFixed(1),
      colMaxPct: +(((colMax + 1) / GW) * 100).toFixed(1),
      clearerSide,
    },
    zones,
    heroAnchor,
    heroBands,
    recommendation: embeddable ? "embed" : "fg",
  };
}

// ── SCENE OPTICS + PALETTE (v2) ──────────────────────────────────────────────
// Deterministic scene measurements that drive the DNA tokens, so "design that fits
// the scene" is a pipeline product, not agent inspiration:
//   palette  — dominant scene colors + a READABLE accent suggestion (sampled, then
//              clamped to usable saturation/lightness) + warm/cool temperature
//   optics   — background vs subject sharpness (Laplacian proxy) → suggested text
//              blur so embed type matches the scene's depth-of-field
//   lighting — bright-side estimate → contact-shadow direction for embed type

function rgb2hsv(r, g, b) {
  r /= 255;
  g /= 255;
  b /= 255;
  const mx = Math.max(r, g, b),
    mn = Math.min(r, g, b),
    d = mx - mn;
  let h = 0;
  if (d > 0) {
    if (mx === r) h = ((g - b) / d) % 6;
    else if (mx === g) h = (b - r) / d + 2;
    else h = (r - g) / d + 4;
    h *= 60;
    if (h < 0) h += 360;
  }
  return { h, s: mx === 0 ? 0 : d / mx, v: mx };
}
function hsv2hex(h, s, v) {
  const c = v * s,
    x = c * (1 - Math.abs(((h / 60) % 2) - 1)),
    m = v - c;
  let [r, g, b] =
    h < 60
      ? [c, x, 0]
      : h < 120
        ? [x, c, 0]
        : h < 180
          ? [0, c, x]
          : h < 240
            ? [0, x, c]
            : h < 300
              ? [x, 0, c]
              : [c, 0, x];
  const f = (n) =>
    Math.round((n + m) * 255)
      .toString(16)
      .padStart(2, "0");
  return `#${f(r)}${f(g)}${f(b)}`;
}

// dominant colors + accent suggestion from the BACKGROUND cells of a mid frame
async function scenePalette(bgPath, occCell, GW, GH) {
  const { data, info } = await sharp(bgPath)
    .resize(GW, GH, { fit: "fill" })
    .removeAlpha()
    .raw()
    .toBuffer({ resolveWithObject: true });
  const ch = info.channels;
  const cells = [];
  for (let c = 0; c < GW * GH; c++) {
    if (occCell && occCell[c]) continue; // background only
    cells.push([data[c * ch], data[c * ch + 1], data[c * ch + 2]]);
  }
  if (!cells.length) return null;
  // dominant: quantize to 3 bits/channel, top buckets by count
  const buckets = new Map();
  for (const [r, g, b] of cells) {
    const k = ((r >> 5) << 6) | ((g >> 5) << 3) | (b >> 5);
    const e = buckets.get(k) || { n: 0, r: 0, g: 0, b: 0 };
    e.n++;
    e.r += r;
    e.g += g;
    e.b += b;
    buckets.set(k, e);
  }
  const hex = (e) =>
    "#" +
    [e.r, e.g, e.b]
      .map((x) =>
        Math.round(x / e.n)
          .toString(16)
          .padStart(2, "0"),
      )
      .join("");
  const dominant = [...buckets.values()]
    .sort((a, b) => b.n - a.n)
    .slice(0, 3)
    .map((e) => ({ hex: hex(e), sharePct: +((e.n / cells.length) * 100).toFixed(1) }));
  // chromatic accent: hue histogram over saturated cells, weighted s·v
  const bins = Array.from({ length: 12 }, () => ({ w: 0, h: 0, s: 0, v: 0, n: 0 }));
  let warmW = 0,
    coolW = 0;
  for (const [r, g, b] of cells) {
    const { h, s, v } = rgb2hsv(r, g, b);
    if (h <= 90 || h >= 330) warmW += s * v;
    else if (h >= 150 && h <= 300) coolW += s * v;
    if (s < 0.18 || v < 0.12 || v > 0.97) continue;
    const bi = Math.floor(h / 30) % 12,
      w = s * v;
    const B = bins[bi];
    B.w += w;
    B.h += h * w;
    B.s += s * w;
    B.v += v * w;
    B.n++;
  }
  const top = bins.reduce((a, b) => (b.w > a.w ? b : a));
  let accent = null;
  if (top.w > 0.5 && top.n >= 3) {
    const h = top.h / top.w,
      s = top.s / top.w,
      v = top.v / top.w;
    // clamp to a readable accent: saturated enough to read as a choice, light enough to sit on video
    accent = hsv2hex(
      h,
      Math.min(0.78, Math.max(0.5, s * 1.2)),
      Math.min(0.8, Math.max(0.55, v * 1.15)),
    );
  }
  const temperature = warmW > coolW * 1.25 ? "warm" : coolW > warmW * 1.25 ? "cool" : "neutral";
  return { dominant, accentSuggestion: accent, temperature };
}

// Laplacian-stdev sharpness proxy of a region crop (full-res)
async function regionSharpness(imgPath, rect, W, H) {
  const x = Math.max(0, Math.min(W - 2, Math.round(rect.x))),
    y = Math.max(0, Math.min(H - 2, Math.round(rect.y)));
  const w = Math.max(2, Math.min(W - x, Math.round(rect.w))),
    h = Math.max(2, Math.min(H - y, Math.round(rect.h)));
  // two passes: crop to a buffer FIRST, then convolve+stats on the crop — sharp's
  // internal pipeline ordering otherwise convolves/stats the full frame and the two
  // regions measure identical.
  const crop = await sharp(imgPath)
    .extract({ left: x, top: y, width: w, height: h })
    .png()
    .toBuffer();
  const st = await sharp(crop)
    .greyscale()
    .convolve({ width: 3, height: 3, kernel: [0, 1, 0, 1, -4, 1, 0, 1, 0], scale: 1, offset: 128 })
    .stats();
  return st.channels[0].stdev;
}

async function sceneOptics(project, bgPath, fgPath, zones, subjectBox, W, H) {
  let bgSharp = null,
    subjSharp = null;
  const bigZone = zones && zones.largest && zones.largest.px;
  try {
    if (bigZone && bigZone.w >= 64 && bigZone.h >= 64)
      bgSharp = await regionSharpness(bgPath, bigZone, W, H);
  } catch {}
  try {
    if (subjectBox && subjectBox.w >= 64)
      subjSharp = await regionSharpness(bgPath, subjectBox, W, H);
  } catch {}
  let suggestedTextBlurPx = 0,
    ratio = null;
  if (bgSharp != null && subjSharp != null && subjSharp > 1) {
    ratio = +(bgSharp / subjSharp).toFixed(3);
    // strong bokeh → text in that depth plane should soften to match
    suggestedTextBlurPx = ratio < 0.35 ? 1.6 : ratio < 0.55 ? 1.0 : ratio < 0.8 ? 0.5 : 0;
  }
  return {
    bgSharpness: bgSharp != null ? +bgSharp.toFixed(2) : null,
    subjSharpness: subjSharp != null ? +subjSharp.toFixed(2) : null,
    sharpnessRatio: ratio,
    suggestedTextBlurPx,
  };
}

// bright-side estimate from the luminance grid → contact-shadow vector (shadow falls AWAY from light)
function sceneLighting(lum, occCell, GW, GH) {
  if (!lum) return null;
  let sw = 0,
    sx = 0,
    sy = 0,
    n = 0,
    mean = 0;
  for (let c = 0; c < GW * GH; c++) {
    if (!occCell[c]) {
      mean += lum[c];
      n++;
    }
  }
  if (!n) return null;
  mean /= n;
  for (let y = 0; y < GH; y++)
    for (let x = 0; x < GW; x++) {
      const c = y * GW + x;
      if (occCell[c]) continue;
      const w = Math.max(0, lum[c] - mean);
      sw += w;
      sx += w * (x / GW - 0.5);
      sy += w * (y / GH - 0.5);
    }
  if (sw < 1) return { lightFrom: "flat", shadow: { dx: 0, dy: 3 } };
  const lx = sx / sw,
    ly = sy / sw; // light centroid offset from center, −0.5..0.5
  const mag = Math.hypot(lx, ly);
  if (mag < 0.04) return { lightFrom: "frontal", shadow: { dx: 0, dy: 3 } };
  // shadow direction = opposite the light, scaled to a subtle px offset
  const s = Math.min(1, mag / 0.25);
  const dx = Math.round((-lx / mag) * 4 * s),
    dy = Math.round(Math.max(1, (-ly / mag) * 4 * s + 2));
  const compass =
    Math.abs(lx) > Math.abs(ly) * 1.8
      ? lx > 0
        ? "right"
        : "left"
      : Math.abs(ly) > Math.abs(lx) * 1.8
        ? ly > 0
          ? "below"
          : "above"
        : `${ly > 0 ? "lower" : "upper"}-${lx > 0 ? "right" : "left"}`;
  return { lightFrom: compass, shadow: { dx, dy } };
}

// split the transcript into sentence windows (punctuation, or a > 0.7s gap)
function sentenceWindows(project) {
  const tp = path.join(project, "transcript.json");
  if (!fs.existsSync(tp)) return [];
  let words;
  try {
    words = (JSON.parse(fs.readFileSync(tp, "utf8")).words || []).filter((w) => w && "start" in w);
  } catch {
    return [];
  }
  const out = [];
  let cur = [];
  for (let i = 0; i < words.length; i++) {
    cur.push(words[i]);
    const w = words[i],
      nx = words[i + 1];
    const ends = /[.!?…]$/.test((w.text || "").trim());
    const gap = nx ? nx.start - w.end > 0.7 : true;
    if (ends || gap || !nx) {
      if (cur.length)
        out.push({
          in: +cur[0].start.toFixed(2),
          out: +cur[cur.length - 1].end.toFixed(2),
          text: cur.map((x) => x.text).join(" "),
        });
      cur = [];
    }
  }
  return out;
}

async function main() {
  const project = path.resolve(process.argv[2] || "");
  if (!process.argv[2]) {
    console.error("usage: safe-zones.cjs <project-dir> [in out]");
    process.exit(1);
  }
  const fgDir = path.join(project, "frames_fg");
  if (!fs.existsSync(fgDir)) {
    console.error(`[safe-zones] no ${fgDir} — run matte.cjs first`);
    process.exit(2);
  }
  if (!sharp) {
    console.error("[safe-zones] sharp unavailable — set HYPERFRAMES_ROOT");
    process.exit(0);
  }

  const frames = fs
    .readdirSync(fgDir)
    .filter((f) => /\.png$/i.test(f))
    .sort();
  if (!frames.length) {
    console.error("[safe-zones] no PNG frames");
    process.exit(2);
  }
  const meta = await sharp(path.join(fgDir, frames[0])).metadata();
  const W = meta.width,
    H = meta.height;
  const CELL = Math.max(W, H) / 48;
  const GW = Math.max(8, Math.round(W / CELL)),
    GH = Math.max(8, Math.round(H / CELL));
  let fps = 24;
  try {
    const f = parseFloat(
      String(fs.readFileSync(path.join(project, "matte.fps"), "utf8")).replace(/[^\d.]/g, ""),
    );
    if (f > 0) fps = f;
  } catch {}

  const bgDir = path.join(project, "frames_bg");
  const hasBg = fs.existsSync(bgDir);
  // cache evenly-sampled frame grids once (each = per-cell avg subject alpha 0..1,
  // plus per-cell mean LUMINANCE from frames_bg — bright zones wash out cream/screen text)
  const sampleIdx = [
    ...new Set(
      Array.from({ length: SAMPLES }, (_, i) =>
        Math.min(frames.length - 1, Math.round((i / (SAMPLES - 1)) * (frames.length - 1))),
      ),
    ),
  ];
  const grids = [];
  for (const i of sampleIdx) {
    const { data, info } = await sharp(path.join(fgDir, frames[i]))
      .resize(GW, GH, { fit: "fill" })
      .raw()
      .toBuffer({ resolveWithObject: true });
    const ch = info.channels,
      g = new Float32Array(GW * GH);
    for (let c = 0; c < GW * GH; c++) g[c] = (ch >= 4 ? data[c * ch + 3] : 255) / 255;
    let lum = null;
    if (hasBg && fs.existsSync(path.join(bgDir, frames[i]))) {
      const { data: bd, info: bi } = await sharp(path.join(bgDir, frames[i]))
        .resize(GW, GH, { fit: "fill" })
        .greyscale()
        .raw()
        .toBuffer({ resolveWithObject: true });
      const bch = bi.channels;
      lum = new Float32Array(GW * GH);
      for (let c = 0; c < GW * GH; c++) lum[c] = bd[c * bch];
    }
    grids.push({ t: i / fps, g, lum });
  }
  const lumWindow = (t0, t1) => {
    const acc = new Float32Array(GW * GH);
    let n = 0;
    let inWin = grids.filter((s2) => s2.t >= t0 - 1e-6 && s2.t <= t1 + 1e-6 && s2.lum);
    if (!inWin.length) inWin = grids.filter((s2) => s2.lum);
    for (const s2 of inWin) {
      for (let c = 0; c < GW * GH; c++) acc[c] += s2.lum[c];
      n++;
    }
    if (!n) return null;
    for (let c = 0; c < GW * GH; c++) acc[c] /= n;
    return acc;
  };
  const lumSeriesWindow = (t0, t1) => {
    let inWin = grids.filter((s2) => s2.t >= t0 - 1e-6 && s2.t <= t1 + 1e-6 && s2.lum);
    if (!inWin.length) inWin = grids.filter((s2) => s2.lum);
    return inWin.map((s2) => s2.lum);
  };
  const occWindow = (t0, t1) => {
    const occ = new Float32Array(GW * GH);
    let inWin = grids.filter((s) => s.t >= t0 - 1e-6 && s.t <= t1 + 1e-6);
    if (!inWin.length) {
      // window between samples → use nearest grid
      const mid = (t0 + t1) / 2;
      inWin = [grids.reduce((a, b) => (Math.abs(b.t - mid) < Math.abs(a.t - mid) ? b : a))];
    }
    for (const s of inWin) for (let c = 0; c < GW * GH; c++) if (s.g[c] > occ[c]) occ[c] = s.g[c];
    return occ;
  };

  // ad-hoc window query
  const qIn = parseFloat(process.argv[3]),
    qOut = parseFloat(process.argv[4]);
  if (Number.isFinite(qIn) && Number.isFinite(qOut)) {
    const a = analyze(
      occWindow(qIn, qOut),
      GW,
      GH,
      W,
      H,
      lumWindow(qIn, qOut),
      lumSeriesWindow(qIn, qOut),
    );
    console.log(
      `[safe-zones] window ${qIn}-${qOut}s: ${a.recommendation.toUpperCase()}  coverage ${a.coverage}%  clearer:${a.subject.clearerSide}`,
    );
    const z = a.zones;
    for (const k of ["largest", "left", "right", "top"])
      if (z[k]) console.log(`   ${k}: ${z[k].wPct}%×${z[k].hPct}% @ (${z[k].xPct}%,${z[k].yPct}%)`);
    console.log(JSON.stringify({ in: qIn, out: qOut, ...a }));
    return;
  }

  const globalOcc = occWindow(-1e9, 1e9);
  const globalLum = lumWindow(-1e9, 1e9);
  const global = analyze(globalOcc, GW, GH, W, H, globalLum, lumSeriesWindow(-1e9, 1e9));
  const windows = sentenceWindows(project).map((s) => {
    const a = analyze(
      occWindow(s.in, s.out),
      GW,
      GH,
      W,
      H,
      lumWindow(s.in, s.out),
      lumSeriesWindow(s.in, s.out),
    );
    return {
      in: s.in,
      out: s.out,
      text: s.text.slice(0, 48),
      coverage: a.coverage,
      recommendation: a.recommendation,
      clearerSide: a.subject.clearerSide,
      zones: a.zones,
    };
  });

  // ── v2: palette / optics / lighting from the mid frame + global grids ───────
  let palette = null,
    optics = null,
    lighting = null;
  try {
    const occCellG = new Uint8Array(GW * GH);
    for (let c = 0; c < GW * GH; c++) occCellG[c] = globalOcc[c] >= THRESH ? 1 : 0;
    const midName = frames[Math.floor(frames.length / 2)];
    const midBg = path.join(bgDir, midName);
    if (hasBg && fs.existsSync(midBg)) {
      palette = await scenePalette(midBg, occCellG, GW, GH);
      // subject bbox in px from the global occupancy grid
      let cx0 = GW,
        cx1 = -1,
        cy0 = GH,
        cy1 = -1;
      for (let y = 0; y < GH; y++)
        for (let x = 0; x < GW; x++)
          if (occCellG[y * GW + x]) {
            if (x < cx0) cx0 = x;
            if (x > cx1) cx1 = x;
            if (y < cy0) cy0 = y;
            if (y > cy1) cy1 = y;
          }
      const subjectBox =
        cx1 >= 0
          ? {
              x: (cx0 / GW) * W,
              y: (cy0 / GH) * H,
              w: ((cx1 - cx0 + 1) / GW) * W,
              h: ((cy1 - cy0 + 1) / GH) * H,
            }
          : null;
      optics = await sceneOptics(
        project,
        midBg,
        path.join(fgDir, midName),
        global.zones,
        subjectBox,
        W,
        H,
      );
      lighting = sceneLighting(globalLum, occCellG, GW, GH);
    }
  } catch (e) {
    console.error(`[safe-zones] scene optics skipped — ${e.message}`);
  }

  const out = {
    width: W,
    height: H,
    fps,
    grid: { cols: GW, rows: GH },
    ...global,
    palette,
    optics,
    lighting,
    windows,
  };
  fs.writeFileSync(path.join(project, "safe-zones.json"), JSON.stringify(out, null, 2));

  const z = (n, zn) =>
    zn
      ? `${n}: ${zn.wPct}%×${zn.hPct}% @ (${zn.xPct}%,${zn.yPct}%) [${zn.areaPct}%${zn.meanLuma != null ? ` · luma ${zn.meanLuma}${zn.bright ? " ⚠BRIGHT" : ""}` : ""}]`
      : `${n}: —`;
  console.log(
    `[safe-zones] ${W}×${H} grid ${GW}×${GH} @ ${fps}fps · GLOBAL coverage ${global.coverage}% · clearer ${global.subject.clearerSide} · verdict ${global.recommendation.toUpperCase()}`,
  );
  console.log(
    `             ${z("largest", global.zones.largest)} | ${z("left", global.zones.left)} | ${z("right", global.zones.right)} | ${z("top", global.zones.top)}`,
  );
  if (global.recommendation === "embed") {
    console.log(
      `[safe-zones] ✅ EMBED — NARRATION planes go in the clean zones (prefer ${global.subject.clearerSide}/top).`,
    );
    if (global.heroAnchor)
      console.log(
        `[safe-zones] 🎯 HERO → centered ON the subject: plane ≈ x${global.heroAnchor.plane.xPct}% y${global.heroAnchor.plane.yPct}% w${global.heroAnchor.plane.wPct}% center · BIG (~0.22–0.34·h) · target ~30–55% occlusion${global.heroAnchor.bandLuma != null ? ` · band luma ${global.heroAnchor.bandLuma}${global.heroAnchor.washoutRisk ? " ⚠WASHOUT RISK — see heroAnchor.note" : ""}` : ""}`,
      );
    if (global.heroBands)
      console.log(
        `[safe-zones] hero bands: best top ${global.heroBands.best.topPct}% (predicted occlusion ${global.heroBands.best.occPct}%) · bg-hero ${global.heroBands.feasible ? "FEASIBLE — keep the hero EMBEDDED (fg is last resort)" : "INFEASIBLE (no band ≤62%) → hero fg"}`,
      );
    if (global.zones.hugLeft || global.zones.hugRight)
      console.log(
        `[safe-zones] hugging zones (narration belongs HERE, abutting the silhouette): L ${global.zones.hugLeft ? global.zones.hugLeft.wPct + "%×" + global.zones.hugLeft.hPct + "%@x" + global.zones.hugLeft.xPct + "%" + (global.zones.hugLeft.bright ? "⚠bright" : "") : "—"} · R ${global.zones.hugRight ? global.zones.hugRight.wPct + "%×" + global.zones.hugRight.hPct + "%@x" + global.zones.hugRight.xPct + "%" + (global.zones.hugRight.bright ? "⚠bright" : "") : "—"}`,
      );
  } else {
    console.log(
      `[safe-zones] ⚠ FG — subject fills the frame; use caption_layer:"fg" (no clean region to embed behind).`,
    );
  }
  if (palette)
    console.log(
      `[safe-zones] 🎨 palette: dominant ${palette.dominant.map((d) => d.hex).join(" ")} · accent suggestion ${palette.accentSuggestion || "— (no chromatic anchor; use the DNA default)"} · ${palette.temperature}`,
    );
  if (optics && optics.sharpnessRatio != null)
    console.log(
      `[safe-zones] 🔭 depth: bg/subject sharpness ${optics.sharpnessRatio} → embed text blur ${optics.suggestedTextBlurPx}px${optics.suggestedTextBlurPx ? " (match the scene's depth-of-field)" : " (scene is uniformly sharp)"}`,
    );
  if (lighting)
    console.log(
      `[safe-zones] 💡 light from ${lighting.lightFrom} → contact shadow offset (${lighting.shadow.dx}px, ${lighting.shadow.dy}px)`,
    );
  if (windows.length) {
    console.log(`[safe-zones] per-sentence windows (place each group using ITS window's zones):`);
    for (const w of windows)
      console.log(
        `   [${w.in}-${w.out}s] ${w.recommendation.toUpperCase()} cov ${w.coverage}% clear:${w.clearerSide}  ${w.zones.largest ? `best ${w.zones.largest.wPct}%×${w.zones.largest.hPct}%@(${w.zones.largest.xPct}%,${w.zones.largest.yPct}%)` : ""}  "${w.text}"`,
      );
  }
  console.log(`[safe-zones] → ${path.join(project, "safe-zones.json")}`);
}
main().catch((e) => {
  console.error(`[safe-zones] (skipped — ${e.message})`);
  process.exit(0);
});
scripts/transcribe.cjs
#!/usr/bin/env node
/*
 * transcribe.cjs — word-level transcription via hyperframes' native Whisper
 * (replaces the Python ElevenLabs Scribe path; no Python, no API key).
 *
 *   node transcribe.cjs <project-dir> [model] [language]
 * Reads:  <project>/source.mp4 (audio track)
 * Writes: <project>/transcript.json  — { text, language_code, words:[{text,start,end,type}] }
 */
const path = require("path");
const fs = require("fs");
const os = require("os");
const cp = require("child_process");

function hfRoot() {
  const roots = [
    process.env.HYPERFRAMES_ROOT,
    path.resolve(__dirname, "..", "..", ".."),
    path.join(os.homedir(), "Downloads", "hyperframes"),
  ].filter(Boolean);
  for (const r of roots)
    if (fs.existsSync(path.join(r, "packages", "cli", "dist", "cli.js"))) return r;
  console.error("[transcribe] hyperframes CLI not found — set HYPERFRAMES_ROOT");
  process.exit(3);
}
function ensureSource(project) {
  const src = path.join(project, "source.mp4");
  if (fs.existsSync(src)) return src;
  const EXCL = new Set(["final", "bg_plus_caps", "fg_caps", "audio"]);
  let cands = fs
    .readdirSync(project)
    .filter(
      (f) =>
        ["mp4", "mov", "webm", "mkv", "m4v"].includes(path.extname(f).slice(1).toLowerCase()) &&
        !EXCL.has(path.basename(f, path.extname(f))) &&
        !f.startsWith("index"),
    )
    .map((f) => path.join(project, f));
  let found = cands.sort((a, b) => fs.statSync(b).size - fs.statSync(a).size)[0];
  if (found) {
    try {
      fs.symlinkSync(path.basename(found), src);
    } catch {
      fs.copyFileSync(found, src);
    }
  }
  return src;
}
function _usableWords(d) {
  return d && Array.isArray(d.words) && d.words.some((w) => w && "start" in w && "end" in w);
}
// Mean loudness of the audio, for the no-speech guard below. Silence → whisper
// hallucinates (famously "Thank you."), and the decision gate refuses "no speech".
function meanVolumeDb(audio) {
  try {
    // ffmpeg writes volumedetect stats to STDERR — capture it (spawnSync, no throw).
    const r = cp.spawnSync(
      "ffmpeg",
      ["-hide_banner", "-nostats", "-i", audio, "-af", "volumedetect", "-f", "null", "-"],
      { encoding: "utf8" },
    );
    const out = (r.stderr || "") + (r.stdout || "");
    const m = out.match(/mean_volume:\s*(-?[\d.]+) dB/);
    return m ? parseFloat(m[1]) : null;
  } catch {
    return null;
  }
}

// Where does AUDIBLE content end? Whisper hallucinates trailing words over a silent
// tail (observed: "I'm sorry." repeated over dead air at a clip's end). silencedetect
// finds a terminal silence running to EOF; words "spoken" inside it are fabricated.
// Conservative: applause/music read as non-silence, so this fires only on truly dead
// tails. Returns {speechEnd, total} or null.
function audibleEnd(audio) {
  try {
    const r = cp.spawnSync(
      "ffmpeg",
      [
        "-hide_banner",
        "-nostats",
        "-i",
        audio,
        "-af",
        "silencedetect=noise=-35dB:d=0.6",
        "-f",
        "null",
        "-",
      ],
      { encoding: "utf8" },
    );
    const out = (r.stderr || "") + (r.stdout || "");
    const durM = out.match(/Duration:\s*(\d+):(\d+):([\d.]+)/);
    const total = durM ? +durM[1] * 3600 + +durM[2] * 60 + +durM[3] : null;
    if (total == null) return null;
    const starts = [...out.matchAll(/silence_start:\s*([\d.]+)/g)].map((x) => +x[1]);
    const ends = [...out.matchAll(/silence_end:\s*([\d.]+)/g)].map((x) => +x[1]);
    if (!starts.length) return { speechEnd: total, total };
    const lastStart = starts[starts.length - 1];
    const closed = ends.some((e) => e > lastStart); // silence re-broken before EOF?
    return { speechEnd: closed ? total : lastStart, total };
  } catch {
    return null;
  }
}

function main() {
  const project = path.resolve(process.argv[2] || "");
  if (!process.argv[2]) {
    console.error("usage: transcribe.cjs <project-dir> [model] [language]");
    process.exit(1);
  }
  // Default = multilingual `small`, NOT `small.en`. Per media-use: ".en models
  // mistranslate non-English and mis-handle accented speech; default to small (auto-detects
  // language)." We hardcoded small.en before — it hallucinated a wrong transcript on an
  // accented speaker. Pass `small.en` only for known-clean-English; tough accents → a larger model.
  const model = process.argv[3] || process.env.WHISPER_MODEL || "small";
  const language = process.argv[4] || process.env.WHISPER_LANG || "";
  const out = path.join(project, "transcript.json");

  // already in our schema? skip — but validate the SHAPE, not just the keys:
  // `hyperframes init` drops a whisper.cpp segment/token-format transcript.json
  // (offsets-in-ms, nested tokens) that can carry a `words` key yet poison the
  // compilers. Only a word-level {text,start,end} array counts as normalized.
  try {
    const d = JSON.parse(fs.readFileSync(out, "utf8"));
    const wordShaped =
      d &&
      Array.isArray(d.words) &&
      d.words.length > 0 &&
      d.words.every(
        (w) =>
          w &&
          typeof (w.text ?? w.word) === "string" &&
          Number.isFinite(w.start) &&
          Number.isFinite(w.end) &&
          w.end < 36000, // ms-offset formats blow past any sane seconds value
      );
    if (wordShaped && d.language_code) {
      console.log("[transcribe] already normalized, skipping");
      return;
    }
    if (d && !wordShaped) {
      console.log(
        "[transcribe] existing transcript.json is NOT word-level (init stub / segment format) — regenerating",
      );
    }
  } catch {}

  const src = ensureSource(project);
  if (!fs.existsSync(src)) {
    console.error(`[transcribe] no source in ${project}`);
    process.exit(2);
  }
  const audio = path.join(project, "audio.mp3");
  if (!fs.existsSync(audio))
    cp.execFileSync(
      "ffmpeg",
      ["-y", "-i", src, "-vn", "-acodec", "libmp3lame", "-q:a", "2", audio],
      { stdio: "ignore" },
    );

  // ── engine: WhisperX (preferred — wav2vec2 forced alignment gives word timings far
  // tighter than whisper.cpp's segment-interpolated ones; our gates are 80ms-strict) →
  // fallback hyperframes whisper.cpp. Force with TRANSCRIBE_ENGINE=whisper|whisperx.
  let words = null,
    engine = null;
  const wantWx = (process.env.TRANSCRIBE_ENGINE || "whisperx") === "whisperx";
  if (wantWx) {
    try {
      const wav = path.join(project, "_wx_audio.wav");
      cp.execFileSync("ffmpeg", ["-y", "-i", src, "-vn", "-ac", "1", "-ar", "16000", wav], {
        stdio: "ignore",
      });
      const outDir = path.join(project, "_wx_out");
      fs.mkdirSync(outDir, { recursive: true });
      const wxModel = model.replace(/\.en$/, ""); // whisperx model names are multilingual ids
      // Pin whisperx so `uvx` fetches a reproducible build instead of resolving
      // "latest" on every run (a supply-chain + determinism foot-gun). Override
      // with $WHISPERX_VERSION if you've validated a different release.
      const whisperxSpec = `whisperx==${process.env.WHISPERX_VERSION || "3.8.6"}`;
      const wxArgs = [
        "--python",
        "3.12",
        "--from",
        whisperxSpec,
        "whisperx",
        wav,
        "--model",
        wxModel,
        "--device",
        "cpu",
        "--compute_type",
        "int8",
        "--output_dir",
        outDir,
        "--output_format",
        "json",
        "--no_align_deletes",
        "--print_progress",
        "False",
      ];
      if (language) wxArgs.push("--language", language);
      // strip our flag if this whisperx build doesn't know it
      let r = cp.spawnSync("uvx", wxArgs, { encoding: "utf8", timeout: 600000 });
      if ((r.status || 0) !== 0 && /no_align_deletes/.test(r.stderr || "")) {
        r = cp.spawnSync(
          "uvx",
          wxArgs.filter((a) => a !== "--no_align_deletes"),
          { encoding: "utf8", timeout: 600000 },
        );
      }
      if ((r.status || 0) !== 0)
        throw new Error(
          (r.stderr || "whisperx failed").split("\n").slice(-4).join(" ").slice(0, 300),
        );
      const wxJson = JSON.parse(fs.readFileSync(path.join(outDir, "_wx_audio.json"), "utf8"));
      const wx = [];
      for (const seg of wxJson.segments || [])
        for (const w of seg.words || []) {
          // alignment occasionally yields a word with no timing (OOV) — interpolate from neighbors later; mark null now
          wx.push({ text: String(w.word || "").trim(), start: w.start, end: w.end, type: "word" });
        }
      // interpolate missing timings from neighbors (rare OOV/number cases)
      for (let i = 0; i < wx.length; i++) {
        if (wx[i].start == null || wx[i].end == null) {
          const prevEnd = i > 0 ? wx[i - 1].end : 0;
          const nextStart = wx.slice(i + 1).find((x) => x.start != null);
          const ns = nextStart ? nextStart.start : prevEnd + 0.3;
          wx[i].start = prevEnd;
          wx[i].end = Math.max(prevEnd + 0.05, ns - 0.02);
        }
      }
      if (wx.length) {
        words = wx.filter((w) => w.text);
        engine = `whisperx(${wxModel}+wav2vec2)`;
      }
      try {
        fs.unlinkSync(wav);
      } catch {}
    } catch (e) {
      console.error(
        `[transcribe] whisperx unavailable (${String(e.message || e).slice(0, 160)}) — falling back to whisper.cpp`,
      );
    }
  }

  if (!words) {
    // run hyperframes Whisper → writes a flat word array to <dir>/transcript.json
    const cli = path.join(hfRoot(), "packages", "cli", "dist", "cli.js");
    const args = ["transcribe", audio, "-d", project, "--json", "--model", model];
    if (language) args.push("--language", language);
    let info = {};
    try {
      const so = cp.execFileSync("node", [cli, ...args], { encoding: "utf8" });
      const line = so.trim().split("\n").filter(Boolean).pop();
      info = JSON.parse(line);
    } catch (e) {
      console.error("[transcribe] hyperframes whisper failed:", e.message);
      process.exit(1);
    }
    const flatPath = info.transcriptPath || out;
    const flat = JSON.parse(fs.readFileSync(flatPath, "utf8"));
    const arr = Array.isArray(flat) ? flat : flat.words || [];
    words = arr
      .filter((w) => (w.text ?? w.word) != null)
      .map((w) => ({
        text: w.text ?? w.word,
        start: w.start ?? w.t0,
        end: w.end ?? w.t1,
        type: "word",
      }));
    engine = `whisper.cpp(${model})`;
  }

  // Tail-hallucination guard: drop words whisper placed entirely inside a terminal
  // silence (it fabricates e.g. repeated "I'm sorry." over dead air). Word START past
  // the audible end (+0.4s slack) = fabricated; real final words start before it.
  const ae = audibleEnd(audio);
  let trimmedTail = 0;
  if (ae && ae.speechEnd < ae.total - 0.8) {
    const keep = words.filter((w) => w.start <= ae.speechEnd + 0.4);
    trimmedTail = words.length - keep.length;
    if (trimmedTail > 0) {
      console.error(
        `[transcribe] ⚠ trimmed ${trimmedTail} trailing word(s) starting after the audible end ` +
          `(${ae.speechEnd.toFixed(2)}s; clip ${ae.total.toFixed(2)}s) — whisper hallucinates over silent tails.`,
      );
      words = keep;
    }
  }

  const text = words
    .map((w) => w.text)
    .join(" ")
    .replace(/\s+([,.!?;:])/g, "$1")
    .trim();
  fs.writeFileSync(
    out,
    JSON.stringify(
      {
        text,
        language_code: language || "en",
        engine,
        words,
        ...(trimmedTail ? { trimmed_tail_words: trimmedTail } : {}),
      },
      null,
      2,
    ),
  );
  console.log(`[transcribe] ${engine} ${words.length} words → ${out}`);
  console.log(`[transcribe] text: ${text.slice(0, 160)}${text.length > 160 ? "…" : ""}`);

  // No-speech guard: whisper returns confident hallucinations over silence (e.g. the
  // whole clip as "Thank you."). The decision gate REFUSES "no speech" — operationalize
  // it so an agent trusting the transcript can't sail past the gate.
  const meanDb = meanVolumeDb(audio);
  if (meanDb != null && meanDb < -45) {
    console.error(
      `\n[transcribe] ⚠ NEAR-SILENT AUDIO — mean ${meanDb.toFixed(1)} dB (real speech ≈ -16..-26 dB).`,
    );
    console.error(
      `  This transcript is almost certainly a Whisper hallucination, NOT real speech.`,
    );
    console.error(
      `  Per the decision gate, REFUSE "no speech" — confirm with \`ffmpeg -i <src> -af silencedetect\`;`,
    );
    console.error(`  do NOT author captions from fabricated words.`);
  }
}
main();
SKILL.md
---
name: embedded-captions
description: >
  Add captions or subtitles to an existing single-subject talking-head video without editing the
  footage. Use for plain verbatim captions, cinematic captions embedded behind the subject, VFX
  captions, “炸/特效/酷炫字幕,” or a named identity from the 35-style catalog. Route by visual
  identity, not by backend engine. The quiet `anchor` rail is the default; embed every word only
  when the user explicitly wants a fully cinematic treatment. The workflow runs locally end to end,
  including transcription and subject matting; split multi-shot footage before applying it.
---

> **First, keep this skill fresh — confirm with the user before running:** `npx hyperframes skills update embedded-captions`. A fast no-op when everything is current; otherwise it refreshes this skill plus the core domain skills it depends on before you rely on them.

# Embedded Captions

**One catalog, picked up front** ([CATALOG.md](CATALOG.md) — 35 identities; the engines behind it are backend detail). **Standard** (default) builds a clean verbatim **rail** (lower-third subtitle carrying most text) + an **embed** climax composited _into_ the scene behind the subject at the peak. **Cinematic** is pure embed — no rail, every caption composited behind the subject (hero typography, accumulation, occlusion as the effect). **Theme** is a complete themed constitution — body paradigm × hero setpiece × front fx × plate reaction, composed from registries ([themes/README.md](themes/README.md)): `ordnance` `terminal` `neonsign` `stardust` `stomp`. Most explainer / voiceover is **Standard**; **embed is the scarce, earned peak** — embedding every word is the common mistake; Theme is for VFX-grade asks ("炸", "特效", "像 AE 做的").

---

## Operational flow (TL;DR)

Routed through `/hyperframes`, the intent layer confirms only the input (which clip) and **announces** the identity pick as a deferred ask — the shortlist needs the probed clip, so it stays at step 1 below; the layer's run-shape questions don't apply (the footage is untouched, there is no storyboard to review). A `BRIEF.md`, when present, carries the confirmed input and any user notes — read it first.

The craft prose below is long; the **pipeline itself is short** — and everything deterministic is computed or compiled, never hand-written:

1. **Decision gate** (refuse bad clips) → **pick ONE identity from [CATALOG.md](CATALOG.md)** (35 identities; engine/compiler derived by lookup — never surface a mode/category question)
2. `hyperframes init` (skip it if the project dir already exists with the video inside — `matte.cjs`/`transcribe.cjs` adopt any video in the dir as source.mp4) → **`bash scripts/prepare.sh <project>`** (matte ∥ transcribe ∥ audio-envelope in parallel, then safe-zones v2 with scene palette/optics/lighting — one command, nothing forgotten)
3. **author a small JSON of creative choices** (read `safe-zones.json` first): Cinematic → `plan.json` → `fill-timings.cjs` → `fit-fonts.cjs` → `make-composition.cjs`; Theme → `theme.json` → `make-theme.cjs` (rail/panel/poem/takeover paradigms; `anchor` is the quiet rail default)
4. **Visual QA**: `node scripts/preview-frames.cjs <project>` → faithful composite previews in ~2s/frame (no render). Check § Visual QA before paying for a render.
5. `render-and-composite.sh` → gates (timing / occlusion+hero / overflow / hand-off) → `final.mp4`

Load-bearing rules people miss:

- **rail (default) + embed (promotion).** `drop` (filler, not shown) / `rail` (verbatim lower-third subtitle, in front, carries most text) / `embed` (a peak word composited behind the subject). **Standard mode does both**, embedding only the peak(s). See **§ Caption model**.
- **The video is delivered UNTOUCHED (Standard/Cinematic; **Theme mode's PLATE budget is the one sanctioned exception** — register-gated reaction beats (charge-dim, punch, shake, grain) defined per theme DNA and applied AFTER the matte composite so subject+text+plate move as one frame)** — captions are the only thing added; the matte just lets the subject occlude the embed track. Never grade/recolor/scanline the footage.
- Two rulebooks: **rail → [references/rail.md](references/rail.md)** (thin), **embed craft → [references/composition-craft.md](references/composition-craft.md)** (rich, embed-only). Skim by need.

---

## Caption model — rail + embed

Every spoken phrase is one of three things:

|           | What                                             | How it's shown                                                                                                                                                    |
| --------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **drop**  | filler — um/uh, stutters, self-corrections       | not shown                                                                                                                                                         |
| **rail**  | the default — ordinary spoken content (verbatim) | clean lower-third subtitle, **in front**, readable. A punch word can get an inline `emphasis` highlight (accent colour / active-word pop) — it stays on the rail. |
| **embed** | a promoted peak — the headline beat              | one big word composited **behind the subject** (matte occlusion), designed entrance + exit                                                                        |

**The rail carries most of the text; embed is the scarce, earned peak.** Scarcity is **per beat/block, not per clip**: ≤1 hero per block (thought), never two co-visible, ≥ a beat of air between hero windows (the compiler warns under 0.6s). A short clip → usually 1–2; a long explainer → ~one per section. Among multiple heroes, the **largest authored one is the APEX** (it alone gets the full lockup embed + width-fit raise); smaller ones are **MINOR peaks** that ride their column as oversized emphasis lines (fg, damped motion) — not every beat needs the matte showcase, which is exactly what keeps the apex an event. Embedding every word is still the common mistake.

Rail-surface identities build exactly this (rail = `rail.html`, embed = the climax in `index.html`). Column-flow identities drop the rail and make everything embed-style — recommend them only for mood-over-verbatim asks, never for explainer / voiceover where the words must read (CATALOG.md encodes this per identity).

---

## Step 0 — pick ONE identity from the CATALOG

**One front-end, three engines behind.** The user picks an IDENTITY from [CATALOG.md](CATALOG.md) (35 entries: 10 classic + 25 themed); the engine, compiler and authoring file are derived by lookup from the catalog row. **Never surface "Standard vs Cinematic vs Theme" as a question** — those are backend names (a product has one UX even with several engines). The catalog encodes everything routing needs: reading surface, voice, recommend-for, scene needs, adjacency notes for the genuinely-close pairs (loud↔ordnance, neon↔neonsign, cream↔stardust).

The identity pick is a **preference gate** (`../hyperframes-core/references/brief-contract.md` § 1): in autonomous mode ("surprise me" / "decide for me"), pick from your shortlist yourself and state the one-line why instead of asking.

Procedure: probe the clip → shortlist 2–3 identities from the catalog → recommend ONE with a one-line why → **the user picks** (autonomous mode: you pick, stating the why) → author that identity's file. Identities are engine-locked (no cross combos; opening one is a validation event — see dna/README.md).

**Always present your recommendation and let the user pick before you author.** Don't silently default.

(The full identity table lives in [CATALOG.md](CATALOG.md) — single source of truth for routing. The engine docs below describe each backend's authoring contract.)

**Recommendation heuristic**: use the "Shortlisting heuristics" in [CATALOG.md](CATALOG.md) — they are identity-level (e.g. "炸" shortlists ordnance/stomp/terminal/loud and picks by WHAT should explode), never category-level. Unsure → `anchor`.

- **Cinematic** → write `plan.json` for a locked template, compiled by `make-composition.cjs`.
- **Theme** → read [themes/README.md](themes/README.md), author `theme.json`, run `scripts/render-theme.sh` (compiles + renders + plate reaction → **final_fx.mp4**).

---

## Decision gate — RUN FIRST

Probe the video and classify the scene before either mode.

```bash
ffprobe <video.mp4>                    # specs
ffmpeg -ss <t> -i <video.mp4> -vframes 1 sample.png   # at 20/50/80%
```

Read the samples. Refuse if:

- Multiple speakers / hard cuts (split & render each shot, or refuse)
- No human subject (this skill is for talking-head)
- Under 3 seconds, **no speech**, or face never clearly visible — `transcribe.cjs` warns when audio is near-silent (Whisper hallucinates words like "Thank you." over silence); **heed it and refuse** rather than caption fabricated words
- **Source already has burned-in captions / subtitles / heavy text graphics** — adding a second caption system conflicts and the footage ships untouched (no covering/inpainting). Burned text often appears only mid-clip: sample a **1fps contact sheet** (`ffmpeg -i in.mp4 -vf "fps=1,scale=160:-1,tile=10x5" sheet.png`), don't trust 3 spot frames.
- **Transcript is garbage** — non-native/heavy-accent speech can transcribe into confident gibberish. Sanity-read `transcript.json` before authoring; if it doesn't parse as language, try `WHISPER_MODEL=medium` once, else refuse (a verbatim rail of fabricated words is worse than no captions).
- Busy handheld with fast motion (matte flickers)

### Pre-flight probes (cost nothing, prevent the worst failures)

1. **Shot-cut probe.** Sample frames at 20%, 50%, 80%. If a different subject/scene appears, **trim the clip** before the cut.
2. **Letterbox / pillarbox probe.** Black bars on the first frame? Compute safe content rect and constrain caption placement inside it.
3. **Luminance probe.** Sample the caption region's average luminance — `under 60` → light text reads as-is, `60-180` → add the glyph scrim, `180+` → opaque text + scrim (never bare light text). **Cinematic templates are cream+`screen` and LOCKED** — use this probe to _pick a fitting identity_ (bright scenes → `ink`, or the opaque-rail `anchor` theme), never to recolour one.
4. **Identity recommendation by tone (you recommend; the user picks — see Step 0 + CATALOG.md).** explainer / interview / must-read words → rail/panel-surface identities; poetic / social / "cinematic" → column-flow identities by register; "炸 / 特效 / VFX" / named worlds → themed identities. When unsure → `anchor` (words read, scene safe) — but present a shortlist and let the user choose.

---

## Pipeline — 5 steps

```
1. hyperframes init <project> --non-interactive --video <video.mp4> --skill=embedded-captions
2. bash scripts/prepare.sh <project>       # matte ∥ transcribe (parallel) → safe-zones. One command.
                                           #   → frames_fg/ transcript.json safe-zones.json
3. [AGENT STEP — the only creative step] author a small JSON; see below by mode
   Cinematic: author plan.json → node scripts/fill-timings.cjs → fit-fonts.cjs → make-composition.cjs
   Theme:     author theme.json → bash scripts/render-theme.sh <project>   (compiles + renders + plate fx)
4. node scripts/preview-frames.cjs <project>   # ~2s/frame composite previews → § Visual QA (BEFORE the render)
5. bash scripts/render-and-composite.sh <project>  # gates → final.mp4 + history/ snapshot
   (Theme mode: SKIP steps 3b/5 — render-theme.sh already runs compile + render-and-composite
    + _postfx.sh; the deliverable is final_fx.mp4, final.mp4 is pre-plate-reaction)
```

Step 1's `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date.

Step 3 differs by mode:

### Step 3 — Cinematic mode (pure embed)

1. **Read `safe-zones.json` first.** Narration planes go in **`zones.hugLeft`/`hugRight`** — clean strips ABUTTING the silhouette (text far from the body reads as floating, not embedded; far corners are the fallback, not the default). The hero defaults to `heroAnchor`/`heroBands.best` (centered ON the subject, ~30–55% occluded). `recommendation:"fg"` moves NARRATION in front for legibility; **the hero stays embedded whenever `heroBands.feasible`** — hero-fg is the last resort.
2. **The DNA is the identity you picked in Step 0** (CATALOG.md) — do not re-open the choice here. Sanity-check it against the scene (bright hero band luma > 150 wants `ink`; full pick guidance lives in the catalog, covering all ten incl. neon / glitch / chrome / velocity). State your pick + why; the user decides. The DNA locks type/palette/blend/motion + hero three-act; safe-zones v2 (`palette`/`optics`/`lighting`) parameterizes it to THIS scene automatically.
3. **Author `<project>/cinematic.json`** — `"dna": "<name>"` + thought-BLOCKS, not raw groups: each block = lines of words (grouped 2–5 at clause boundaries) + the plane it stacks in + per-line `css` (size/weight/style only — no positions) + at most ONE line marked `"hero": true` (the promoted word; `"text"` for display form). Schema: `scripts/make-cinematic.cjs` header.
4. **Compile**: `node scripts/make-cinematic.cjs <project>` — lowers blocks → plan.json → index.html. Generated for you: transcript-sequenced timings, accumulate-within-block, page-flip-between-blocks, **the hero LOCKUP** (a hero block's pre-context, HERO and post-context stack as ONE bonded composition centered on the subject — reading order top→bottom = spoken order by construction; context floats in FRONT while the hero embeds BEHIND = the depth sandwich; a mass rule keeps the hero dominating its context), apex/minor hero split, **reading order by construction**, fg fallback per safe-zones. Then the gates run as usual. _(Hand-authoring plan.json directly remains possible for designs blocks can't express — then run `fill-timings.cjs` + `fit-fonts.cjs` + `make-composition.cjs` yourself.)_

### Step 3 — Theme mode (themed constitution)

**Read [themes/README.md](themes/README.md) FIRST** — paradigm/setpiece registries, linkages, hard rules, and the exact `theme.json` schema.

1. **Pick a theme DNA** by content register (each `themes/<name>.json` has `voice` + `when`). State your pick + why; the user decides.
2. **Author `<project>/theme.json`** — `dna`, `lines` (verbatim, transcript order; 1–5 words each — for `takeover` each line is one CARD), `minors` (emphasis words), `hero:{match}` (the climax word/phrase; leave it OUT of `lines` for embed setpieces, keep it IN for inline setpieces and panel+redact).
3. **Render**: `bash scripts/render-theme.sh <project>` — compiles (verbatim-completeness gate at compile time), renders both layers, composites, applies the plate reaction → `final_fx.mp4`. Use `preview-frames.cjs` between compile and render for Visual QA.

---

## Visual QA — preview BEFORE you render

`node scripts/preview-frames.cjs <project> [t…]` composites **faithful preview frames in ~2s each** (caption layers screenshotted at seek-time + real video frame + matte occlusion + rail overlay = what the final composite will look like at that moment). Default samples = each group/climax window. A full render costs minutes — never use it to _discover_ layout problems.

Check the previews (`<project>/preview/sheet.png`) against this list — these are the failures the geometric gates **cannot** catch:

1. **Washout** — light text over a bright region (window/sign/sky): unreadable → move the plane or change DNA/mode (bright scene → `ink`).
2. **Text-on-text** — captions over the scene's own text/graphics, or two caption groups colliding.
3. **Reading order** — on-screen vertical order must match spoken order; the hero must not sit below later words.
4. **Hero presence** — the climax should be BIG and visibly behind the subject (~30–55% occluded), not a floating label in a margin.
5. **Balance** — one coherent column/band, not scattered fragments; margins breathing; nothing clipped.

Then the **5 positive checks** in [references/reference-bar.md](references/reference-bar.md) (poster test · timid test · one-glance hierarchy · scene handshake · dead-air audit) — the failure list keeps a render from being broken; the positive list is what makes it _designed_. Ship when both pass.

**Fresh-eyes review (recommended for anything user-facing):** you have confirmation bias about your own layout. If you can spawn a subagent, give it ONLY the preview sheet + this checklist and ask for PASS/FIX verdicts per frame ("review these caption previews against the 5-point checklist; answer PASS or the specific fix per frame"). Apply fixes in plan.json / theme.json, recompile, re-preview — each loop costs seconds. Render once, when the previews pass.

---

## The DNA registry — ten visual languages (replaces the template catalog)

Both modes draw from **[dna/](dna/README.md)** — ten art-directed visual languages that **parameterize per scene** (accent sampled from the footage, contact shadow along the measured light direction, depth-match blur, RMS-coupled hero amplitude):

| DNA             | Register       | Scene fit                                       | Voice                                                                                              |
| --------------- | -------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| **cream**       | premium-warm   | dark/mid warm scenes                            | Inter + warm cream + screen; glowing emergence hero (successor of cinematic-cream)                 |
| **ink**         | premium        | **bright scenes (luma > 150)**                  | near-black multiply — type printed ON the wall; the bright-scene answer                            |
| **editorial**   | editorial-luxe | introspective / fashion / poetic                | Bodoni Moda, lowercase-italic hero — magazine elegance                                             |
| **keynote**     | tech-premium   | product / launch                                | opaque white Inter 800, dead-center stillness                                                      |
| **documentary** | formal         | interview / serious                             | burn-in reveals, no hero — gravitas IS the style                                                   |
| **loud**        | loud           | hype / sport / social                           | Anton + scene-sampled accent, single-unit slam + ripple; body ANNOUNCES in front (`bodyLayer: fg`) |
| **neon**        | loud-neon      | neon-noir / nightlife / tech-noir (dark scenes) | electric-cyan signage, ignition flicker, the hero powers ON like a sign                            |
| **glitch**      | loud-neon      | digital / hacker / AI                           | RGB-split echoes snap together on landing; machine-percussive timing                               |
| **chrome**      | loud-luxe      | Y2K / fashion-tech / music                      | liquid-metal gradient hero + one sheen sweep during the hold                                       |
| **velocity**    | loud-sport     | sport / auto / fitness                          | every word arrives along its motion vector (streak+skew), hero passes with speed trails            |

Pick by `safe-zones.json` (`heroAnchor.bandLuma`, `palette.temperature`) × content register — [dna/README.md](dna/README.md) has the decision rule. Authoring: `cinematic.json` takes `"dna": "<name>"`.

The engine generates the **hero three-act** from the DNA (no authoring needed): co-visible captions dim (setup) → per-letter entrance with amplitude ∝ spoken loudness (impact) → breathe + glow until exit (afterglow).

(Legacy: `plan.template:"cinematic-cream"` maps to `dna:"cream"` automatically. The retired 54-template library is archived outside this repo and is not distributed with the skill; `_motion.md` remains in-skill as the motion-verb reference catalog.)

---

## Aesthetic decision — tone × shot × platform (input to the catalog shortlist, NOT a second router)

Classify the clip on 3 axes and feed the result into CATALOG.md's shortlisting — this section never picks a mode/engine by itself:

**Tone** (what feel does the content have?)

- documentary | conversational | energetic | poetic | keynote | investigative | music-video

**Shot** (what's the framing?)

- close-up (head + shoulders) | mid-shot (torso+) | wide (full body+) | cut-montage (mixed shots)

**Platform** (where will it play?)

- 9:16 portrait (TikTok/IG/Shorts) | 16:9 landscape (YouTube/web) | 1:1 square | broadcast export

Cross-reference in [references/direction-catalog.md § Classification matrix](references/direction-catalog.md) for direction language — then return to [CATALOG.md](CATALOG.md) to shortlist identities (this matrix informs the shortlist; the catalog is the only routing surface).

## Composition craft (embed track) — read before embedding

The full **embed-track** playbook lives in **[references/composition-craft.md](references/composition-craft.md)**: transcript role-annotation, phrase grouping, planes & clean-zone anchoring, zone coherence, climax pop & readability, edge-breathing, the occlusion 3-step judgement, and accumulation/persistence. It governs how a _promoted_ phrase sits INTO the scene — read it before authoring any embed (Cinematic `plan.json` or Standard `index.html`). The default **rail** track has its own, much simpler spec → **[references/rail.md](references/rail.md)**.

---

## Shared knowledge

| Doc                                                                      | What                                                                                                                               |
| ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
| [references/rail.md](references/rail.md)                                 | **The rail track** — standard lower-third subtitle spec (the default; carries most text).                                          |
| [references/composition-craft.md](references/composition-craft.md)       | **The embed-track playbook** — grouping, planes, climax pop, occlusion judgement, accumulation/persistence. Read before embedding. |
| [dna/README.md](dna/README.md)                                           | **The DNA registry** — ten scene-parameterized visual languages; how to pick.                                                      |
| [references/reference-bar.md](references/reference-bar.md)               | **The taste bar** — per-register world-class references + the 5 positive checks.                                                   |
| [references/aesthetic-principles.md](references/aesthetic-principles.md) | **The 18 rules.** Beat Veed AI on taste. Read first.                                                                               |
| [references/motion-vocabulary.md](references/motion-vocabulary.md)       | 10 named motion primitives + tone→timing lookup                                                                                    |
| [references/direction-catalog.md](references/direction-catalog.md)       | 10 ship-ready aesthetics + tone×shot×platform matrix                                                                               |
| [references/anti-patterns.md](references/anti-patterns.md)               | Bugs already locked out (CoreML, letter-spacing reflow, etc.)                                                                      |
| [references/scene-types.md](references/scene-types.md)                   | When a wall surface is usable (4 conditions)                                                                                       |
| [references/layout-heuristics.md](references/layout-heuristics.md)       | Plane positioning, clean-zone selection, crown 3 conditions, pillarbox math                                                        |
| [references/typography-presets.md](references/typography-presets.md)     | Font-size × column-width matrix (starting points)                                                                                  |
| [references/caption-grouping.md](references/caption-grouping.md)         | Word → group rules (pauses, sentence boundaries)                                                                                   |
| [references/failure-modes.md](references/failure-modes.md)               | Long tail of dev gotchas                                                                                                           |
| [references/bespoke-vs-presets.md](references/bespoke-vs-presets.md)     | Why presets fail sometimes; clone-and-tweak pattern                                                                                |

**Read the aesthetic principles and direction catalog FIRST.** Everything else is implementation detail.

---

## Non-negotiables

- **Face must never be 100%-covered continuously** — every 0.3s window, face bbox ≥30% uncovered.
- **WCAG contrast** — final render lints; fix palette if it fails.
- **Deterministic** — no `Math.random()`, no `Date.now()`, no `repeat:-1`.
- **Never grade/recolor the video.** The footage ships untouched — captions are the only addition. No full-frame scanlines / duotone / darken / vignette over the a-roll. neon-noir/CRT texture belongs _inside_ a caption element, not over the whole frame.
- **Rail-first for talking-head / explainer.** Don't embed the whole transcript — most text is the rail; embed only peaks. Embedding everything is the default mistake.
- **Embed is scarce + spaced.** ≤1 embed per sentence/beat, never two adjacent or co-visible, ≥ a beat apart, at most one `apex`. climax = per-beat peak, **not** "the single payoff of the entire clip."
- **Matte = the PERSON (hyperframes `remove-background`, u2net_human_seg, Apache-2.0).** Human segmentation by intent, but not surgically: thin offset furniture (mic boom arms) is usually excluded — captions render over it, behind the person — while large salient objects NEAR the subject (a telescope, a desk rig) can still leak into the matte and occlude captions. Objects HELD by the subject (products, phones) may drop out intermittently, letting captions pass in front. NEVER assume: sample `frames_fg/` at 2-3 timestamps before placing the hero, and prefer hero positions clear of any leaked furniture (`heroAnchor` can be skewed by leaks — cross-check against frames_bg).
- **safe-zones is PROP-BLIND — eyeball every band you use.** Zones/heroBands score _subject_ occlusion + luma only: a mic, telescope, or screen sitting inside a "clean" zone is invisible to them (and a prop leaking INTO the matte skews `heroAnchor.centerXPct` off the person). Before authoring, extract ONE frame of each band you intend to use; if a prop lives there, measure its bbox and move/shrink the plane. Two real cases shipped clean only because the agent did exactly this. (Auto prop-saliency is a known gap; zones' `peakLuma` only catches _moving_ bright objects.)
- **Captions stay on-frame.** Cinematic mode hard-gates frame-overflow; Standard mode runs `check-overflow.cjs` as a WARNING (intentional bleed is the only exception — read the warning).
- **Each caption ≥ 0.5s on screen** — shorter = unreadable.
- **Word timings must match transcript.json within 80ms** — a caption firing 500ms off-beat destroys the scene illusion. Cinematic runs `check-timing.cjs --strict` before rendering (via render-and-composite.sh); THEME mode enforces the same timings at compile time instead (make-theme's sequential transcript matcher + verbatim completeness gate — drift is a compile error). Never pack multiple transcript words into one entry (e.g. `"FUTURE OF"` or an `IT` + line-break + `ALL` stack with one start/end) — the second word inherits the first's timestamp and fires early. Split them into separate word entries with their own timings, even if you want them on the same visual line (use CSS `white-space` / natural wrap instead of `<br>`). Creative substitutions where caption text ≠ transcript (e.g. `"15%"` replacing `"fifteen percent"`) are supported — register them in `CREATIVE_SUBS` inside `check-timing.cjs`.
- **Group windows must envelop their words** — `group.in ≤ min(word.start)` and `group.out ≥ max(word.end)` for every group. If `group.in` is later than a word's start, the word is silently delayed until the container mounts (we've shipped 800ms lag bugs from this). The validator enforces this.
- **No two caption groups may overlap in both time AND screen region** — overlapping-in-time captions create text-on-text pileups. Options: (a) **spatial separation** — place each group in a non-overlapping vertical band so they can coexist (memory-wall cascade style); (b) **handoff** — set the earlier group's `out` ≤ the next group's `in` so only one is on screen; (c) **deliberate layered typography** — add `"allow_overlap": true` on one of the groups to silence the validator. The validator estimates each group's vertical bbox from its CSS and flags collisions. Pick (a) by default — it's what makes cinematic-cream feel like a poem accumulating, not a subtitle track replacing itself.
- **Screen-blend fails on bright backgrounds (>180 luminance).** **Cinematic** templates are cream + `screen` and that DNA is **locked** (the plan can't recolour them) → on a bright backdrop they wash out, so pick `ink` (letterpress built FOR bright surfaces) or the `anchor` theme (opaque rail surface) rather than overriding a look.
- **Don't animate `letter-spacing` or `filter:blur` on word entrance** — inline-block reflow causes line-jumps.
- **CoreML banned for matting** — the onnxruntime CoreML EP's mixed-precision partitioning corrupted face alpha (observed with the previous RVM engine; don't re-try it). Matting is CPU-only (~2 fps @1080p ≈ 2-3 min per 10s clip; budget for it on long clips).

---

## Dependencies

- **hyperframes**, built (`packages/cli/dist/cli.js`). Scripts auto-resolve the checkout: `HYPERFRAMES_ROOT` env → repo root if this skill ships _inside_ hyperframes → `~/Downloads/hyperframes`. Build with `bun install && bun run build`.
- **Node-first; two Python touchpoints via `uvx` (no manual installs):** transcription runs WhisperX through `uvx` (word-level timings; falls back per SKILL §transcription), and Theme's `drawon` setpiece shells `python3 scripts/gen-stroke-path.py` at compile time. Everything else runs on the toolchain hyperframes already ships: matting via the hyperframes CLI's **`remove-background`** (u2net_human_seg; weights auto-download once, ~168 MB, to `~/.cache/hyperframes/`), image/alpha math via **`sharp`**, layout/occlusion/overflow via **`puppeteer`**, plus **`ffmpeg`**. The scripts auto-resolve these from the hyperframes checkout — nothing extra to install.
- **Transcription = WhisperX via `uvx`** (word-level timings + alignment; no manual install — `transcribe.cjs` drives `uvx whisperx`). Falls back to an existing word-level `transcript.json` if present.
- **Source video** — `matte.cjs` / `transcribe.cjs` auto-resolve `source.mp4` (or glob the clip / read `hyperframes.json`), so `hyperframes init --video X.mp4` needs no manual rename.
- **fps** — `matte.cjs` extracts at the source's native rate and records `matte.fps`; `render-and-composite.sh` uses that so the matte stays frame-aligned.
- Matting weights are NOT bundled: `matte.cjs` shells the hyperframes CLI's `remove-background`, which downloads u2net_human_seg (~168 MB, Apache-2.0) once to `~/.cache/hyperframes/background-removal/models/`. First prepare on a fresh machine needs network for that one download.

If a hard dependency is missing, STOP and ask the user — don't silently skip steps.
themes/anchor.json
{
  "name": "anchor",
  "voice": "the quiet professional — a verbatim rail that simply reads (2-frame fade-ups, accent emphasis), and one climax that settles into the scene behind the subject with a drawn accent underline. No spectacle: discipline IS the style. This is the conservative default that replaced Standard mode.",
  "when": "explainers, tutorials, product walkthroughs, corporate and news-adjacent clips — anywhere every word must be read and nothing should show off. Pick it when unsure.",
  "register": "calm",
  "fonts": { "body": "Sora", "hero": "Sora", "tag": "Sora" },
  "palette": { "body": "#F2EFE9", "accent": "#D9B98C", "em": "#EFCB9A" },
  "body": {
    "paradigm": "rail",
    "layer": "fg",
    "fontPx": 46,
    "bottomPx": 96,
    "letterSpacing": "0.012em",
    "entrance": "fade",
    "exit": "fade",
    "minorScale": 1.0,
    "yield": { "dim": 0.55, "pre": 0.2, "post": 0.9 }
  },
  "hero": {
    "setpiece": "settle",
    "layer": "bg",
    "fontPx": 150,
    "params": {}
  },
  "plate": { "dim": 0.16, "pushIn": 0.012, "grain": 5 },
  "linkages": []
}
themes/arcade.json
{
  "name": "arcade",
  "voice": "8-bit cabinet attract mode — the whole frame plays through a CRT (scanlines + vignette) above a bordered HUD bar (1UP blinking on the classic cadence, CREDIT 01) where every word coin-pops in on steps(2) with a 1-frame white blip; emphasis words run a cyan→magenta palette swap. The apex is a BOSS INTRO behind the subject: the room dims, WARNING blinks twice, ~64 seeded pixel blocks fall quantized into a matrix grid over the word rect, then the word flashes ON in Press Start 2P with chromatic ghost layers + a 3-frame palette strobe while letterbox bars slam in and a HI-SCORE odometer rolls top-right; everything dissolves checkerboard and the blocks drop away.",
  "when": "gaming, retro-tech, playful hype, launches, achievement/level-up beats, '像素/街机/8-bit/打游戏' asks — anything that should read as an arcade cabinet boss fight. Loud register; the boss dim + dark HUD bar bring their own contrast on bright skies.",
  "register": "loud",
  "fonts": { "body": "Press Start 2P", "hero": "Press Start 2P", "tag": "Press Start 2P" },
  "palette": {
    "body": "#FFFFFF",
    "accent": "#00E5FF",
    "magenta": "#FF2E88",
    "yellow": "#FFE600",
    "credit": "#7ADCFF"
  },
  "body": {
    "paradigm": "hudrail",
    "layer": "fg",
    "fontPx": 26,
    "bar": { "h": 70, "inset": 24, "bottom": 50 },
    "tags": { "left": "1UP", "right": "CREDIT 01" },
    "scan": 0.06,
    "textTransform": "uppercase",
    "yield": { "dim": 0.55, "pre": 0.09, "post": 1.04 }
  },
  "hero": {
    "setpiece": "bossintro",
    "layer": "bg",
    "fontPx": 84,
    "params": {
      "seed": 13013,
      "rows": 4,
      "tag": "WARNING",
      "flashDelay": 0.14,
      "hold": 1.24,
      "letterbox": 44
    }
  },
  "fx": { "coinflash": 0.55 },
  "plate": {
    "dim": 0.58,
    "punch": 0.04,
    "punchDecay": 9,
    "punchOffset": 0.14,
    "shakeAmpX": 7,
    "shakeAmpY": 5,
    "shakeHz": 12,
    "shakeDecay": 7,
    "shakeWindow": 0.6,
    "grain": 6
  },
  "linkages": []
}
themes/aurora.json
{
  "name": "aurora",
  "voice": "polar-night elegance — the sky is alive and every caption is woven light. Two huge aurora blobs drift the top of frame all clip; body words are silk serif ink whose rose→teal→violet sheen drifts continuously through the letters (emphasis words get one bright highlight sweep), lines share two coexisting rows like a woven couplet and dissolve UPWARD into drifting light streaks. The apex is a SILK RIBBON: the hero word is hand-WRITTEN into the sky behind the subject stroke by stroke (single-line script font, blurred halo + bright core, a light mote riding the pen) — and the signature: the aurora gradient keeps FLOWING through the drawn ribbon during the hold while a soft spill tints the sky and light motes rise past the subject. Gentle — no shake.",
  "when": "dreamy / romantic / wonder content, night-sky and travel pieces, poetry, weddings, elegant brand films, '极光/丝带/优雅手写字幕' asks — anything that should feel written by light rather than printed. Soft register; the apex scrim + dark understroke carry contrast on bright skies.",
  "register": "elegant",
  "fonts": { "body": "Cormorant Garamond", "strokeFont": "HersheyScript1.svg" },
  "palette": {
    "body": "#F2EFF7",
    "rose": "#FF9EC4",
    "accent": "#7FE0D4",
    "violet": "#B9A8FF",
    "deep": "#070C24"
  },
  "body": {
    "paradigm": "sheenrail",
    "layer": "fg",
    "fontPx": 48,
    "bottomPx": 76,
    "rowDy": 58,
    "band": { "w": 860, "h": 170 },
    "streaks": 4,
    "seed": 20260611,
    "yield": { "dim": 0.5, "pre": 0.16, "post": 0.62 }
  },
  "hero": {
    "setpiece": "silkribbon",
    "layer": "bg",
    "params": {
      "targetWidth": 880,
      "window": 0.68,
      "haloWidth": 18,
      "coreWidth": 6,
      "wash": 0.12,
      "deepen": 0.2,
      "spill": 0.3
    }
  },
  "fx": { "silkmotes": 6, "seed": 20260612 },
  "plate": { "scrim": 0.72, "pushIn": 0.012, "grain": 5 },
  "linkages": []
}
themes/biolume.json
{
  "name": "biolume",
  "voice": "deep-sea bioluminescence — the clip sinks into an abyss grade with a breathing vignette and every caption is a living light. Body words GLOW ON like jellyfish (2-frame opacity + radial bloom from center while a cyan glow breathes in), emphasis words pulse a slow double-beat (propulsion), lines float on a gentle drift across two coexisting rows and SINK away dimming; seeded plankton motes drift the open water and the ones ringing the apex get pulled into the bloom. The apex is a bioluminescent BLOOM behind the subject: a creature pre-glow double-beats and crushes to a point, the word condenses in, six luminous tendrils draw outward like filaments while caustics shimmer and cyan light spills onto the scene — then all the light sinks back into the deep.",
  "when": "ocean / nature / science-wonder docs, dreamy or meditative monologues, slow night-time reveals, 'underwater / 深海 / 发光水母' asks — anything that should feel alive and submerged rather than printed. Soft register; the abyss grade + pocket scrim make their own contrast on bright scenes.",
  "register": "dreamy",
  "fonts": { "body": "Fredoka", "hero": "Fredoka", "tag": "Fredoka" },
  "palette": {
    "body": "#E9FFFA",
    "accent": "#6DF2E0",
    "violet": "#9D7BFF",
    "ice": "#8FE8FF",
    "core": "#F2FFFD",
    "em": "#F4FFFD",
    "mote": "#96F5E8",
    "spore": "#BDFFF4"
  },
  "body": {
    "paradigm": "planktonrail",
    "layer": "fg",
    "fontPx": 44,
    "bottomPx": 70,
    "rowDy": 58,
    "motes": { "count": 12, "near": 5, "seed": 2929 },
    "yield": { "dim": 0.55, "pre": 0.17, "enter": 0.8, "post": 0.63 }
  },
  "hero": {
    "setpiece": "biobloom",
    "layer": "bg",
    "fontPx": 120,
    "params": { "crush": 0.11, "drift": -30, "mood": 0.92 }
  },
  "fx": { "sporeburst": 7, "seed": 2930 },
  "plate": { "scrim": 0.55, "pushIn": 0.012, "grain": 5 },
  "linkages": []
}
themes/brush.json
{
  "name": "brush",
  "voice": "sumi-e study — the lower third is a washi paper band (deckle-edged under-sheets, warm translucent sheet) where every word is brush-WIPED on behind a traveling brush-head shadow in a Mincho serif, seeded ±1° tilt; emphasis words get a double curved ink underline drawn beneath, and spent lines are wiped OFF right-to-left like a wet cloth. At the apex a warm paper wash charges behind the subject and the hero word is written in ONE violent calligraphic gesture — a pressure-tapered wet brush (four stacked stroke widths) slapping down on a start-splat, spattering droplets at the sharpest curves, ink flecks kicking past the subject — then the ink BLEEDS into the paper through the hold and a vermilion SEAL chop stamps the work with the plate punch.",
  "when": "calm-authority explainers, craft / design / philosophy / Japan-adjacent content, tea-ceremony-paced brand films, meditation and writing channels — anything that wants ink-and-paper elegance with one violent flourish. The dark ink needs a mid/bright scene band (the paper wash supplies its own ground on bright skies).",
  "register": "contemplative-elegant",
  "fonts": {
    "body": "Shippori Mincho",
    "seal": "Shippori Mincho",
    "strokeFont": "HersheyScript1.svg"
  },
  "palette": {
    "ink": "#19120b",
    "nib": "#1a130c",
    "body": "#221a12",
    "ulInk": "#2a1c12",
    "bh": "#281e14",
    "paperHi": "#efe8d7",
    "paperLo": "#e7dfcb",
    "sheetHi": "#f5efe2",
    "sheetLo": "#ece4d2",
    "wash": "#f6f0e2",
    "wash2": "#f4edde",
    "seal": "#d24a32",
    "sealMid": "#c03422",
    "sealLo": "#a92a1a",
    "sealText": "#f7efe2",
    "accent": "#c03422"
  },
  "body": {
    "paradigm": "brushrail",
    "layer": "fg",
    "fontPx": 44,
    "weight": 600,
    "strip": { "w": 820, "h": 90, "bottomPx": 61 },
    "maxHold": 0.78,
    "seed": 20260611,
    "yield": { "dim": 0.55, "pre": 0.16, "post": 0.46 }
  },
  "hero": {
    "setpiece": "brushwrite",
    "layer": "bg",
    "params": {
      "targetWidth": 760,
      "window": 0.45,
      "bleedWidth": 16,
      "fatWidth": 18,
      "midWidth": 10,
      "coreWidth": 5,
      "spatterN": 6,
      "wash": 0.34,
      "washSettle": 0.27,
      "washH": 420,
      "bleed": 0.38,
      "breatheHz": 0.55,
      "sealGlyph": "観",
      "sealDelay": 0.04,
      "sealSize": 64,
      "seed": 380611
    }
  },
  "fx": { "brushflecks": 6, "seed": 20260611 },
  "plate": {
    "punch": 0.04,
    "punchDecay": 9,
    "punchOffset": 0.49,
    "shakeAmpX": 7,
    "shakeAmpY": 5,
    "shakeHz": 12,
    "shakeDecay": 7,
    "shakeWindow": 0.6,
    "grain": 6
  },
  "linkages": []
}
themes/chalkboard.json
{
  "name": "chalkboard",
  "voice": "night lecture — the lower third is a worn deep-green chalk band where every word is SCRIBBLED in fast chalk (2-frame pop + scribble reveal + dust puff, seeded crooked), emphasis gets double underline dashes and the final word lands in yellow chalk; between sentences an ERASER SWIPE smears the old words off and leaves ghost smudges. At the apex the band grows into a full blackboard behind the subject and the hero word is HAND-WRITTEN 130px tall in chalk stroke by stroke (jittered nib, dust falling from the pen), finished with a hand-slap dust clap and two violent underlines, then the halo breathes.",
  "when": "education/explainers, science and lecture content, teacher channels, documentary asides, 'professor energy' brand spots. Reads as handwriting, so it suits thoughtful pacing; the deep-green board supplies its own contrast on bright scenes.",
  "register": "scholarly-warm",
  "fonts": { "body": "Caveat", "tag": "Caveat", "strokeFont": "HersheyScript1.svg" },
  "palette": {
    "body": "#f3f6ee",
    "em": "#f3d96b",
    "accent": "#fbf9ef",
    "chalk": "#fbf9ef",
    "dust": "#f6f4e9",
    "board": "#1d3328",
    "boardHi": "#22392c",
    "boardLo": "#182b22",
    "bandHi": "#23392d",
    "bandLo": "#16271f",
    "rim": "#8a6a48",
    "rimLo": "#5f452c"
  },
  "body": {
    "paradigm": "chalkrail",
    "layer": "fg",
    "fontPx": 52,
    "strip": { "w": 1200, "h": 148, "bottomPx": 0 },
    "rows": [26, 82],
    "tag": "OBSERVATORY — NIGHT LECTURE · wk 07",
    "seed": 13,
    "eraseDelay": 0.23,
    "yield": { "dim": 0.55, "pre": 0.07, "post": 0.41 }
  },
  "hero": {
    "setpiece": "chalkwrite",
    "layer": "bg",
    "params": {
      "targetWidth": 860,
      "window": 0.78,
      "coreWidth": 7,
      "haloWidth": 14,
      "halo": 0.18,
      "dustN": 8,
      "seed": 7,
      "jitterSeed": 21,
      "dim": 0.22,
      "vig": 0.35,
      "boardGap": 0.554
    }
  },
  "fx": { "chalkclap": 12, "seed": 13 },
  "plate": { "grain": 5 },
  "linkages": []
}
themes/dossier.json
{
  "name": "dossier",
  "voice": "cold-war case file — the clip is typed onto a manila transcript strip (carbon Special Elite hammered in char by char on seeded baselines, red ink circles around the evidence), and the verdict lands as a RUBBER STAMP behind the subject: the footage goes sepia-archival, the stamp crushes in at -8°, double-strike ghost, thin-ink patches, then holds DEAD STILL — sold only by a breathing paper indent — until the page is filed away with a mechanical yank.",
  "when": "investigations, exposés, true-crime, document-driven storytelling, 'the record shows' narration, declassified/archival framing. Works on any luma (the manila strip is self-lit; the stamp brings its own sepia charge).",
  "register": "archival-noir",
  "fonts": { "body": "Special Elite", "hero": "Special Elite", "tag": "Special Elite" },
  "palette": {
    "body": "#2b2722",
    "accent": "#b3271e",
    "paper": "#d8c49a",
    "ink": "#8a6f3f",
    "fleck": "#8e1f17"
  },
  "body": {
    "paradigm": "carbonstrip",
    "layer": "fg",
    "fontPx": 40,
    "strip": { "w": 764, "h": 132, "bottomPx": 4 },
    "rows": [36, 82],
    "header": "CASE N°018 — TRANSCRIPT OF RECORDING",
    "watermark": "CONFIDENTIAL",
    "seed": 180618,
    "yield": { "dim": 0.55, "pre": 0.02, "post": 0.41 }
  },
  "hero": {
    "setpiece": "rubberstamp",
    "layer": "bg",
    "fontPx": 120,
    "params": {
      "crush": 0.074,
      "hold": 2.02,
      "rotation": -8,
      "paperRot": 2.4,
      "sepia": 0.14,
      "patches": [
        [27, 36, 0.72],
        [71, 68, 0.57]
      ]
    }
  },
  "fx": { "inkflecks": 12, "seed": 180618 },
  "plate": {
    "charge": 0.32,
    "dim": 0.26,
    "punch": 0.04,
    "punchOffset": 0.074,
    "punchDecay": 9,
    "shakeAmpX": 7,
    "shakeAmpY": 5,
    "shakeHz": 12,
    "shakeDecay": 7,
    "shakeWindow": 0.6,
    "grain": 6
  },
  "linkages": []
}
themes/graffiti.json
{
  "name": "graffiti",
  "voice": "street bombing run — the lower third is a marker rail where words FLICK on crooked like fast handstyles (1-frame squeak skew, seeded rotation, back.out settle); emphasis words take alternating paint colors (yellow/magenta) with a spray-underline swoosh + overspray dots, spent lines are ROLLER-BUFFED away by a gray paint roller whose wet smear lingers, and the final word lands BIG in yellow with the punch. At the apex a writer steps up behind the subject: the dusk wall dims, the can RATTLES, and the hero word is spray-TAGGED stroke by stroke in fat yellow paint (dark under-stroke armor + soft overspray halo + glowing nib), drips grow from the low points through the hold and a magenta paint splat pops at the pen-finish.",
  "when": "street culture, hip-hop / rap, sneaker and fashion drops, urban sports, skate/BMX, rebellious brand spots — anything that wants real handstyle energy. Loud register; the dark under-stroke armors the tag against bright skies.",
  "register": "loud-street",
  "fonts": {
    "body": "Permanent Marker",
    "tag": "Permanent Marker",
    "strokeFont": "HersheyScript1.svg"
  },
  "palette": {
    "body": "#f7f2e7",
    "accent": "#ffd23f",
    "em": "#ffd23f",
    "em2": "#ff2e88",
    "under": "#241008",
    "nib": "#fff6d9",
    "dripMid": "#eab928",
    "dripLo": "#d8a51d",
    "rollerHi": "#8d8a86",
    "rollerLo": "#6f6c69",
    "roller": "#807d79"
  },
  "body": {
    "paradigm": "markerrail",
    "layer": "fg",
    "fontPx": 46,
    "bottomPx": 98,
    "seed": 20260611,
    "yield": { "dim": 0.5, "pre": 0.04, "post": 0.17 }
  },
  "hero": {
    "setpiece": "spraytag",
    "layer": "bg",
    "params": {
      "targetWidth": 1000,
      "window": 0.58,
      "underWidth": 21,
      "haloWidth": 36,
      "coreWidth": 13,
      "haloOpacity": 0.5,
      "osprayN": 6,
      "drips": 3,
      "seed": 140987,
      "dim": 0.22,
      "scrim": 0.62
    }
  },
  "fx": { "paintsplat": 6, "seed": 20260611 },
  "plate": {
    "punch": 0.04,
    "punchDecay": 9,
    "punchOffset": 0.63,
    "shakeAmpX": 7,
    "shakeAmpY": 5,
    "shakeHz": 12,
    "shakeDecay": 7,
    "shakeWindow": 0.6,
    "grain": 6
  },
  "linkages": []
}
themes/hologram.json
{
  "name": "hologram",
  "voice": "volumetric projection — every caption is LIGHT cast from an emitter. The body rail is a scanlined projection plate fed by a desk cone; words boot with a rising sweep + 1-frame interference glitch + chromatic fringe, emphasis words drop out for 2 frames and restore brighter, lines drift in rotationY and exit with the projector cut (collapse to a slit, then a dot). The apex is the full volumetric boot: emitter dot pop, cone snap, seed beam, 7-layer hologram word with climbing interference bands; a hand passes through it before the projector cuts it to the base dot.",
  "when": "tech / sci-fi / AI / product-reveal explainers, futuristic UI talk, space and imaging content — anything that should feel projected rather than printed. Sleek register. Needs a lower frame edge for the emitter dots; the apex scrim makes its own contrast on bright scenes.",
  "register": "sleek",
  "fonts": { "body": "Orbitron", "hero": "Orbitron", "tag": "Orbitron" },
  "palette": {
    "body": "#C7F1FF",
    "accent": "#7AE8FF",
    "magenta": "#FF5FD6",
    "glow": "#3ECCF2",
    "core": "#EAFBFF",
    "em": "#F4FEFF",
    "glowEm": "#A0F2FF",
    "band": "#D8FAFF",
    "dot": "#BFF0FF",
    "dotHot": "#DFF8FF"
  },
  "body": {
    "paradigm": "holorail",
    "layer": "fg",
    "fontPx": 38,
    "bottomPx": 108,
    "plateW": 980,
    "plateH": 96,
    "lineDy": 9,
    "letterSpacing": "0.02em",
    "tag": "VOLUMETRIC FEED // CH-07",
    "emphDelay": 0.34,
    "yield": { "dim": 0.5, "pre": 0.18, "post": 0.37 }
  },
  "hero": {
    "setpiece": "holoboot",
    "layer": "bg",
    "fontPx": 118,
    "params": { "passAt": 1.29, "coneW": 800 }
  },
  "fx": { "holoflash": 0.5 },
  "plate": { "scrim": 0.58, "charge": 0.28, "dim": 0.18, "grain": 5 },
  "linkages": []
}
themes/inkwater.json
{
  "name": "inkwater",
  "voice": "sumi ink dropped in still water — the scene offers paper-white radial washes to the text zones, then Mincho-serif ink words BLEED into a lower-third of three fixed reading rows (blur + scale carry the bleed over a paper-glow halo, a dark blurred halo overshoots and tightens, an ink-settling hairline draws under each line, emphasis words shed a curling ink wisp) and spent lines re-dissolve upward like ink dispersing. At the apex a dark drop falls into the frame, hits an invisible water surface at the word point and BLOOMS — three stacked blurred ink ellipses at different rates, two thin ripple rings — and the hero words resolve out of the blob (blur 16→0) on their own spoken times while the world stills under a gentle vignette; through the hold the glyph edges breathe and the ink finally re-dissolves before the next thought needs the reader.",
  "when": "contemplative, literary or scientific-wonder narration, astronomy / nature / memory / essay films, poetry and slow-craft channels — anything spoken quietly that wants one liquid, meditative event instead of an impact. Dark ink needs a mid/bright scene band (the paper washes supply their own ground on bright skies).",
  "register": "contemplative-still",
  "fonts": { "body": "Shippori Mincho", "hero": "Shippori Mincho" },
  "palette": {
    "body": "#14110e",
    "ink": "#14110e",
    "paper": "#fffaee",
    "paper2": "#fff8e8",
    "wash": "#fff9ee",
    "washApex": "#fffaf0",
    "accent": "#14110e"
  },
  "body": {
    "paradigm": "inkrail",
    "layer": "fg",
    "fontPx": 46,
    "weight": 400,
    "letterSpacing": "0.055em",
    "rowGap": 56,
    "bottomPx": 36,
    "yield": { "dim": 0.5, "delay": 0.12, "post": 1.07 }
  },
  "hero": {
    "setpiece": "inkbloom",
    "layer": "bg",
    "fontPx": 132,
    "params": {
      "fall": 0.15,
      "maxHold": 1.75,
      "clearGap": 0.24,
      "vig": 0.34,
      "washRailW": 1180,
      "washRailH": 300,
      "washApexH": 430
    }
  },
  "fx": {},
  "plate": { "pushIn": 0.012, "grain": 5 },
  "linkages": []
}
themes/laser.json
{
  "name": "laser",
  "voice": "concert laser rig — the room goes to blackout and the light operator types the transcript. Every body word is IGNITED where two thin beams (green from the top-left, magenta from the top-right) converge on it two frames early: flare pop, snap-on in laser green, single strobe on emphasis; a sweeper beam carries each line away. The apex is the drop: a 6-beam array sweeps to one hot point, the word ignites behind the subject in an additive glow stack with two controlled strobe pops, a criss-cross beam cage draws itself around it, then the array fans out slow and wide during the hold before the beams sweep everything off L→R.",
  "when": "music, nightlife, festivals, drops, hype reveals, tech keynote 'one more thing' beats — anything that should read as a light show. Loud register; the concert blackout makes its own contrast on any plate (bright skies included).",
  "register": "loud",
  "fonts": { "body": "Audiowide", "hero": "Audiowide", "tag": "Audiowide" },
  "palette": {
    "body": "#58ff9d",
    "accent": "#58ff9d",
    "magenta": "#ff4df0",
    "core": "#eefff5",
    "glowSoft": "#c9ffe2"
  },
  "body": {
    "paradigm": "laserrail",
    "layer": "fg",
    "fontPx": 40,
    "bottomPx": 98,
    "letterSpacing": "0.02em",
    "wordGapEm": 0.24,
    "textTransform": "uppercase",
    "minorScale": 1.325,
    "yield": { "dim": 0.5, "beamDim": 0.55, "pre": 0.04, "post": 0.9 }
  },
  "hero": {
    "setpiece": "lasercage",
    "layer": "bg",
    "fontPx": 100,
    "params": {}
  },
  "fx": { "laserflash": 0.85 },
  "plate": {
    "charge": 0.46,
    "dim": 0.3,
    "punch": 0.04,
    "punchDecay": 9,
    "punchOffset": 0.07,
    "shakeAmpX": 5,
    "shakeAmpY": 4,
    "shakeHz": 12,
    "shakeDecay": 7,
    "shakeWindow": 0.5,
    "grain": 5
  },
  "linkages": []
}
themes/lastpage.json
{
  "name": "lastpage",
  "voice": "the speaker reads from a manuscript that already exists \u2014 a blurred future-transcript haunts the room all video; one rack-focus reveals every faint line was the apex word. The future was only ever one sentence.",
  "when": "visionary thesis, prophecy-toned founder talks, trailers, literary content. Mid/dark scenes (the field needs air).",
  "register": "literary-prophetic",
  "fonts": {
    "body": "Cormorant Garamond"
  },
  "palette": {
    "body": "#F0E9D8"
  },
  "body": {
    "paradigm": "lastpage",
    "layer": "bg",
    "fontPx": 44,
    "bottomPx": 96,
    "fieldCount": 9,
    "fieldBlur": 9,
    "seed": 4242
  },
  "hero": {
    "setpiece": "rackfocus",
    "inline": true,
    "params": {},
    "maxHold": 0
  },
  "fx": {},
  "plate": {
    "pushIn": 0.012,
    "grain": 6
  },
  "linkages": ["field-is-the-apex"]
}
themes/neonsign.json
{
  "name": "neonsign",
  "voice": "sign district at dusk \u2014 body lines are warm tube light flicking on word by word and dying like power cuts; the apex is a hand-bent neon tube WRITTEN onto the scene stroke by stroke (single-line font draw-on), then it hums.",
  "when": "lifestyle, night/dusk scenes, music, fashion, mood pieces. Needs a darker or dusk backdrop for the glow to read; rose accent loves cool/dusk palettes.",
  "register": "creator-warm",
  "fonts": {
    "body": "Space Grotesk",
    "strokeFont": "HersheyScript1.svg"
  },
  "palette": {
    "body": "#ffe9d6",
    "accent": "#ff3fae",
    "em": "#ff9ed2"
  },
  "body": {
    "paradigm": "rail",
    "layer": "fg",
    "fontPx": 46,
    "bottomPx": 98,
    "letterSpacing": "0.02em",
    "entrance": "flick",
    "exit": "powercut",
    "emScale": 1.18,
    "rule": {
      "width": 560,
      "color": "#ff9ed2"
    },
    "glow": "0 0 7px rgba(255,214,170,0.65), 0 0 22px rgba(255,180,120,0.35), 0 2px 10px rgba(0,0,0,0.55)"
  },
  "hero": {
    "setpiece": "drawon",
    "layer": "bg",
    "x": 560,
    "y": 200,
    "params": {
      "targetWidth": 640,
      "window": 0.78,
      "tubeWidth": 7,
      "haloWidth": 22,
      "hum": true,
      "buzzDipAt": -0.18,
      "rotate": -3,
      "spill": 0.38
    },
    "maxHold": 0
  },
  "fx": {},
  "plate": {
    "pushIn": 0.012,
    "grain": 5
  },
  "linkages": []
}
themes/ordnance.json
{
  "name": "ordnance",
  "voice": "military marking system — everything has mass. Body words STAMP in and cool from hot amber to bone; the apex is a sliced stencil stamp that detonates: sheared slices snap into register, paint cools, positioning bars + designation tag deploy, the room reacts.",
  "when": "punchy announcements, hype, product drops, sports, anything that should hit. Loud register. Needs a scene that tolerates a charge-dim (any luma).",
  "register": "loud",
  "fonts": { "body": "Anton", "hero": "Anton", "tag": "Space Grotesk" },
  "palette": { "body": "#EDE6D8", "hot": "#FFB347", "accent": "#FFB347" },
  "body": {
    "paradigm": "rail",
    "layer": "fg",
    "fontPx": 56,
    "bottomPx": 102,
    "letterSpacing": "0.03em",
    "textTransform": "uppercase",
    "entrance": "stamp",
    "exit": "drop",
    "minorScale": 1.4,
    "yield": { "dim": 0.45, "pre": 0.2, "post": 0.9 }
  },
  "hero": {
    "setpiece": "detonation",
    "layer": "bg",
    "x": 640,
    "y": 268,
    "fontPx": 178,
    "params": {
      "slices": [
        [0, 61.5],
        [40.5, 27.5],
        [74.5, 0]
      ],
      "sliceShear": [-46, 52, -58],
      "bars": true,
      "barWidth": 760,
      "barDy": 124,
      "ticks": true,
      "tag": "APEX-01"
    }
  },
  "fx": { "flash": 0.97, "rings": 2, "sparks": 26, "embers": 7, "seed": 20260611 },
  "plate": {
    "charge": 0.5,
    "dim": 0.32,
    "punch": 0.045,
    "punchDecay": 9,
    "shakeAmpX": 7,
    "shakeAmpY": 5,
    "shakeHz": 12,
    "shakeDecay": 7,
    "shakeWindow": 0.6,
    "grain": 6
  },
  "linkages": []
}
themes/papercut.json
{
  "name": "papercut",
  "voice": "stop-motion paper craft — the frame becomes an animator's table. Body words are cream paper chips PLACED one by one on a kraft strip (steps(2) drop, seeded crooked, handmade jitter weave, washi-tape slaps on emphasis, accent-red sign-off chip); the apex is a torn kraft mat slapped down behind the subject where torn-edge letter chips DROP with stepped gravity, land crooked, wobble at 8fps, then a table bump scatters them off-frame on the final word. A red paper star stands in for the hero word on the rail (rail↔climax hand-off).",
  "when": "craft/DIY, kids and family content, indie product demos, behind-the-scenes, maker channels — anything warm and handmade that suits a 12fps stop-motion heartbeat. Playful register; needs a lower third that tolerates a kraft-paper strip.",
  "register": "playful",
  "fonts": { "body": "Inter", "hero": "Anton", "tag": "Caveat" },
  "palette": {
    "body": "#2b241b",
    "ink": "#2b241b",
    "accent": "#ff4f3f",
    "accentEdge": "#c93527",
    "chip": "#f6edd8",
    "chipEdge": "#cdb98e",
    "paper": "#f8f0dd",
    "paper2": "#efe3c6",
    "tag": "#503d20"
  },
  "body": {
    "paradigm": "paperrail",
    "layer": "fg",
    "fontPx": 38,
    "strip": { "w": 1140, "h": 88, "bottomPx": 26 },
    "tag": "cut & paste · 12 fps",
    "tapeDelay": 0.25,
    "seed": 150372,
    "yield": { "dim": 0.6, "pre": 0.2, "post": 0.9 }
  },
  "hero": {
    "setpiece": "papermat",
    "layer": "bg",
    "fontPx": 130,
    "params": { "step": 0.09, "seed": 151515, "vig": 0.3 }
  },
  "fx": { "paperscraps": 3, "seed": 150372 },
  "plate": { "grain": 6 },
  "linkages": []
}
themes/popup.json
{
  "name": "popup",
  "voice": "a pop-up book opens inside the frame — body words POP UP on a baseline hinge (rotationX back.out, paper overshoot) along a cream page strip that unfolds from the bottom edge (center foldline, two reading rows); emphasis words land bigger with a contact squash the page FEELS, then flick-wobble with a paper strut leaning in behind. The apex is THE CENTERFOLD: blue + cream paper starbursts unfold behind the subject, the hero word pops up in three hinged segments, struts appear, backdrop + word wobble-settle together and breathe — then the whole spread folds flat (the book closing). A blue paper star holds the hero slot on the rail (rail↔climax hand-off); paper confetti flicks past the subject at the pop.",
  "when": "kids and family content, storytelling, education, greeting-card and wholesome brand spots, picture-book or craft channels — anything warm, rounded and gentle that suits a paper toy theater. Playful register; needs a lower third that tolerates a cream paper strip.",
  "register": "playful",
  "fonts": { "body": "Baloo 2", "hero": "Baloo 2", "tag": "Baloo 2" },
  "palette": {
    "body": "#33271a",
    "ink": "#33271a",
    "accent": "#6cb6e3",
    "accentEdge": "#3f7fae",
    "blue": "#2e7cb3",
    "page": "#f7eed8",
    "page2": "#f0e4c8",
    "page3": "#e7d8b4",
    "pageEdge": "rgba(122,96,54,0.55)",
    "cream": "#f6ecd4",
    "creamEdge": "#8a6f45",
    "kraft1": "#a98f5e",
    "kraft2": "#cdbb92",
    "kraft3": "#e6d6af",
    "strut1": "#4f93c4",
    "strut2": "#6cb6e3",
    "conf": ["#f6ecd4", "#6cb6e3", "#e3d2ac", "#f6ecd4", "#6cb6e3", "#33271a", "#f6ecd4", "#e3d2ac"]
  },
  "body": {
    "paradigm": "popuprail",
    "layer": "fg",
    "fontPx": 42,
    "emPx": 52,
    "strip": { "w": 1230, "h": 124 },
    "rowA": 70,
    "rowB": 20,
    "maxHold": 0.95,
    "yield": { "dim": 0.55, "pre": 0.03, "post": 0.47 }
  },
  "hero": {
    "setpiece": "centerfold",
    "layer": "bg",
    "fontPx": 120,
    "params": { "segs": 3, "segStagger": 0.07, "scrim": 0.42 }
  },
  "fx": { "confetti": 8, "seed": 360372 },
  "plate": { "grain": 5 },
  "linkages": []
}
themes/ransom.json
{
  "name": "ransom",
  "voice": "kidnapper's collage — every word is a cutout chip clipped from a different page (cream Anton, black Inter, newsprint typewriter, accent-yellow marker) and PASTED crooked on the rail with torn edges and misaligned baselines; emphasis words get a 1-frame re-paste lift, spent chips are RIPPED off with paper-tear flashes. The apex: the sky darkens behind a collage scrim and the hero word slams in letter by letter as a ransom note — each letter a different chip, flight shadow popping tight on contact — then the whole word JOLTS as the last letter lands (the plate punches with it), looms crooked through the hold, and is torn off chip by chip.",
  "when": "true crime, mystery / thriller content, heist energy, punk / DIY zine aesthetics, edgy product drops, unhinged-comedy hooks, \"勒索信/剪贴/拼贴字幕\" asks. Loud register; the solid chip backgrounds carry their own contrast on any scene.",
  "register": "loud-collage",
  "fonts": {
    "body": "Anton",
    "hero": "Anton",
    "dark": "Inter",
    "news": "Special Elite",
    "accent": "Permanent Marker"
  },
  "palette": {
    "body": "#f5f1e4",
    "paper": "#f5f1e4",
    "paperInk": "#17130d",
    "dark": "#141414",
    "darkInk": "#f7f5ef",
    "news": "#d8d3c5",
    "newsInk": "#262017",
    "accent": "#ffd23f",
    "accentInk": "#1c160c",
    "flash": "#fffdf2"
  },
  "body": {
    "paradigm": "ransomrail",
    "layer": "fg",
    "fontPx": 46,
    "bottomPx": 56,
    "letterSpacing": "0.02em",
    "emphDelay": 0.23,
    "seed": 3940,
    "yield": { "dim": 0.5, "pre": 0.02, "post": 0.71 }
  },
  "hero": {
    "setpiece": "ransomnote",
    "layer": "bg",
    "fontPx": 130,
    "params": {
      "step": 0.06,
      "crush": 0.09,
      "ripStag": 0.045,
      "ripLead": 0.19,
      "scrim": 0.44,
      "pulse": 0.2,
      "seed": 3939
    }
  },
  "fx": {},
  "plate": {
    "punch": 0.04,
    "punchDecay": 9,
    "punchOffset": 0.64,
    "shakeAmpX": 7,
    "shakeAmpY": 5,
    "shakeHz": 12,
    "shakeDecay": 7,
    "shakeWindow": 0.6,
    "grain": 6
  },
  "linkages": []
}
themes/README.md
# THEME mode — composed visual constitutions

Theme mode is the third compiler (`scripts/make-theme.cjs`), beside Standard and
Cinematic. It exists because "mode" was a bundle of orthogonal axes pretending
to be one switch. A theme DNA composes its identity from registries implemented
ONCE in the compiler — **paradigms are the unit of code; DNAs are the unit of
identity**. A new look is a JSON file; only a genuinely new paradigm/setpiece
(rare) touches the compiler.

```
theme DNA = body PARADIGM   how the transcript surface lives
          × body LAYER      fg-alpha (rail.html channel) | bg-embed
          × hero SETPIECE   the climax choreography
          × front FX        flash / rings / sparks / scanband / crowdflash / paflash (fg, over subject)
          × PLATE budget    charge-dim (in-page) + punch/shake/grain (_postfx.sh)
          × LINKAGES        declarative theme interactions
```

Standard and Cinematic were, in retrospect, two fixed points of this space.
Standard is now RETIRED (2026-06-12): its rail×embed-climax point is served by
the `anchor` theme (rail paradigm × settle setpiece — the quiet default).
Cinematic remains a separate compiler; do not re-implement it as a theme yet.

## Unification roadmap (strangler fig — interface first, engines later)

The user-facing model is already unified (SKILL.md Step 0): one catalog of
LOOKS; classic looks pick a DELIVERY (rail | column), themed looks bind their
own. "Standard/Cinematic" are delivery/compiler names, not modes. Remaining
phases, each gated on need — never rewrite for tidiness alone:

- **Phase 2 — one authoring schema.** `lines`/`minors`/`hero` are already
  ~90% shared between standard.json and theme.json; a router that translates a
  single `caption.json` into the engine-specific file removes the last
  user-visible seam. cinematic.json's blocks/planes are the odd one — map the
  common fields, pass engine-specific ones through.
- **Phase 3 — engine convergence.** Port a classic delivery into make-theme
  ONLY when something forces it (e.g. a classic DNA wants a plate budget or a
  setpiece). Acceptance bar: blind A/B on the cap_multi regression scenes vs
  the old compiler — swap engines only when indistinguishable or better. Until
  then the old compilers are the reference implementation of 8 rounds of
  validated typography (lockup/orbit, multi-climax, ratio-lock, per-plane
  legibility, occlusion adjudication) — that machinery is the moat, not debt.

## Body paradigms (registry)

| paradigm      | surface                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          | layer | entrance verbs                                                                                                                                                                                                                                                                                           | exit verbs                                                                                                                                                                                                                         |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `rail`        | lower-third lines, replace                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       | fg    | `stamp` (1f hot appear→crush→recoil→cool), `flick` (tube double-flick), `bootflick` (corrupted-cyan boot 1–2f → settles to UI color; minors keep a cyan/red registration shadow). `stamp` minors cool to `palette.minorCool` (default amber)                                                             | `drop` (mass falls), `powercut` (bright→ghost→off)                                                                                                                                                                                 |
| `panel`       | docked glass console, accumulate + typed                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | fg    | `type` (width steps per word, $ prompt, caret blinks)                                                                                                                                                                                                                                                    | hold                                                                                                                                                                                                                               |
| `poem`        | open-space stanzas, accumulate; letters CONDENSE from seeded dust                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                | fg    | `condense`                                                                                                                                                                                                                                                                                               | `drift` (stanza hand-off) + optional dispersal linkage                                                                                                                                                                             |
| `takeover`    | full-frame hard-cut cards, plate dimmed throughout                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               | bg    | `cut` (+ tension `creep` before silences, auto-detected)                                                                                                                                                                                                                                                 | hard cut / blur dissolve                                                                                                                                                                                                           |
| `lastpage`    | manuscript rail (typed serif) + a seeded field of BLURRED apex-word instances haunting the room from t=0                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | bg    | typed rail; field breathes imperceptibly                                                                                                                                                                                                                                                                 | rail fades; field resolves at apex (see `rackfocus`)                                                                                                                                                                               |
| `flaprail`    | split-flap word tiles on a chyron bar (tag + ROW flip-counter; optional odometer chip docks above the bar while the first emphasis line reads)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | fg    | `splitflap` (3 flips: wrong word → wrong word → real word clacks on hot, squash → elastic settle → cools to bone; emphasis tiles keep amber text + underline)                                                                                                                                            | flip-to-blank cascade left→right (completes before the next line)                                                                                                                                                                  |
| `ledboard`    | amber LED departure strip docked lower-third (`body.board` geometry); lines page by vertical scroll-snap (steps(6)) inside the board window; furniture: green platform tag, ticking seconds clock (stacked digit reel), ⚠ DELAYED blinks then pages to ● ON TIME ~0.7s after the apex, idle LED shimmer in dead air ≥1.2s                                                                                                                                                                                                                                                                                                                        | fg    | `ledwipe-mini` (per-word L→R LED column wipe, clipPath steps(len), + 1-frame arrival flicker; emphasis words get the LED-refresh double-blink; last word gets one late refresh)                                                                                                                          | scroll-snap page-up (next line pages in at the same instant)                                                                                                                                                                       |
| `vhsrail`     | camcorder-playback lower third: every word carries a PERMANENT 1px red+cyan misregistration fringe (split clip-path halves); furniture: ▶ PLAY / ◀◀ REW top-left (REW gag in dead air ≥0.7s + the final rewind), ● REC top-right through the apex window, green timestamp bottom-right with 1 Hz blinking colon, idle tape-drift line in long silences, plus apex-coupled artifacts (1-frame white tear band + head-switching noise bar at REC FREEZE, scrub lines at the apex band on the hero rewind)                                                                                                                                          | fg    | `trackglitch` (instant on + 2-frame y-shake + 1-frame band-slice shift of the word's bottom half; emphasis = PAUSE jitter ±1px y oscillation + white noise-band flicker)                                                                                                                                 | REWIND streak left + flat-white echo copy + 2 scrub-line flickers                                                                                                                                                                  |
| `hudrail`     | arcade-cabinet HUD: whole frame plays through CRT overlays (scanlines `body.scan` + corner vignette); docked HUD bar (`body.bar` geometry, 2px accent border, glow) with `body.tags` 1UP / CREDIT furniture — the 1UP tag blinks on the classic 0.45s attract cadence all clip; HUD yields (`body.yield`) while the boss lands                                                                                                                                                                                                                                                                                                                   | fg    | `coinpop` (instant-on + steps(2) scale pop from the baseline + 1-frame white coin-blip tick over the word; emphasis = 3-frame palette swap body→accent→magenta→body)                                                                                                                                     | checkerboard dissolve (words vanish in 2 alternating 2-frame passes, line off ≤ DUR−0.02)                                                                                                                                          |
| `carbonstrip` | cold-war case-file transcript strip docked on the clearer side (manila gradient, punch holes, faint red `body.watermark`, typed `body.header` that types on by width steps); lines live on up to `body.rows` fixed slots; PAGES (broken at full rows, dead-air gaps > 0.7s, and after the hero hand-off line — it is filed away while the stamp owns the frame); the strip top-shadow breathes during the stamp's dead-still hold and the strip yields (`body.yield`) while it lands; the final page is pulled with the whole strip at clip end                                                                                                  | fg    | `hammer` (per-char sets 30ms apart, scale 1.35→1 in one frame = the stamp motif at low amplitude; seeded ±1.2px baselines + seeded ribbon-ink opacity; every strike kicks the strip 1px — y-channel locked while the strip itself tweens; emphasis words get a red ink ellipse drawn around them, 0.25s) | X-row over-strike (3 chunks per line, 0.05s apart, lines 0.16s apart) → carriage yank-down feed → return; roomy cadence in dead air, compressed when the next page is close — always completes before the next page's first strike |
| `laserrail`   | concert lower third: lines centered on a bottom rail (`body.bottomPx`), uppercase laser-green words with a neon text-glow; emphasis words render at `body.minorScale`× with a hotter glow; word centers are BAKED at compile time from the measured char-width table (`assets/fonts/char-widths.json`, ≤3px vs browser ground truth) so the beam aim can't be skewed by font-load timing; per-word furniture = two thin beams (accent from top-left, magenta from top-right) that sweep to CONVERGE on the word 2 frames early + a convergence flare; beams dim ×`yield.beamDim` inside the apex window and the visible line dims to `yield.dim` | fg    | `igniteconverge` (beams converge at st−0.083 → flare pop → word snaps on at opacity 1 with brightness 1.85, crush 0.09s power3.in → 2-frame contact squash → elastic settle → brightness cools; emphasis = single 1-frame strobe off/on + lingering beams)                                               | sweeper beam crosses L→R in 0.13s and each word streaks away (x+44, blur 5) as it passes; line off ≤ DUR−0.02                                                                                                                      |

| `stormrail` | rain-washed lower third: ambient rain = `body.rain.count` seeded thin streaks falling on repeating linear cycle tweens (pure f(t), one slanted wind vector, mid-fall at t=0); a second seeded set DOUBLES the rain for ~1s after the strike; lines sit on a bottom gradient scrim (stable reading surface); the visible line dims to `yield.dim` while the bolt lands and its text-shadow flips toward the strike (`hero.params.side`) for the flash frames | fg | `lightningpop` (the apex motif at low amplitude: instant on at brightness 2.2, y+4, scale 1.06 → 0.12s micro settle; emphasis = sheet-lightning double flash, 2-frame brightness 1.9/1.0/1.8 chain — the bg sky double-kisses at the same beats) | wash downward with the rain (y+22, blur 3, 0.18s power1.in); a line facing dead air washes early at lastWord+1.08s; line off ≤ DUR−0.02 |
| `holorail` | volumetric projection plate: a scanlined glass slab (`body.plateW`×`plateH`, centered at `H − bottomPx`) with corner brackets + feed tag (`body.tag` / `theme.plateTag` override) boots like a projector turning ON (scaleY 0.03→1 expo + 1-frame flicker) and breathes a finite yoyo luminance cycle sized to the clip length; lines sit centered IN the plate (`lineDy` below plate center) with a constant chromatic-fringe text-shadow (−1.5px accent / +1.5px magenta) and drift slowly in rotationY (it IS a projection); the whole railwrap — plate included — dims to `yield.dim` while the apex lands | fg | `sweepboot` (the apex motif at low amplitude: instant opacity + rising clip-wipe reveal 0.15s + 1-frame x interference glitch at st+0.05/0.09/0.13; emphasis = 2-frame projection DROPOUT at `body.emphDelay`, restores brighter in `palette.em` with a hotter fringe, then settles back — the delay auto-shrinks near the line's exit so dropout+restore always complete) | projector cut — brightness 2.2 pop → collapse to a horizontal slit (scaleY 0.045, 0.07s power3.in) → to a dot (scaleX 0.015, 0.06s) → off at exit+0.15; the plate itself gets the same cut at DUR−0.14 |
| `planktonrail` | deep-sea lower third on TWO coexisting row slots: even lines ride the upper row (`H − bottomPx − rowDy`), odd lines the lower (`H − bottomPx`) — couplets share the water like drifting strata. A line clears when its row is next needed (line i+2 in − 0.15) or ~0.3s after its couplet finishes speaking (staggered pair exits, +0.12s on odd rows); the pre-apex leftover clears at heroIn−0.21 while the FEEDING line (last to start before the apex) yields to `yield.dim` instead — no restore unless ≥1.2s of hold remains (it exits dimmed, by design); a line entering during the hold arrives at `yield.enter` and restores at I+`yield.post`; every line floats on a gentle y keyframe drift sized to its window. Furniture: `body.motes` = {count, near, seed} plankton motes on seeded drift walks f(t); the first `near` seed a ring around the apex heart (ring radius scales with hero fontPx/120) and get ATTRACTED to the bloom at I−0.19 (pulled 0.72× toward it, brightening to 0.42 at scale 1.7), wiggle through the hold, and sink at clip end | fg | `glowon` (the apex bloom at low amplitude: 2-frame opacity ramp + radial bloom from word center — scale 0.92→1, blur 5→0, brightness 0.45→1, 0.4s sine.out — while a cyan glow text-shadow grows in; emphasis = slow DOUBLE-BEAT propulsion pulse at st+0.52, 5-key brightness/scale 0.55s, guarded ≤ DUR−0.09) | light sinks — y+15 while dimming, 0.3s power1.in; the last line floats to the end of the clip; line off ≤ DUR−0.02 |
| `sheenrail` | iridescent silk lower third on TWO coexisting row slots: even lines ride the upper row (`H − bottomPx − rowDy`), odd lines the lower (`H − bottomPx`) — couplets read together like a woven pair. Words are aurora-gradient ink (background-clip:text over a rose→teal→violet 300% wash whose backgroundPosition drifts f(t) 0%→300% across the whole clip — the body's hold life) above a blurred dark understroke (top+3px, blur 3); a soft radial band scrim sits under the rows (stable reading surface). Line scheduling mirrors planktonrail: a row clears when next needed (line i+2 in − 0.15) or shortly after its couplet finishes speaking (pairEnd + 0.23 + 0.17 on odd rows); the pre-apex leftover clears at heroIn−0.32 while the FEEDING line stays and dims with the railwrap yield (`yield.dim` at I−`pre`, restore at I+`post` — band included); the last line holds to the end | fg | `flowon` (the ribbon's flow at low amplitude: 2-frame opacity + y8 settle 0.38s sine.out; emphasis = ONE bright sheen sweep — a white highlight marches through the word once at st+0.16 (backgroundPosition −80%→180%, 0.42s power2.inOut) + a brightness 1.55 pulse) | dissolve UPWARD — words to y−12, blur 3, 0.32s power1.in staggered 0.035; each line releases `body.streaks` seeded light streaks (rose/teal/violet verticals) that float up from its row (y 14→−78, 0.62s, start clamped ≤ DUR−0.66) |
| `scoperail` | oscilloscope lower third: a dark phosphor scope band (`body.bandH`, top border + glass shadow) hosts HUD readouts (`body.hudL` left, sample-rate `· TRIG` + amber trig LED right), a graticule, and an ALWAYS-ALIVE waveform — an SVG polyline rebuilt from baked point-set states (seeded pseudo-RMS envelope at ~12 states/s + 3-frame triplets on word arrivals: speech grows the amplitude, silence flattens it, every word kicks a local gaussian burst at its slot center — burst x BAKED at compile time from the measured char-width table; the hero words kick WIDE bursts spread from `HG.x` toward `HG.x + 0.6·halfW`, where the trace surges to write). Body lines sit centered on the band above the trace with reserved-width slots (sizer + clip — no re-center drift as words arrive); the whole band (lines + wave + HUD) dims to `yield.dim` while the apex writes. At the FINAL transcript word the scope DIES: band x-shake, trig LED panics red then dies, the trace explodes to max-amp chaos for ~0.3s, flatlines, collapses to a single dot (scaleX 0.015) and `body.siglost` stamps with a 2-frame flicker (all clamped ≤ DUR−0.02) | fg | `tracewipe` (the apex pen-write at low amplitude: clip wipes the word in L→R over 0.10s + a 2-frame just-traced flash — flash-white instantly, settle to phosphor green; the death word settles to `palette.em` amber; the trig LED blinks on every arrival; emphasis = a ticking frequency-readout chip above the word, 3 seeded ascending values stepping every 2 frames) | the line collapses INTO the waveform (y+30, 0.13s power2.in) — early into dead air (lastWordStart+0.67), else just before the next line needs the slot; the last line collapses as the trace flatlines (min(NZ+0.33, DUR−0.2)) |
| `paperrail` | stop-motion craft table: a kraft strip (torn top edge via seeded polygon clip-path, washi-taped corners, handwritten `body.tag` in the tag font) docked lower-third (`body.strip` geometry, centered by default); body words are cream paper chips (`palette.chip` on `palette.chipEdge` border, hard dy3 shadow) on centered replace-lines; the whole strip jitters ±0.7px every 4 frames (handmade stop-motion weave); the hero slot keeps a red paper star (`palette.accent`, rail↔climax hand-off) that pulses 1.18→1.06→1 as the apex chips land; the FINAL transcript word is an accent-red sign-off chip; emphasis words get a washi-tape slap (steps(2) scale 1.7→1 at `word.start + body.tapeDelay`, chip pressed 1 frame); strip + lines yield (`body.yield`) while the apex lands | fg | `place` (the papermat drop at low amplitude: opacity full in 0 frames + seeded ±2.5° rotation, y −16 + scale 1.06→1 steps(2) 0.12s; hold = rotation-only ~5fps wobble) | `peel` (rotation −8°, y −40, fade, steps(3), staggered 0.03s; last line clamped to finish on frame) |
| `popuprail` | pop-up book page: a cream page strip (`body.strip` geometry, center foldline, top border + paper glow inset) unfolds up from the bottom edge (scaleY 0.02→1 back.out at 0.14 — the stage is built) and hosts TWO reading rows (`body.rowA`/`rowB` px above H) that alternate per line; the whole page breathes a gentle y drift sized to the clip; the hero slot keeps a blue paper star chip (`palette.accent` clip-path star, rail↔climax hand-off — spliced into the line whose window holds heroIn); emphasis words land at `body.emPx` weight 800 with a contact squash (1.06/0.93 set → elastic settle) the strip FEELS (1px dip), then flick-wobble (5-key rotationX, squeezed to fit before the fold) with a blue paper strut leaning in behind (scaleY back.out(2)); the FINAL transcript word is a blue sign-off with a strut; page yields (`body.yield`) while the centerfold lands; on the final word the whole page TILTS 2° +x6 (paper physics, `body.tilt:false` opts out) and the strip folds shut on the last frame (scaleY power3.in ≤ DUR−0.017) | fg | `popup` (the centerfold motif at low amplitude: opacity 1 in 0 frames + rotationX 85→0 baseline hinge, 0.25s back.out(1.4), back-shadow grows as it rises) | `fold` (the page closing: rotationX 88 power2.in 0.12s, shadow off, L→R wave staggered 0.045s, slot hidden at +0.13; fold at next line in / lastWord+`body.maxHold` in silence, wave clamped ≤ DUR−0.017) |
| `chalkrail` | night-lecture chalk band: a deep-green board strip (`body.strip` geometry, worn top hairline, eraser smudges, handwritten `body.tag` header in the tag font, two chalk sticks bottom-right) docked at the bottom edge hosts `body.rows` reading rows that ACCUMULATE — lines fill the rows top→bottom in phases of rows.length; when the next phase needs the rows an ERASER SWIPE clears the board (blurred light band sweeps across 0.42s at `prevEnd + body.eraseDelay` clamped clear of the next phase, the old words SMEAR out scaleX 1.3 + x+24 + blur(8) staggered 0.06s, faint ghost smudges fade in and STAY — chalk never fully erases); the FINAL transcript word lands in yellow chalk (`palette.em` sign-off); the band yields (`body.yield`) while the apex writes | fg | `scribble` (the chalkwrite verb at low amplitude: 2-frame opacity pop + scribble clip-reveal steps(4) 0.12s + y4 drop-settle + 6 seeded dust specks puffing at the word base, seeded ±2° tilt; emphasis = double underline dashes drawn beneath at st+0.22/+0.38) | eraser-swipe smear between phases (scaleX 1.3, x+24, blur 8, power2.in 0.18s); the final phase holds to clip end |
| `markerrail` | street handstyle rail: centered marker lines on a bottom rail (`body.bottomPx`), `palette.body` cream paint with a heavy drop text-shadow; emphasis words take ALTERNATING paint colors (`palette.em`/`em2`, yellow/magenta) + a spray-underline SWOOSH (dash-revealed squiggle viewBox-stretched to the word) with 8 seeded overspray dots that pop and fade; the FINAL transcript word lands BIG (1.5×) in `palette.em` with mini apex-impact physics (crush 0.09s power3.in → 2-frame contact squash → elastic settle — it rides the plate punch); the line visible at the tag's onset yields (`body.yield`, restore only with runway before its buff — else it exits dimmed) | fg | `flick` (the spray-write verb at low amplitude: opacity full in 1 frame at scale 1.18, seeded ±6° rotation settling to ±2°, 1-frame squeak skewX 7, back.out(2.2) settle 0.18s) | ROLLER BUFF (gray paint roller scaleX-sweeps the line 0.15s power2.in, words vanish under it, the wet smear lingers at 0.15 + blur(2) before fading); the last line is never buffed — the sign-off stays painted |
| `brushrail` | washi paper band: a deckle-edged band (`body.strip` geometry, two faintly rotated `paperHi`→`paperLo` under-sheets at 0.42 + a translucent `sheetHi`→`sheetLo` main sheet with warm inset shadow) swipes in L→R behind a traveling brush-head shadow (clipPath wipe 0.2s power2.out + blurred `palette.bh` gradient head — the ink gesture at low amplitude), then the under-sheet breathes faintly (0.32 Hz baked f(t)); body lines are centered replace-lines on the band (`fonts.body` serif at `body.fontPx`/`body.weight`, seeded ±1° tilt); emphasis words get a DOUBLE curved ink underline (`palette.ulInk`, fat 7/0.3 wash + tight 2.6/0.95 core, dash-drawn at st+0.12/+0.15); band + the line under the gesture yield (`body.yield`) while the apex lands; the band exits with the last line on one final R→L wipe | fg | `brush-wipe` (the gesture at low amplitude: clipPath inset reveal L→R 0.16s power2.out with a blurred per-word brush-head shadow riding the reveal edge, then vanishing) | wet-cloth WIPE (the reveal in reverse: the line clipPath wipes R→L over 0.10–0.14s power2.in with an erase brush head riding it, at min(next line − 0.136, lastWord + `body.maxHold`); the LAST line and the band wipe together at DUR−0.142) |
| `inkrail` | sumi ink on still water: up to THREE fixed reading rows at the bottom center (`body.bottomPx` = last-row center, `body.rowGap` apart; rows cycle in pairs, the closing line takes the third row — the final thought ACCUMULATES instead of replacing); `fonts.body` Mincho serif at `body.fontPx`/`body.weight`/`body.letterSpacing`, `palette.body` ink over a paper-white text-shadow halo (`palette.paper`/`paper2` glow stack — readable on bright skies) with a duplicate blurred dark halo per word; an ink-settling RULE (86%-width gradient hairline, scaleX 0→1 over 0.9s) draws beneath each line at firstWord+0.12; emphasis words shed a faint ink wisp curling off the word top (st+0.55, ±dir by line parity, skipped near clip end); the rows visible under the bloom yield (`body.yield` — dim at heroIn+`delay`, restore at heroIn+`post` only with runway before the exit) | fg | `bleed` (the apex bloom at low amplitude: opacity snaps in 2 frames; blur 12→0 + scale 1.25→1 power2.out carry the bleed — late words bleed faster so they resolve before clip end; the dark halo overshoots scale 1.5→1.04 while its opacity decays 0.5→0.25) | RE-DISSOLVE (blur 9 + scale 1.13 + fade + y−8 power1.in — ink dispersing back into the water) as the line-after-next approaches (= the next occupant of its row, outDur 0.5−0.05·i, never mid-read); the closing TWO lines persist to clip end |
| `ransomrail` | kidnapper's collage rail: every transcript word is a cutout chip in one of 4 cycled recipes (cream/`fonts.body` Anton, black/`fonts.dark` Inter-800, newsprint/`fonts.news` Special Elite, accent/`fonts.accent` Permanent Marker — emphasis words snap to the accent chip, never two identical chips in a row) PASTED along the bottom rail line (`body.bottomPx`) with seeded crookedness (rot sign alternates with line+word parity, baseline dy leans the other way), seeded ±px sizes (`body.fontPx`−2..+1, emphasis +3) and torn edges (seeded 12-vertex tear polygon, same paper stock as the apex); chips carry a hard dy5 offset drop-shadow; lines breathe (5-key scale keyframes) through their hold; the whole railwrap yields (`body.yield`, container opacity — never contests per-chip channels) while the apex letters slam | fg | `paste` (the apex slam motif at low amplitude: opacity full in 1 frame at scale 1.12 / rot×1.5 / y−8 → power3.in crush 0.08s → 2-frame contact squash 1.05/0.95 → elastic settle; emphasis = 1-frame RE-PASTE lift at `body.emphDelay` — shadow grows to a flight blur, rotation flips sign for good, then crush + squash + elastic again, rip-runway-guarded) | RIPPED off one by one (rotation ±16/17° throw + y−30 + fade, power2.in, staggered 0.045s) with a 1-frame paper-tear flash under each chip — timed so the rip tail overlaps the next line's pastes a beat (collage chaos, by design); the last line tears off clamped to finish on frame |

fg body uses the **rail.html alpha-webm channel** (true alpha — dark panels and
scrims work; never the screen-blend index_fg path, which can only add light).

## Hero setpieces (registry)

| setpiece      | what happens                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | notes                                                                                                                                                                                                                                                                                                                                                                                                                         |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `detonation`  | charge-dim → sheared stencil slices crush in hot → SNAP into register → squash → elastic settle → paint cools to bone → bars/ticks/designation-tag deploy → shear-apart exit                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | pairs with fx flash/rings/sparks + plate punch/shake                                                                                                                                                                                                                                                                                                                                                                          |
| `decode`      | slot-machine glyph reels (steps() ease, seek-safe) lock left→right with RGB jitter → lock snap → CRT power-off exit                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | pairs with `redact-until-hero`                                                                                                                                                                                                                                                                                                                                                                                                |
| `drawon`      | the word is WRITTEN stroke-by-stroke from a single-line font (Hershey) — per-stroke paths revealed sequentially at constant pen speed, nib rides `getPointAtLength`, hops at pen lifts; then hum + buzz dip                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    | any word, zero tuning: `gen-stroke-path.py` lays out glyph pen-paths at compile time                                                                                                                                                                                                                                                                                                                                          |
| `cpslam`      | acid-yellow stencil word slams in BEHIND the subject with REAL diagonal notch cuts (SVG mask = true transparency over footage), chromatic split that settles to a PERMANENT cyan/red registration error, seeded glitch ticks, katakana tag, glitch-out exit                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    | bounded hold (~2.6s default, `params.hold` / `hero.exitAt` override) — a climax is an event, not wallpaper                                                                                                                                                                                                                                                                                                                    |
| `assembly`    | (inline, poem) apex word condenses BIG while star particles fly into it                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `colorflip`   | (inline, takeover) accent-color crush card + dim kick + squash/settle + loom                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | pairs with fx flash + plate punch/shake                                                                                                                                                                                                                                                                                                                                                                                       |
| `flapboard`   | split-flap departures board takeover: floodlight dim/scrim swing on, the housing clacks down (rotationX, squash, elastic) behind the subject, one giant flap tile per char flip-cycles 3–5 seeded wrong glyphs then LOCKS left→right across `params.lockWindow` (hot flash → cools to bone) under baked clack y-jitter; lock-complete = big clack + amber glow flicker + rule wipe + steps() status-ticker; hold = loom + one tile re-flutter; exit = tiles flip to blank cascade, housing flips away                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          | pairs with `fx.crowdflash` (pops at lock start/end) and `plate.punchOffset = lockWindow` so the plate punch lands on the lock-complete clack, not the hero onset                                                                                                                                                                                                                                                              |
| `ledwipe`     | transit LED board takeover: cool scrim + dim charge, an amber dot-matrix panel EXPANDS upward (scaleY steps(6)) behind the subject, header tags + unlit LED ghost boot-flicker alive, then the word ILLUMINATES column-by-column (clipPath L→R, steps ≈ 1.4×chars over `params.wipeDur`) hot → cools to amber; contact = cell squash + elastic settle + PA "ding" double brightness pulse; an 8-phase chasing LED-marquee border runs to the exit; hold = glow breathe + seeded-look LED dropouts; exit = word pages up out of the window, panel collapses back down                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           | pairs with `fx.paflash` (amber PA flash at wipe-complete contact). Panel geometry derives from fontPx (`panelH = 44 + 1.42×fontPx + 18`); `params.panelW` fixed, width-fit shrinks the type only. Plate: grain-only (no punch) is the demo register                                                                                                                                                                           |
| `vhsosd`      | REC FREEZE camcorder slam: permanent lens vignette (`params.vignette`) + backlight-comp scrim (peak = `plate.charge`, cools to 0.69×) kill the bright sky; the word crushes in as a huge OSD readout — 3 horizontal clip-path tracking bands (`params.bandClips`) sheared (`params.bandShear`) + blurred (power4.in over `params.crush`) SNAP into register on the contact frame, squash → elastic settle; red/cyan misregistration copies cool from ±`misregIn` to ±`misreg` px and keep BREATHING through the hold under tape-weave y jitter (`params.weave`) + slow loom; auto-iris pump (white wash 0.20, dark dip = `plate.dim`); exit at `I + params.hold` = REWIND streak left + flat-white echo copy                                                                                                                                                                                                                                                                                                                                                                   | pairs with the `vhsrail` paradigm, which emits the fg REC-FREEZE artifacts (tear band, head-switching bar, apex scrub lines, ● REC) at the same I/X times. Plate: grain-only (no punch) is the demo register; hold-life keyframes auto-clamp inside [contact, exit]                                                                                                                                                           |
| `bossintro`   | arcade BOSS INTRO: the room dims (gradient `plate.dim`) behind a CRT edge glow (cyan base wash that pulses on the last emphasis word before the boss and spills magenta on the strobe + the final word); `params.tag` (WARNING) blinks 2× above the word rect; ~COLS×ROWS (`params.rows`, cells 0.7×/0.5× fontPx) seeded pixel blocks fall steps(3) from above into a tight matrix grid covering the word rect, each landing with a 1-frame white tick; at `I + params.flashDelay` the word flashes ON (Press Start 2P, instant at opacity 1, scale 1.15 → steps(3) crush → 2-frame squash → elastic settle) with ±4px chromatic ghost layers + a 3-frame palette strobe (white→cyan→magenta→white); hold = quantized 1.6% breathe + CRT glow cycle + baked block shimmer blinks; exit at `I + params.hold` = per-letter checkerboard dissolve, blocks drop away steps(3)                                                                                                                                                                                                      | pairs with the `hudrail` paradigm; emits fg furniture via setpiece fg parts: letterbox bars (`params.letterbox` px) slam in steps(3) with a 1-frame overshoot and retract after the exit, HI-SCORE odometer (accent label, yellow digits) rolls every 0.066s through the hold, 1-frame white tick out. `fx.coinflash` adds the 1-frame radial impact flash at the strike; plate punch anchors at `punchOffset` = `flashDelay` |
| `rubberstamp` | the footage becomes an archive page (vignette charge = `plate.charge`, manila sepia wash `params.sepia`, dim kick = `plate.dim` that cools to 0.385×) and the verdict slams in BEHIND the subject as a bordered rubber stamp at `params.rotation`: a manila paper wash lights an instant before contact, the stamp crushes 2.2→1 power4.in over 0.115s, 2-frame contact squash, double-strike ghost print (`opacity 0.35`, offset +5/−4 rot 1.3), two thin-ink patches (`params.patches` clip-path circles in paper color), wet-ink saturate/brightness settle, paper indent shadow bloom; stamps don't move — the hold is DEAD STILL, sold by the breathing indent + a slow paper loom; exit at `C + params.hold` = mechanical 3-frame yank-down cut (the page is filed away), the archive plate releases 0.25s before                                                                                                                                                                                                                                                        | contact `C = heroIn + params.crush`; pairs with `fx.inkflecks` (seeded red spatter at C, ballistic with gravity) and `plate.punchOffset = params.crush` so the plate punch lands on the stamp contact; pairs with the `carbonstrip` paradigm (strip yield + shade breathe anchor at the same C); face chrome (border, radius, padding, ghost offset) scales with fontPx from the 120px demo cut                               |
| `lasercage`   | concert-blackout laser show: the plate dims to `plate.dim` at t=0 and deepens to `plate.charge` before the drop while haze cones breathe in the top corners; a 6-beam array (alternating accent/magenta, emitters along the top edge) snaps on at I−0.26 and SWEEPS to converge on the word center (staggered 0.022s), a hot core pops at convergence; the word IGNITES behind the subject as an additive glow stack (wide accent blur 0.22×fontPx + magenta fringe offset + tight soft blur + near-white core): scale 1.16 crush power4.in → 2-frame contact squash → elastic settle + 2 controlled strobe pops (brightness 2.05, 1s apart, runway-guarded); a criss-cross beam CAGE (6 lines derived from the word rect) draws itself dash-by-dash around the word and flickers; through the hold the array FANS OUT slowly to floor targets (the iconic concert fan) while the word looms; exit at `heroOut − 0.38` = cage un-draws, beams sweep away L→R (+0.74W) carrying the letters (per-letter streak + blur, stagger auto-clamped ≤ DUR), the room lifts back to 0.12 | word geometry is metric-exact via `assets/fonts/char-widths.json` (cage/spill/flash all derive from `halfW` + `fontPx`); pairs with `fx.laserflash` (accent contact flash at I+0.03, sized 2·halfW+80 × 4.8·fontPx) and the `laserrail` paradigm (same converge-ignite motif at low amplitude); plate punch anchors at `punchOffset` ≈ 0.07 (the ignite contact)                                                              |

| `boltstrike` | THE STRIKE: a slate storm mood (`params.mood`) sits on the room from t=0 and the sky KISSES (1-frame 6% gradient brighten) on every body word — double-kiss on emphasis words, re-flashes at I+0.84/I+1.44 during the hold; the room charge-dims to `plate.charge` under a radial word scrim from I−0.39; a hand-authored branched bolt (5-seg zigzag + 2 branches, haze/glow/core SVG stack in `palette.accent`/`glow`/`core`) flashes 2 frames BEFORE the word — leader 1 → 0.35 → return stroke 1 at contact — with a sky flash; the word slams in (Anton scale 3.1 crush power4.in 0.105s, blur 14→0, brightness 3.2→2.5) → 2-frame contact squash → elastic settle, lit HARD for 2 frames then cools to 1; SIGNATURE: the word's text-shadow flips toward the bolt side for the flash frames; ozone under-glow (accent, blur 26×fontPx/145) blooms at contact and breathes (6-key opacity cycle) through the hold + slow loom + 2-frame lit blips on the sheet re-flashes; the dim releases at the flash, settles to a `plate.dim` storm hold and lifts before the exit; exit at `min(heroOut−0.2, I+params.hold)` = washes downward with the rain (y+38, blur 6) | bolt geometry is baked at compile time as offsets from the word center (halfW/360 × fontPx/145 units, `params.side` flips it); word rect is metric-exact via `assets/fonts/char-widths.json`; word scrim = 2·halfW+380 × 2.28·fontPx; pairs with `fx.strikeflash` (full-frame fg flash with the same leader/return flicker grammar) and the `stormrail` paradigm (rain doubles + shadow flip at the same I); plate punch anchors at `punchOffset` ≈ 0.02 (the return stroke) |
| `holoboot` | full volumetric boot behind the subject: a desk emitter dot pops (back.out scale), the projection cone SNAPS wide (scaleX 0.07→1 expo, 0.11s, then dips 0.55→0.95), a seed beam fires up from the emitter (scaleY power3.in, fades after contact), and the word boots with a RISING clip sweep (opacity instant at I−0.055, the reveal is the wipe) into a 7-layer projection stack (dark backing shadow / wide `palette.glow` blur / magenta + cyan chromatic fringes / `palette.core` word / scanline mask / climbing interference bands, band pitch 0.39×fontPx); contact at I+0.07 = brightness 2.3 flash + scaleX 1.07 / scaleY 0.91 squash held 2 frames + elastic settle + 1-frame x interference jumps; fringes blow ±8px then settle to the permanent ±2px registration offset; hold = bands climb −6 pitches, rotationY oscillates [0,6,−7,5,0], glow/cone breathe, emitter dot pulses, slow 1.028 loom; at `I + params.passAt` (≥ I+1.25, runway-guarded) a hand "passes through" the projection (skew 6/−4/0 + blur + fringe blowout + scaleY ripple — pure projection logic); exit at `min(heroOut−0.22, DUR−0.3)` = projector cut: brightness 2.4 → collapse to a horizontal line (scaleY 0.018) → to the base dot (scaleX 0.015), cone fades, dot pops 2.2× and dies. A second smaller emitter cone (`params.coneW`) rises from the desk under the holorail plate ALL video with baked flicker cycles + dot pulses, and gets its own projector cut at DUR−0.2 | word rect is metric-exact via `assets/fonts/char-widths.json` (cone width 2·halfW+80, emitter at cy+2.4·fontPx, all rig geometry derives from `halfW` + `fontPx`; scene scrim is a radial centered on the hero's frame fractions); pairs with `fx.holoflash` (cyan fg lock flash at I+0.075, radial at the same hero fractions) and the `holorail` paradigm (same sweep-boot + interference-glitch motif at low amplitude; the body cone is aimed at ITS plate); plate: grain-only (no punch) is the demo register, scene reaction = `plate.scrim`/`charge`/`dim` |
| `biobloom` | bioluminescent BLOOM behind the subject: the abyss grade (`params.mood`) + a breathing vignette (6-key opacity cycle sized to the clip) sit on the room from t=0 — the world is underwater before anything speaks; a creature pre-glow (1.083×/0.717× fontPx ellipse) gathers with a double-beat anticipation at I−0.31 and crushes to a point at I−0.115; a dark pocket scrim (`plate.scrim`, 2·halfW+280 × 2.5·fontPx) opens behind the word zone at I−0.03; the word CONDENSES in (scale 2.35 crush power4.in over `params.crush`, blur 15→2, brightness 2.6→1.7) → 2-frame contact squash (1.09/0.93) → elastic settle while cyan light SPILLS onto the scene (radial at the hero's frame fractions: snap 0.55 at contact → 0.2 → shimmer keyframes); 6 luminous tendrils (paired blurred SVG strokes — glow w9/blur5 + core w3/blur1.1, accent/violet/ice alternating) draw outward from the word rect staggered 0.05s (stagger auto-clamps so the last filament completes before the sink); hold = glow breathes 8–14% (4-key scale/opacity), glow-layer flicker, two counter-drifting caustic gradients shimmer over the text zone, the bloom drifts slowly with the current (`params.drift` on the x channel); exit at `min(heroOut−0.2, DUR−0.3)` = the light SINKS — tendrils (via their HTML wrapper, never gsap-y on inner SVG), caustics, pocket, spill and the word all drift down (+14/+26) while dimming, word blurs out | tendril geometry is hand-authored from the demo and baked at compile time as offsets from the word center (x scaled by halfW/demoHalf, y by fontPx/120, endpoints frame-clamped); word rect is metric-exact via `assets/fonts/char-widths.json` (0.01em tracking); pairs with `fx.sporeburst` (seeded spore puffs burst PAST the bloom at contact in the fg layer, ballistic with slight sink) and the `planktonrail` paradigm (same glow-on motif at low amplitude + mote attraction anchored at the same I); plate: pushIn+grain (no punch) is the demo register — the demo's postfx profile was `push` |
| `silkribbon` | aurora silk WRITTEN into the night sky behind the subject: the hero word is a sequential Hershey-stroke reveal (one `<path>` per pen stroke at constant pen speed, `gen-stroke-path.py` baked at compile time — any word, zero tuning) in a blurred halo (`params.haloWidth`, feGaussianBlur 7) + tight core (`params.coreWidth`, white drop-shadow), BOTH stroked by ONE userSpaceOnUse rose→teal→violet linearGradient with reflect spread; SIGNATURE: the gradient's x1/x2 translate f(t) from I−0.07 to the clip end (shift 1.5× the gradient span) so the aurora colors FLOW THROUGH the drawn ribbon during the hold; a soft light mote (blurred halo + core dot) rides the pen, hops at lifts, and blooms out 2.4× at the final stroke; scene reaction = two huge aurora blobs (teal top-left / rose top-right, frame-fraction geometry) drift the sky f(t) from t=0 at `params.wash` and DEEPEN to `params.deepen` as the ribbon writes, a top scrim (`plate.scrim`, 0.653H) charges in at I−0.34 (sky washout guard), a light spill (`params.spill`) tints the sky at I+0.1 once the ribbon completes and flickers gently, and the halo breathes (5-key opacity cycle) through the hold; gentle — no shake, no contact squash: the apex verb is the writing itself; writing window = `params.window` clamped so the ribbon finishes + holds before ENDT = DUR−0.04 | canvas/gradient/spill all derive from `params.targetWidth` (demo: 880 ink on a 1010×280 canvas, baseline 200; svgW = tw+130, spill = (tw+60)×0.51, gradient span = 0.6364·tw), centered at the scene-aware HG (pref top 18, the sky band — same as drawon); pairs with `fx.silkmotes` (seeded aurora motes rise near the ribbon during the hold, fg layer, demo spread fractions keep the face gap) and the `sheenrail` paradigm (same flow motif at low amplitude); plate: pushIn+grain (no punch) is the demo register — the demo's postfx profile was `push` |
| `scopetrace` | the scope's own trace LEAPS off the band and WRITES the hero word behind the subject: a surge column (3px, bottom-anchored scaleY pop, landing at the pen-start point parsed from the baked path) jumps from the scope band at I−0.12; the word is a sequential Hershey-stroke reveal (one `<path>` per pen lift at constant pen speed, `gen-stroke-path.py` baked at compile time — any word, zero tuning; punctuation stripped for the pen) drawn CORE-ONLY in a thin phosphor stroke (`params.coreWidth`, bright `palette.bright` + accent drop-shadows — it is a trace, not a tube), nib rides `getPointAtLength` and pops 2.4× at DRAWN; the phosphor FILL (blurred halo strokes, `params.haloWidth`, blur 7) flicker-arrives AFTER the write at FLICK = DRAWN+`params.flickGap` (pre-arm 2 frames early, 5-step 1/0.25/1/0.55/1 blink, core dips 0.55 for a frame); hold = glow hum (7 Hz + 11.3 Hz sine keyframes, f(t)) + 1.6% breathe on an HTML wrapper with transformOrigin at the hero's frame fractions, ending exactly at the collapse; exit = the word COLLAPSES to a flatline (wrapper scaleY 0.02, 0.14s power3.in, same origin) + a horizontal flash bar (2·halfW wide at the word center) that shrinks out — scheduled at nextSpokenWord − `params.clearGap` (clamped ≥ FLICK+0.45, ≤ heroOut) so the trace clears BEFORE the next line speaks; scene reaction = phosphor radial scrim (`plate.scrim` at the hero fractions, I−0.18 → EXIT+0.05) + green spill sized from targetWidth (`params.spill`); second beat: a 1-frame phosphor pulse answers the FINAL transcript word — the same death beat the scoperail finale owns | trace geometry derives from `params.targetWidth` (svgW = tw+140, local baseline 215, ink center ≈ baseline−28 — the demo registration; surge x parsed from the first `M` of the path); centered at the scene-aware HG (pref top 25, ink height 115); pairs with the `scoperail` paradigm (tracewipe = the same pen-write at low amplitude; the paradigm's hero waveform kicks fire at the same word onsets) and needs no front fx; plate: grain-only (no punch) is the demo register |
| `papermat` | STOP-MOTION PAPERCUT behind the subject: a torn-edge kraft mat (two seeded torn polygon layers) DROPS from above with steps(2) gravity and slaps down (squash → rest, soft blob shadow); then one torn cream chip per char (`palette.paper`/`paper2`, hero-font ink `palette.ink`) drops with steps(3) gravity + 1-frame overshoot at `I + i·params.step`, each announced by an anticipation shadow 3 frames early, landing crooked (seeded ±3° ±4px), settling through three 12fps poses, then holding an 8fps wobble (baked set chain — the stop-motion heartbeat) while the whole tableau breathes 1.6% (camera-push loom); scene reaction = a warm vignette (`params.vig`) charging with the landing; on the FINAL word the table BUMPS (y4, 1 frame) and the chips SCATTER off down-right steps(4) staggered 0.045s, the mat swept off last (disperse-on-last-word, `hero.exitAt` overrides) | chip geometry derives from `hero.fontPx` (demo ratios: tile 0.91×/1.31× fontPx, pitch tile+14, mat N·pitch+80 × 1.846× fontPx), width-fit shrinks type and chips together; pairs with `fx.paperscraps` (paper scraps puff at each chip contact, fg) and the `paperrail` paradigm (same place verb at low amplitude); plate: grain only is the demo register |
| `centerfold` | POP-UP BOOK CENTERFOLD behind the subject: blue + cream 12-point paper starbursts (deterministic starPts polygon, `palette.accent`/`cream` fills with kraft-edge strokes) unfold on a baseline hinge (scaleY 0.03→1 back.out(1.6), cream staggered +0.06) starting I−0.272 while a blob shadow grows beneath; the hero word pops up at I in `params.segs` hinged clip-path segments (rotationX 85→0 back.out(1.4) staggered `params.segStagger` — the body's popup verb at full amplitude); two kraft struts lean in at I+0.33; backdrop + word wobble-settle TOGETHER (one `#cfWob` wrapper owns rotationX, 6-key) then breathe through the hold (word 5-key rotationX + shadow scaleX, both auto-squeezed into the window); scene reaction = a dusk radial scrim at the hero's frame fractions (`params.scrim`, bright-sky insurance) charging with the unfold; exit = the whole spread folds FLAT (segments rotationX 88 staggered, struts/bursts scaleY collapse cream-then-blue, scrim out) at `max(I+segStagger·(segs−1)+0.45, min(heroOut−0.33, DUR−0.39))` — the book closes before the next sentence | burst geometry derives from the word rect (metric-exact via char-widths: burstR = clamp(1.83·fontPx, 0.59·wordW, 0.42·H), box 2R+180 × 2R+fontPx, demo props scale at burstR/220), width-fit shrinks the type first; pairs with `fx.confetti` (seeded ballistic paper confetti launched from the hinge line at I+0.128, fg) and the `popuprail` paradigm (same baseline-hinge verb at low amplitude); plate: grain only is the demo register |
| `chalkwrite` | NIGHT LECTURE behind the subject: the lower chalk band grows into a FULL deep-green board (scaleY 0→1 power3.out from the bottom edge at I−0.3, the room dims `params.dim` + vignette `params.vig` with it); the hero word is HAND-WRITTEN in chalk — sequential Hershey stroke reveal (drawon pattern, `gen-stroke-path.py` baked at compile time), dry/rough via seeded ±1px jitter on every pen point (`params.jitterSeed`), a blurred halo (`params.haloWidth`, feGaussianBlur 3.2, group opacity `params.halo`) under a tight core (`params.coreWidth`), a chalk-stick nib (rotated rect) riding the pen — while `params.dustN` dust specks fall from the nib along the path; at write-complete a hand-slap CLAP shakes the board 1 frame (set-chain y 3/−2/1/0; the dust burst lives in the rail, in FRONT: `fx.chalkclap`), then TWO violent accelerating underlines slash beneath (seeded waviness, power3.in, the nib rides them, dust kicks at the ends), the chalk flicks away, and the halo breathes (baked 1.6/3.7 Hz f(t)) through the hold with two slow motes drifting off the letters | geometry registers on the MEASURED ink box (the stroke path is parsed at compile time; ink CENTER lands on scene-aware HG.y, pref top 44): the board rect (`params.boardGap`×inkH above the ink, bottom flush with the frame, width = the body strip), the underlines (inkL+14→inkR−4 at inkBot+30/+50) and the fg clap point (the pen's LAST contact point → HG.clapX/Y/T) all derive from it; x defaults to the board's LEFT margin + tw/2 — a hand writes from the left, never centered on the subject (`theme.hero.x` overrides); pairs with `fx.chalkclap` (12 seeded radial dust specks + a blurred puff at DRAWN, fg) and the `chalkrail` paradigm (same scribble verb at low amplitude); plate: grain only is the demo register; needs `python3` |
| `spraytag` | STREET TAG behind the subject: the dusk wall dims (`params.dim`) + a radial scrim charges (`params.scrim`) at I−0.42 as the writer steps up; the can RATTLES at the pen-start point (6 seeded set-chain nib jitters, I−0.30→I−0.02), then the hero word is spray-written stroke by stroke — sequential Hershey dash reveal at constant pen speed (`gen-stroke-path.py` baked at compile time) — as a TRIPLE stack per stroke: fat dark under-stroke `palette.under` (`params.underWidth`, sky-contrast armor) + blurred overspray halo (`params.haloWidth`, feGaussianBlur 7, group opacity `params.haloOpacity`) + wet paint core (`params.coreWidth`, drop-shadow), the glowing nib (r9, `palette.nib`) riding each stroke; `params.osprayN` seeded overspray dots pop near the nib as it passes; nib throws off 2.6× at DRAWN; through the hold `params.drips` seeded DRIPS (paint→`dripMid`→`dripLo` gradient bars) grow from painted low points (x-separated >110px) and KEEP growing, the halo hums (7/11 Hz baked f(t)) and the whole tag looms 1.014; writing window = `params.window` (demo 0.58s) clamped so write + splat + drip fit before ENDT = DUR−0.04 | geometry registers on the MEASURED ink box (ink CENTER lands on scene-aware HG.y, pref top 26 — the sky band, crossing the subject silhouette is the point; tag centers on the hero anchor, `targetWidth` capped at W−140); the pen-finish point is baked onto HG.splatX/Y/T for the fg splat; pairs with `fx.paintsplat` (hot `em2` ring + paint ring + seeded ballistic droplets at DRAWN, fg, in FRONT of the subject) and the `markerrail` paradigm (same flick verb at low amplitude); plate: punch with `punchOffset` ≈ window+0.05 so the punch lands on the splat, not the hero onset; needs `python3` |
| `brushwrite` | SUMI-E GESTURE behind the subject: a warm paper wash (`palette.wash`/`wash2` radial, `targetWidth`+380 × `params.washH`) charges at I−0.18 to `params.wash`, settling to `params.washSettle` through the hold; the brush SLAPS down at the first pen point — a start-SPLAT (irregular ink ellipse rotated −24° + 5 seeded satellite dots, scale 0.35→1 power3.out 0.12s; the fg fleck pass rides `fx.brushflecks`) — then the hero word is written in ONE sequential gesture (Hershey dash reveal at constant pen speed, `gen-stroke-path.py` baked at compile time) as a FOUR-layer stack per stroke: blurred bleed (`params.bleedWidth`, feGaussianBlur 6, group hidden until the hold) under fat/mid/core widths (`fatWidth` 0.25 / `midWidth` 0.6 / `coreWidth` 1.0 stroke-opacity = pressure-tapered wet brush), the round ink nib (r7.5, `palette.nib`) riding the pen and flicking off (+42,−30, scale 1.9) at DRAWN; ≤`params.spatterN` spatter droplets eject PERPENDICULAR at the sharpest-curvature points of the gesture (angle-delta > 0.55 rad, te-separated > 0.045s); through the hold the ink BLEEDS into the paper (bleed group fades to `params.bleed` over ≤1.7s) and the whole work breathes 0.6% (`params.breatheHz` baked f(t), svgOrigin = ink center); then the vermilion SEAL chop (`params.sealGlyph`, `palette.seal`/`sealMid`/`sealLo` radial face, `fonts.seal` glyph) stamps at DRAWN+`params.sealDelay` — power4.in crush 0.09s → 2-frame squash → elastic settle + 3 seeded red flecks — skipped entirely if the clip ends too soon; writing window = `params.window` (demo 0.45s) clamped so write + seal + bleed fit before ENDT = DUR−0.04 | geometry registers on the MEASURED ink box (ink CENTER lands on scene-aware HG.y, pref top 19 — the sky band, crossing the subject silhouette is the point; gesture centers on the hero anchor, `targetWidth` capped at W−240 — on scenes with a dark object near the pen start, set `theme.hero.x` so the slap-down lands clear, like the demo's 864); the pen-START point is baked onto HG.fleckX/Y/T for the fg flecks and the seal sits at (inkR−103, inkBot+72) clamped on frame (`params.sealDx/sealDy` override); pairs with `fx.brushflecks` (seeded upward ballistic ink flecks at the slap-down, fg, in FRONT of the subject) and the `brushrail` paradigm (same brush-wipe verb at low amplitude); plate: punch with `punchOffset` ≈ window+`sealDelay` so the punch lands on the seal CONTACT, not the hero onset; needs `python3` |
| `ransomnote` | RANSOM NOTE slam behind the subject: one cutout chip per letter (4 recipes cycled, seeded ±13% sizes around `hero.fontPx`, alternating-sign rotations ±2–5°, misaligned baselines ±2–6px, seeded tear-polygon edges) slams in oversized (scale 1.5, rot×1.8, flight drop-shadow) at S_k = I + 0.01 + `params.step`·k, crushes power3.in over 0.085s, and on contact the shadow POPS tight (1-frame filter swap) → 2-frame squash 1.07/0.93 → elastic settle; when the LAST letter lands the whole word takes a collective 1-frame JOLT (y6 → power3.out) + a scene pulse (`params.pulse` full-frame dark blink); hold = slow 5-key loom (the crooked grid never straightens); exit = letters RIPPED off one by one (rot+±15–22° throw, y−34, fade, 1-frame tear flash under each) at ripT = min(lastWord − `params.ripLead`, DUR-clamped), ≥ jolt+0.45; scene reaction = collage scrim (`params.scrim` top-dark gradient) charges at I−0.07, out with the rip | width-fit measures each glyph IN ITS OWN recipe font (`assets/fonts/char-widths.json`) + 0.20em chip padding + 6px margins so the collage never overflows; chip chrome (shadows, space width) scales with fontPx from the 130px demo cut; the setpiece RECOMPUTES `plate.punchOffset` = 0.01 + step·(N−1) + crush from the real letter count so the plate punch always lands on the collective jolt, whatever the hero word length; pairs with the `ransomrail` paradigm (same paste verb + tear flashes at low amplitude, same recipe classes/seeded tear polygon emitted into both layers); no front fx — the flashes live inside the chips |
| `inkbloom` | INK DROP IN STILL WATER behind the subject: paper-white radial washes charge the two text zones (`palette.wash` rail wash from t≈0.15, PERSISTS to clip end so the closing body lines keep their ground — it registers on the body row geometry `body.bottomPx`/`rowGap`; `palette.washApex` apex wash at I−0.077, out with the bloom) and a gentle vignette (`params.vig`) STILLS the world while the ink blooms; the drop (ink teardrop, `params.fall` 0.15s) appears above the word point at I−0.08, swells, falls power2.in with a scaleY stretch, squashes 1.7/0.35 on contact (2 frames) and merges into the BLOOM — 3 stacked blurred irregular ink ellipses (`palette.ink`, opacity snaps 0.82/0.62/0.5 then decays) scaling at different rates with counter-rotations (expo.out 1.15/1.35/1.5s) + 2 thin concentric ripple rings expanding 7.2×/9.6× on the water surface; the hero words RESOLVE out of the blob (opacity snaps, blur 16→0 + scale 1.12→1 power2.out) — the FIRST at contact+0.02, the following at their own spoken times (multi-word hero, per-word hand-off); through the hold the glyph edges BREATHE (baked 5-key blur keyframes per word, alternating 0.66/0.5s), the group looms 1.018 and two faint ink wisps curl off the word top (C+0.573/C+0.753, fit-guarded); exit: RE-DISSOLVE (blur 11 + scale 1.15 + fade, 0.52s power1.in) at XOUT = min(I+`params.maxHold`, next body line − `params.clearGap`, DUR−0.6) — the ink clears before the next thought needs the reader | geometry derives from the measured word rect (`assets/fonts/char-widths.json`; kx = halfW/demoHalf, ky = fontPx/132 scale the demo's blob/ring/wisp anchors; pref top 25 — the sky band, crossing the subject silhouette is the point); dark `palette.ink` + its paper washes carry the contrast on bright skies; pairs with the `inkrail` paradigm (same bleed verb at low amplitude); plate: quiet `pushIn`, no punch — the bloom is liquid, not percussive |

Inline setpieces (`hero.inline: true`) live inside the body paradigm — the hero
word stays in `lines`. Embed setpieces own the word: leave it OUT of `lines`
(rail↔climax hand-off), except `panel` + `redact-until-hero`, where the words
appear in the log as `█████` until the decode locks.

A setpiece may additionally return `fgCss/fgHtml/fgJs` — those parts are merged
into the fg alpha page (rail.html), for furniture that must ride ABOVE the matte
(e.g. a verbatim chip that may never be occluded) while the setpiece scenery
stays embedded behind the subject.

## Linkages

- `redact-until-hero` — panel log shows hero words as blocks; un-redacts at lock
- `corrupt-on-last-word` — panel jitters + hue-shifts on the final word
- `disperse-on-last-word` — every visible poem letter scatters back to dust
- `field-is-the-apex` — (lastpage) the blurred future-field rack-focuses at the
  apex: every faint line was the apex word all along; the main instance holds,
  the rest re-blur and die

## Hard rules inherited (do not relax)

- determinism: paused GSAP on `window.__timelines["main"]`, seeded PRNG only,
  set-chains / keyframes, no Math.random/Date.now/CSS-keyframes
- 80 ms word sync (timings come from transcript.json via the strict matcher;
  any uncovered transcript word is a compile ERROR — verbatim completeness)
- apex owns the frame: fg furniture must avoid the setpiece rect or yield
  (`body.yield`) during the hero window
- plate punch/shake ALWAYS post-composite (`_postfx.sh`) so subject + text +
  plate move as one photographed frame (matte never desyncs)

## SVG gotchas (paid for, do not rediscover)

- dash patterns restart on EVERY subpath → sequential per-stroke paths, never
  one dashoffset across a multi-stroke word
- round linecap renders a DOT at dashoffset=len → hide each stroke until its turn
- inline-block trailing spaces collapse → word gaps via margin, not text spaces
- SVG-font glyph y is UP → flip when converting (gen-stroke-path.py does)
- NEVER gsap x/y/scale on raw SVG children: it runs `_parseTransform`/getBBox, and
  zero-area shapes (fan-kit slivers) trip GSAP's bbox hack → `appendChild` on a
  null docElement in injected-gsap contexts (preview-frames, renderer). Jitter
  SVG pieces via `attr:{transform:"translate(...)"}` only; scale/loom belongs on
  an HTML wrapper div. Nuance: a ROOT `<svg>` inside a div has null
  ownerSVGElement → GSAP classifies it as HTML (CSS transforms, no getBBox), so
  scale/skew on the svg root is safe — but copying that onto an inner `<g>`
  becomes the fatal class. attr transforms can't TWEEN — use frame-quantized
  step chains (identical on screen at 24fps)

## Timeline ownership rules (audit-derived, do not relax)

- ONE owner per (target, property) at any instant. A duration tween re-renders
  every tick until it ends — any set/tween on the same property inside that
  window gets visually overwritten a frame later (1-frame ghosts, flash-backs).
  Known shapes: yield dim vs line exit; yield restore vs powercut set-chain;
  per-word line-y bounces with word gaps < 0.22s. The rail emitter guards these
  (bounce ownership tracker, dim clamped ≥ line-in, restore only with ≥0.3s
  runway before exit — else the line exits dimmed, by design).
- Inverted clamps: `Math.max(lo, Math.min(hi, x))` with lo > hi silently pins to
  lo. When a lockup is wider than the frame, CENTER it (x = W/2) instead of
  clamping — decorative bleed must be symmetric.
- The overflow gate only measures elements with direct text nodes — SVG-painted
  setpieces (mask text + rects) are invisible to it. Do not read its "ok" as
  proof for svg lockups; judge those on the preview frames.

## theme.json — full schema

```json
{ "dna": "<themes/*.json name>",            // required
  "lines": [["word","word",...], ...],       // required — verbatim transcript tokens, in order,
                                             //   INCLUDING punctuation ("size.", "noise.")
  "minors": ["word", ...],                   // optional — theme's inline emphasis treatment
  "hero": { "match": "word or phrase",       // required — exact transcript tokens (case-insensitive,
                                             //   punctuation-stripped match); first occurrence wins
            "text": "DISPLAY FORM",          // optional display override
            "out": 5.9, "exitAt": 3.3 },     // optional window overrides (seconds)
  "width": 1280, "height": 720,              // optional, default 1280x720 (theme DNAs are tuned
                                             //   for 16:9; vertical needs a DNA variant)
  "fps": 24,                                 // optional — matte.fps is AUTHORITATIVE when present
  "panelHeader": "..." }                     // optional (panel paradigm)
```

Scene awareness (inherited from the existing typography system): when
`safe-zones.json` is present the compiler re-anchors the hero setpiece to the
measured occlusion bands (`heroBands.profile`, 18–55% occlusion, closest to the
setpiece's preferred height) and `heroAnchor.centerXPct`, width-fits the hero
font to the actual word, and docks panel/poem furniture on
`subject.clearerSide`. Explicit `theme.json` values > scene auto > DNA fallback.

Expected (benign) log noise: `[Browser:REQUESTFAILED] GET source.mp4
net::ERR_ABORTED` during the fg-layer page load, reel-glyph overflow lint
lines, and `media_missing_data_start` — all cosmetic; the render is good if
render-theme.sh exits 0 and prints the gates summary.

## Authoring a project (this is ALL a user writes)

`<project>/theme.json`:

```json
{
  "dna": "ordnance",
  "lines": [
    ["The", "stars", "were", "only", "limited"],
    ["by", "the", "pixel", "size."],
    ["It's"],
    ["what", "you", "can", "actually"],
    ["recover", "from", "the", "noise."]
  ],
  "minors": ["stars"],
  "hero": { "match": "remarkable" }
}
```

- `lines` follow the transcript verbatim, in order (compile error otherwise);
  every transcript word must be covered by `lines` ∪ `hero.match` — the example
  above omits "remarkable" from `lines` precisely because `hero.match` covers it
  (it slots between `["It's"]` and `["what",...]`)
- leave the hero word(s) out of `lines` for embed setpieces; keep them for
  inline setpieces and for panel+redact
- `takeover`: each line is one CARD — group by beat (1–2 words), not subtitle
  length; `panel` scrolls automatically past ~5 lines (terminal scroll)
- the ordnance "charge" is an edge vignette + rail yield (the center stays
  clean by design) — judge it at the frame corners, not the center
- `minors`: words that get the theme's inline emphasis treatment
- preview first: `node scripts/make-theme.cjs <project>` then
  `node scripts/preview-frames.cjs <project> <t1> <t2> ...` (Visual QA)
- then: `bash scripts/render-theme.sh <project>` → **`final_fx.mp4`** (the
  deliverable; `final.mp4` is pre-plate-reaction)

Prereqs identical to other modes — `prepare.sh` outputs: transcript.json,
frames_fg/, matte.fps, safe-zones.json (scene awareness), source.mp4.
frames_bg/ is only needed by preview-frames.cjs, not by the render.

## Adding a theme DNA

Copy a `themes/*.json`, change identity fields. You may ONLY combine existing
paradigm/setpiece/linkage names — if the look you want needs a new mechanism,
that is a registry contribution (this file + make-theme.cjs), reviewed like an
engine change. A DNA that needs per-word visual tuning to ship is rejected:
**any input, zero hand-fixing** is the acceptance bar (the draw-on lesson).
themes/scoreboard.json
{
  "name": "scoreboard",
  "voice": "stadium split-flap departures board — every body word is a mechanical flap tile on a chyron bar that flip-cycles two seeded wrong words before clacking onto the real one (hot amber flash, cools to bone); a ROW counter flips at each line change and an odometer chip rolls beside the first emphasis line. The apex is a board takeover behind the subject: floodlights swing on, a giant departures housing clacks down, one huge flap tile per letter flip-cycles wrong glyphs then locks the word left→right under a crowd-flash, the status ticker types on, one tile re-flutters during the hold.",
  "when": "sports, transit/travel, scores, countdowns, announcements and reveals — anything that should read as DEPARTURES-board mechanical. Loud register; needs a lower third that tolerates a dark chyron bar.",
  "register": "loud",
  "fonts": { "body": "Teko", "hero": "Anton", "tag": "Teko" },
  "palette": {
    "body": "#F5EEDD",
    "hot": "#FFB347",
    "accent": "#FFB347",
    "grey": "#B9BEC7",
    "boardBone": "#F6EFDF"
  },
  "body": {
    "paradigm": "flaprail",
    "layer": "fg",
    "fontPx": 48,
    "barH": 96,
    "letterSpacing": "1px",
    "seed": 808,
    "chyron": { "tag": "DEPARTURES", "counter": "ROW" },
    "chip": { "label": "LIMIT", "value": "1.2", "unit": "PX", "x": 884, "y": 563 },
    "yield": { "dim": 0.55, "pre": 0.07, "post": 0.47 }
  },
  "hero": {
    "setpiece": "flapboard",
    "layer": "bg",
    "fontPx": 138,
    "params": {
      "seed": 8088,
      "glyphs": "ABCDEFGHIKLMNOPRSTUVXYZ0123456789",
      "lockWindow": 0.34,
      "gap": 7,
      "headerLeft": "★ DEPARTURES",
      "headerRight": "GATE 06  22:40",
      "tag": "STATUS // NOW BOARDING"
    }
  },
  "fx": { "crowdflash": [0.5, 0.45] },
  "plate": {
    "charge": 0.62,
    "dim": 0.34,
    "punch": 0.045,
    "punchDecay": 9,
    "punchOffset": 0.34,
    "shakeAmpX": 7,
    "shakeAmpY": 5,
    "shakeHz": 12,
    "shakeDecay": 7,
    "shakeWindow": 0.6,
    "grain": 6
  },
  "linkages": []
}
themes/spectrum.json
{
  "name": "spectrum",
  "voice": "an oscilloscope is listening — a dark scope band hosts an always-alive waveform (speech grows it, silence flattens it, every word kicks a local burst) and body words are traced on in phosphor mono with a just-traced flash; the apex is the trace itself LEAPING off the scope to hand-write the hero word in a thin phosphor stroke, fill flicker, hum, then collapse to a flatline — and on the final word the whole trace explodes, flatlines to a single dot and stamps SIGNAL LOST.",
  "when": "science / signal / audio / measurement / astronomy explainers, data-nerd content, retro-lab or synth aesthetics, \"示波器/波形/信号字幕\" asks. The band brings its own dark glass; bright scenes fine.",
  "register": "tech-retro",
  "fonts": { "body": "VT323", "tag": "VT323", "strokeFont": "HersheyScript1.svg" },
  "palette": {
    "body": "#4dff88",
    "accent": "#4dff88",
    "bright": "#d6ffe4",
    "flash": "#eafff2",
    "em": "#ffb347",
    "hud": "#2f8f55",
    "panic": "#ff5f47"
  },
  "body": {
    "paradigm": "scoperail",
    "layer": "fg",
    "fontPx": 40,
    "bandH": 164,
    "waveTop": 80,
    "waveH": 84,
    "lineDy": 34,
    "hudL": "SPECTRUM // CH1",
    "siglost": "— SIGNAL LOST —",
    "waveSeed": 7,
    "chipSeed": 11,
    "yield": { "dim": 0.52, "pre": 0.13, "post": 0.97 }
  },
  "hero": {
    "setpiece": "scopetrace",
    "layer": "bg",
    "params": {
      "targetWidth": 720,
      "window": 0.6,
      "flickGap": 0.12,
      "clearGap": 0.215,
      "coreWidth": 4,
      "haloWidth": 14,
      "spill": 0.55
    }
  },
  "fx": {},
  "plate": { "scrim": 0.62, "grain": 5 },
  "linkages": []
}
themes/stardust.json
{
  "name": "stardust",
  "voice": "celestial condensation — letters condense out of drifting dust into an accumulating poem; the apex assembles from inbound star particles; on the final word the entire visible poem disperses back into dust (semantic exit).",
  "when": "introspective, poetic, memory, science-wonder monologues. The poem column needs one clear side; a soft gradient scrim (behind the subject) handles bright skies.",
  "register": "poetic",
  "fonts": { "body": "Cormorant Garamond" },
  "palette": { "body": "#f7f0dd" },
  "body": {
    "paradigm": "poem",
    "layer": "fg",
    "fontPx": 58,
    "left": 84,
    "stanzaTops": [110, 222, 292],
    "entrance": "condense",
    "exit": "drift",
    "bigScale": 2.0,
    "emScale": 1.38,
    "scrim": { "side": "left", "opacity": 0.62 },
    "seed": 777
  },
  "hero": {
    "setpiece": "assembly",
    "inline": true,
    "params": { "particles": 22, "spread": 150 }
  },
  "fx": {},
  "plate": { "pushIn": 0.01, "grain": 5 },
  "linkages": ["disperse-on-last-word"]
}
themes/stomp.json
{
  "name": "stomp",
  "voice": "full-frame typographic takeover — every beat is a hard-cut center card over a dimmed plate; a long-held card creeps with tension into a true VOID, then the apex detonates in the accent color; the final word dissolves to blur.",
  "when": "hooks, manifestos, ads, hype openers. The plate runs dimmed throughout — content where the words ARE the show. Body stays bg (text behind the subject is the identity).",
  "register": "takeover",
  "fonts": { "body": "Anton" },
  "palette": { "body": "#F5F1E8", "accent": "#FF5C39" },
  "body": {
    "paradigm": "takeover",
    "layer": "bg",
    "anchors": [
      [640, 330],
      [612, 302],
      [668, 358],
      [640, 388],
      [618, 336]
    ],
    "baseDim": 0.42,
    "entrance": "cut",
    "creepScale": 1.1,
    "hotSnap": 1.18,
    "sizes": { "function": 150, "content": 200, "minor": 230 }
  },
  "hero": {
    "setpiece": "colorflip",
    "inline": true,
    "fontPx": 196,
    "params": { "crushFrom": 2.6, "dimKick": 0.62, "flash": 0.9, "dissolveLast": true }
  },
  "fx": { "flash": 0.9 },
  "plate": {
    "punch": 0.05,
    "punchDecay": 9,
    "shakeAmpX": 8,
    "shakeAmpY": 5,
    "shakeHz": 12,
    "shakeDecay": 7,
    "shakeWindow": 0.6,
    "grain": 7,
    "minorPunch": 0.02
  },
  "linkages": []
}
themes/terminal.json
{
  "name": "terminal",
  "voice": "console session — the transcript is a signal log typed into a docked glass panel; the apex decodes out of noise as slot-machine glyph reels, and the log REDACTS the apex words (█████) until the decode locks. The feed corrupts on the final word.",
  "when": "tech, data, AI, engineering, investigative explainers. Cool scenes preferred (the cyan is scene-motivated on cool/dusk palettes).",
  "register": "creator-tech",
  "fonts": { "body": "VT323", "hero": "Orbitron", "tag": "VT323" },
  "palette": {
    "body": "#bfeefe",
    "accent": "#66e0ff",
    "magenta": "#ff5fd6",
    "panelBg": "rgba(2,14,22,0.62)",
    "prompt": "#4d7f93"
  },
  "body": {
    "paradigm": "panel",
    "layer": "fg",
    "fontPx": 34,
    "panel": { "left": 36, "bottom": 30, "width": 470, "header": "SIGNAL LOG_" },
    "entrance": "type",
    "exit": "hold",
    "caret": true,
    "yield": { "dim": 0.68 }
  },
  "hero": {
    "setpiece": "decode",
    "layer": "bg",
    "x": 660,
    "y": 255,
    "fontPx": 112,
    "params": {
      "glyphs": "X017#%/<>+=*KZR",
      "brackets": true,
      "lockStagger": 0.045,
      "exit": "crt-off",
      "seed": 99
    }
  },
  "fx": { "scanband": true, "lockflash": 0.55 },
  "plate": {
    "dim": 0.22,
    "punch": 0.016,
    "punchDecay": 10,
    "shakeAmpX": 0,
    "shakeAmpY": 0,
    "rgbashift": 4,
    "grain": 6
  },
  "linkages": ["redact-until-hero", "corrupt-on-last-word"]
}
themes/thunder.json
{
  "name": "thunder",
  "voice": "the storm is the editor — slate storm mood on the room, ambient slanted rain in front of the subject, and the sky 'kisses' (1-frame brighten) on every body word. Body words arrive on a rain-scrimmed lower third with a lit-bright lightning pop; emphasis words get a sheet-lightning double flash mirrored by a double sky-kiss. The apex is THE STRIKE: a hand-authored branched bolt flashes two frames before the word, then the word slams in lit HARD with its shadow flipped toward the bolt, ozone under-glow breathing through the hold while the rain doubles; everything washes downward with the rain.",
  "when": "dramatic reveals, storm/weather content, epic hype beats, trailer-toned monologues, anything that should land like a thunderclap. Loud register; the charge-dim + word scrim make their own contrast on any plate (bright skies included).",
  "register": "loud",
  "fonts": { "body": "Sora", "hero": "Anton", "tag": "Sora" },
  "palette": {
    "body": "#EEF4FB",
    "hero": "#F2F7FF",
    "accent": "#9FD0FF",
    "glow": "#CFE8FF",
    "core": "#FFFFFF"
  },
  "body": {
    "paradigm": "stormrail",
    "layer": "fg",
    "fontPx": 44,
    "bottomPx": 98,
    "letterSpacing": "0.015em",
    "rain": { "count": 18, "opacity": 0.08, "seed": 9021, "seed2": 4477 },
    "yield": { "dim": 0.5, "pre": 0.15, "post": 0.29 }
  },
  "hero": {
    "setpiece": "boltstrike",
    "layer": "bg",
    "fontPx": 145,
    "params": { "mood": 0.1, "side": "left", "hold": 2.05 }
  },
  "fx": { "strikeflash": 0.55 },
  "plate": {
    "charge": 0.3,
    "dim": 0.22,
    "punch": 0.04,
    "punchDecay": 9,
    "punchOffset": 0.02,
    "shakeAmpX": 5,
    "shakeAmpY": 4,
    "shakeHz": 12,
    "shakeDecay": 7,
    "shakeWindow": 0.5,
    "grain": 5
  },
  "linkages": []
}
themes/transit.json
{
  "name": "transit",
  "voice": "amber LED departure board — the whole video is a station PA system. Body words light column-by-column (L→R LED wipe + 1-frame arrival flicker) on a docked departure strip whose lines page in/out by scroll-snap; furniture: green platform tag, ticking seconds clock, a ⚠ DELAYED → ● ON TIME swap gag after the apex. The apex is a board takeover behind the subject: a big LED panel expands upward inside a chasing marquee border, the word illuminates column-by-column under a PA flash + two brightness 'ding' pulses, glow breathes with LED dropouts, then the word pages up and the panel collapses.",
  "when": "travel/transit, announcements, schedules, countdowns, retro-terminal or arcade registers — anything that should read as an amber dot-matrix station board. Mid-loud register; needs a lower third that tolerates a dark LED strip.",
  "register": "loud",
  "fonts": { "body": "VT323", "hero": "VT323", "tag": "VT323" },
  "palette": {
    "body": "#FFB000",
    "hot": "#FFE9B8",
    "accent": "#FFB000",
    "green": "#36E57A",
    "warn": "#FF9E3D"
  },
  "body": {
    "paradigm": "ledboard",
    "layer": "fg",
    "fontPx": 46,
    "letterSpacing": "0.05em",
    "board": { "left": 140, "width": 1000, "h": 116, "bottom": 30 },
    "header": {
      "plt": "PLT 4",
      "clockPrefix": "19:42:0",
      "statusWait": "⚠ DELAYED",
      "statusOk": "● ON TIME"
    },
    "yield": { "dim": 0.5, "pre": 0.2, "post": 0.9 }
  },
  "hero": {
    "setpiece": "ledwipe",
    "layer": "bg",
    "x": 620,
    "y": 444,
    "fontPx": 120,
    "params": {
      "panelW": 1080,
      "wipeDur": 0.3,
      "headerLeft": "NOW ARRIVING ▸ 19:42",
      "headerRight": "GATE A4"
    }
  },
  "fx": { "paflash": 0.5 },
  "plate": { "charge": 0.5, "dim": 0.2, "grain": 5 },
  "linkages": []
}
themes/vhs.json
{
  "name": "vhs",
  "voice": "camcorder tape playback — the whole frame is a VHS deck. A permanent lens vignette and OSD furniture (▶ PLAY, ● REC, green blinking timestamp) frame the clip; body words are an OSD lower third with a permanent 1px red+cyan misregistration fringe, arriving by tracking glitch (instant on + 2-frame y-shake + 1-frame band-slice shift) and exiting as REWIND streaks with flat-white echoes + scrub lines; the deck flips PLAY→REW in dead air. The apex is REC FREEZE: a 1-frame white tear band + head-switching noise bar, then the word slams in as a huge OSD readout behind the subject — three tracking-sliced bands shear in and SNAP into register, heavy red/cyan misregistration cools and keeps breathing through the hold under tape-weave jitter and an auto-iris pump, then the whole readout REWINDS off left.",
  "when": "retro / nostalgia / found-footage / home-video registers, music and lifestyle edits, '90s tech content, '录像带/VHS/复古/DV' asks — anything that should read as a tape being played back. Mid-loud register; the backlight-comp scrim makes its own contrast on bright skies.",
  "register": "retro-analog",
  "fonts": { "body": "VT323", "hero": "VT323", "tag": "VT323" },
  "palette": {
    "body": "#F2F2EE",
    "hot": "#FFFFFF",
    "accent": "#D9F7D2",
    "red": "#FF2E2E",
    "cyan": "#3FE8E8",
    "green": "#B9FFB0"
  },
  "body": {
    "paradigm": "vhsrail",
    "layer": "fg",
    "fontPx": 44,
    "bottomPx": 108,
    "letterSpacing": "0.04em",
    "osd": { "ts": ["JUN.11 2026 PM 7", "42"] },
    "yield": { "dim": 0.55, "pre": 0.02, "post": 0.9 }
  },
  "hero": {
    "setpiece": "vhsosd",
    "layer": "bg",
    "x": 640,
    "y": 268,
    "fontPx": 150,
    "params": {
      "bandClips": [
        [0, 64],
        [36, 30],
        [70, 0]
      ],
      "bandShear": [-46, 40, -34],
      "misregIn": 11,
      "misreg": 3,
      "weave": [-2, 1, -1, 2, -1, 1, -2, 0],
      "vignette": 0.34,
      "crush": 0.1,
      "hold": 1.69
    }
  },
  "plate": { "charge": 0.55, "dim": 0.12, "grain": 5 },
  "linkages": []
}