From 0aa986e61bf2956a56b8600fc1e6f5a74e5a4307 Mon Sep 17 00:00:00 2001 From: calesthio Date: Tue, 30 Jun 2026 17:03:20 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20Ink=20Theater=20=E2=80=94=20determinist?= =?UTF-8?q?ic=20hand-drawn=20moving-art=20engine=20+=20creative=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reusable engine (ink-theater/ink-theater.js, global InkTheater) for hand-drawn ink-on-white animated 'moving art', authored for HyperFrames (atelier). Five seek-safe primitives: variable-width brush strokes (inkPath/inkRibbon), timeline-driven boil, closed-form damped-spring eases, FABRIK 2D IK + riggable mascot, and a parametric contraption grammar (crank/gauge/hopper/slot/lever/box). Adds skills/creative/ink-theater.md (metaphor method + mined archetypes + color grammar) and README with the HyperFrames font gotcha (webfonts apply to HTML divs, not SVG ). Two reference builds under ink-theater/examples/. Grounded in deep-research on vector/physics/metaphor foundations (FABRIK, bounded-biharmonic/ARAP, closed-form springs, LLM metaphor+layout planning). --- ink-theater/README.md | 57 +++++ ink-theater/examples/assets/inkhand.woff2 | Bin 0 -> 11572 bytes ink-theater/examples/momentum.html | 119 ++++++++++ ink-theater/examples/reel.html | 147 ++++++++++++ ink-theater/ink-theater.js | 269 ++++++++++++++++++++++ skills/creative/ink-theater.md | 50 ++++ 6 files changed, 642 insertions(+) create mode 100644 ink-theater/README.md create mode 100644 ink-theater/examples/assets/inkhand.woff2 create mode 100644 ink-theater/examples/momentum.html create mode 100644 ink-theater/examples/reel.html create mode 100644 ink-theater/ink-theater.js create mode 100644 skills/creative/ink-theater.md diff --git a/ink-theater/README.md b/ink-theater/README.md new file mode 100644 index 00000000..9cf38f6e --- /dev/null +++ b/ink-theater/README.md @@ -0,0 +1,57 @@ +# Ink Theater + +A deterministic, seek-safe engine for hand-drawn **"moving art"** — a minimalist black-ink-on-white world where a deadpan mascot physically *performs* an abstract idea by operating absurd low-tech contraptions. Built for OpenMontage's **atelier** path and rendered through **HyperFrames** (HTML/SVG/CSS + one paused GSAP timeline → MP4). + +Inspired by Ian's `小黑 / Xiaohei` illustration skill (MIT — credit Ian for the technique); this is an original, generic, English, motion-first engine, not a copy. + +## Why it exists + +The illustration style is simple enough that **the illustration IS the animation** — no diffusion model needed. Vector shapes + math give you the whole thing: free, deterministic, infinitely editable, and the character genuinely acts out the concept. This engine turns the research findings (`memory: project_ink_atelier_animation`, deep-research on vector/physics/metaphor foundations) into reusable primitives. + +## The five capabilities (`ink-theater.js`, global `InkTheater`) + +| Module | What it does | Key API | +|---|---|---| +| **ink strokes** | Confident hand-drawn lines — variable-width brush ribbons + wobbled centerlines | `inkPath(pts, opt)`, `inkRibbon(pts, {width,taper,seed})` | +| **boil** | Seek-safe hand-drawn line "boil" — steps a `feTurbulence` seed off the timeline (~9fps), NOT SMIL | `boil(turbEl, tl, {duration,fps})` | +| **spring physics** | Closed-form damped-spring eases (anticipation/overshoot/settle) — pure functions of progress, seek-safe | `springEase({stiffness,damping,mass})`, `ease.{settle,overshoot,bouncy,soft}` | +| **rig / IK** | 2D FABRIK inverse kinematics + a riggable mascot whose arms reach a target | `fabrik(lengths,origin,target)`, `mascot({x,y,scale})` → `.reachL/.reachR([x,y])` | +| **contraption grammar** | Parametric composable machine parts | `parts.{crank,gauge,hopper,slot,lever,box}` | + +## Determinism (HyperFrames render contract) + +Every frame must be reproducible from time alone. This engine obeys that: + +- **Closed-form springs** — `springEase` evaluates an analytic damped-oscillator step response, so any progress `p` maps deterministically (no numeric integration, no accumulated state). +- **Seek-safe boil** — driven by a GSAP stepped-seed tween on the timeline, never SMIL / render-time clocks. +- **IK-follow via `onUpdate`** — pose the arm from a target whose position is set by the timeline; GSAP fires `onUpdate` on seek, so it's pure-function-of-time. +- Seeded PRNG (`rng`) for all "random-looking" wobble — no runtime `Math.random`. +- No `repeat:-1` (finite counts only), animate only transforms/opacity/attrs. + +## ⚠ The font gotcha (learned the hard way) + +**HyperFrames' rasterizer applies `@font-face` webfonts to HTML elements but NOT to SVG ``.** SVG text silently falls back to a system serif — even static, even with the font "loaded". + +**Always render custom-font captions as HTML overlay `
`s** positioned over the SVG scene, never as SVG ``. See `projects/ink-theater-momentum/index.html` for the working pattern: + +```html +
+ …ink art… +
handwritten line
+
+``` + +Bundle the font locally (`@font-face url("assets/x.woff2")`) — a Google Fonts hot-link is a render-time network fetch and violates determinism. Prefer a **static** woff2 (e.g. Patrick Hand) — but note even static SVG `` fails; it's the SVG-vs-HTML boundary that matters, not variable-vs-static. + +## Usage in a HyperFrames project + +1. Copy `ink-theater.js` into the project root; ` + + + + +
+
+
+ + + + + + + + + +
starting is the hard part
+
all the effort is up front
+
then it carries itself
+
momentum does the rest
+
+
momentum
+ +
get it moving — then keep it rolling.
+
+
+
+
+ + + + diff --git a/ink-theater/examples/reel.html b/ink-theater/examples/reel.html new file mode 100644 index 00000000..b8d26ef8 --- /dev/null +++ b/ink-theater/examples/reel.html @@ -0,0 +1,147 @@ + + + + + + + + + + +
+
+
+ + + + + + + + + +
+
+
+ + + + diff --git a/ink-theater/ink-theater.js b/ink-theater/ink-theater.js new file mode 100644 index 00000000..52d05c5c --- /dev/null +++ b/ink-theater/ink-theater.js @@ -0,0 +1,269 @@ +/* + * Ink Theater — a deterministic, seek-safe engine for hand-drawn "moving art". + * Browser global `InkTheater`. Designed for HyperFrames: every primitive is a pure + * function of geometry or of the GSAP timeline playhead, so frame N is reproducible + * from time alone. No render-time clocks, no Math.random at runtime. + * + * Modules: + * rng / noise — seeded determinism + * inkPath/inkRibbon — hand-drawn variable-width strokes (the "look") + * boil — seek-safe stepped line-boil driven off the timeline + * springEase / ease — closed-form damped-spring eases (anticipation/overshoot/settle) + * fabrik — 2D inverse kinematics for limb rigs + * parts — parametric, composable low-tech contraption pieces (the grammar) + * mascot — a riggable deadpan ink character + */ +(function (root) { + "use strict"; + var SVGNS = "http://www.w3.org/2000/svg"; + + // ---- DOM helper ------------------------------------------------------------ + function el(tag, attrs, kids) { + var n = document.createElementNS(SVGNS, tag); + if (attrs) for (var k in attrs) if (attrs[k] != null) n.setAttribute(k, attrs[k]); + if (kids) (Array.isArray(kids) ? kids : [kids]).forEach(function (c) { if (c) n.appendChild(c); }); + return n; + } + + // ---- Seeded determinism ---------------------------------------------------- + function rng(seed) { // mulberry32 + var a = (seed >>> 0) || 1; + return function () { + a |= 0; a = (a + 0x6D2B79F5) | 0; + var t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } + + // ---- Path math ------------------------------------------------------------- + function resample(pts, step) { + if (pts.length < 2) return pts.slice(); + var out = [pts[0]], acc = 0, i, prev = pts[0]; + for (i = 1; i < pts.length; i++) { + var p = pts[i], dx = p[0] - prev[0], dy = p[1] - prev[1], d = Math.hypot(dx, dy); + while (acc + d >= step) { + var t = (step - acc) / d; + prev = [prev[0] + dx * t, prev[1] + dy * t]; + out.push(prev); + dx = p[0] - prev[0]; dy = p[1] - prev[1]; d = Math.hypot(dx, dy); acc = 0; + } + acc += d; prev = p; + } + out.push(pts[pts.length - 1]); + return out; + } + function normals(pts) { + var ns = [], i; + for (i = 0; i < pts.length; i++) { + var a = pts[Math.max(0, i - 1)], b = pts[Math.min(pts.length - 1, i + 1)]; + var tx = b[0] - a[0], ty = b[1] - a[1], L = Math.hypot(tx, ty) || 1; + ns.push([-ty / L, tx / L]); + } + return ns; + } + // Catmull-Rom -> cubic Bézier 'd' (smooth curve through points) + function smoothD(pts, closed) { + if (pts.length < 2) return ""; + var d = "M" + r2(pts[0][0]) + " " + r2(pts[0][1]), i, n = pts.length; + for (i = 0; i < n - 1; i++) { + var p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2; + var c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6; + var c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6; + d += "C" + r2(c1x) + " " + r2(c1y) + " " + r2(c2x) + " " + r2(c2y) + " " + r2(p2[0]) + " " + r2(p2[1]); + } + return d + (closed ? "Z" : ""); + } + function r2(x) { return Math.round(x * 100) / 100; } + + // Bake a small deterministic hand-wobble into a polyline (perpendicular jitter). + function wobblePts(pts, amp, seed) { + var rand = rng(seed || 1), ns = normals(pts); + return pts.map(function (p, i) { + var j = (rand() - 0.5) * 2 * amp; + return [p[0] + ns[i][0] * j, p[1] + ns[i][1] * j]; + }); + } + + // A confident hand-drawn centerline (use with stroke + round caps). + function inkPath(pts, opt) { + opt = opt || {}; + var P = wobblePts(resample(pts, opt.step || 26), opt.wobble != null ? opt.wobble : 1.6, opt.seed || 7); + return smoothD(P, opt.closed); + } + + // A variable-width brush ribbon (returns a CLOSED outline, fill it black). + // Width tapers at the ends and swells in the middle — the difference between + // a drawn line and a traced one. + function inkRibbon(pts, opt) { + opt = opt || {}; + var w = opt.width || 12, taper = opt.taper != null ? opt.taper : 0.55, seed = opt.seed || 5; + var P = wobblePts(resample(pts, opt.step || 22), opt.wobble != null ? opt.wobble : 1.2, seed); + var ns = normals(P), n = P.length, rand = rng(seed + 99); + var left = [], right = [], i; + for (i = 0; i < n; i++) { + var t = n > 1 ? i / (n - 1) : 0; + // width profile: 0 at ends, 1 in the belly; taper controls end thinness + var prof = Math.pow(Math.sin(Math.PI * t), taper) * (0.85 + 0.3 * rand()); + var hw = (w / 2) * prof + 0.6; + left.push([P[i][0] + ns[i][0] * hw, P[i][1] + ns[i][1] * hw]); + right.push([P[i][0] - ns[i][0] * hw, P[i][1] - ns[i][1] * hw]); + } + return smoothD(left, false) + " " + smoothD(right.reverse(), false).replace(/^M/, "L") + "Z"; + } + + // ---- Seek-safe line boil --------------------------------------------------- + // Steps a feTurbulence seed across the timeline so the inked lines "boil" at a + // low frame-rate (hand-drawn feel) while staying 100% deterministic under seek. + function boil(turbulenceEl, tl, opt) { + opt = opt || {}; + var dur = opt.duration || tl.duration() || 8; + var fps = opt.fps || 9; + var steps = Math.max(1, Math.round(dur * fps)); + tl.to(turbulenceEl, { attr: { seed: steps }, duration: dur, ease: "steps(" + steps + ")" }, 0); + return turbulenceEl; + } + + // ---- Closed-form damped spring eases -------------------------------------- + // Step response of a damped harmonic oscillator, normalized to settle at 1. + // Underdamped => overshoot + settle (the juicy follow-through). Pure fn of p. + function springEase(opt) { + opt = opt || {}; + var stiffness = opt.stiffness || 170, damping = opt.damping || 14, mass = opt.mass || 1; + var w = Math.sqrt(stiffness / mass); // natural angular frequency + var zeta = damping / (2 * Math.sqrt(stiffness * mass)); // damping ratio + var horizon = opt.horizon || 1; // p maps to t in [0, horizon*settle] + // choose a time-scale so motion is essentially settled at p=1 + var settle = 8 / (zeta * w || 1); + function y(t) { + if (zeta < 1) { + var wd = w * Math.sqrt(1 - zeta * zeta); + return 1 - Math.exp(-zeta * w * t) * (Math.cos(wd * t) + (zeta * w / wd) * Math.sin(wd * t)); + } else if (zeta === 1) { + return 1 - Math.exp(-w * t) * (1 + w * t); + } else { + var a = -w * (zeta - Math.sqrt(zeta * zeta - 1)), b = -w * (zeta + Math.sqrt(zeta * zeta - 1)); + var A = b / (b - a), B = -a / (b - a); + return 1 - (A * Math.exp(a * t) + B * Math.exp(b * t)); + } + } + var f = function (p) { + if (p <= 0) return 0; if (p >= 1) return 1; // pin endpoints + return y(p * settle * horizon); + }; + return f; + } + var ease = { + settle: springEase({ stiffness: 180, damping: 18 }), // minimal overshoot + overshoot: springEase({ stiffness: 200, damping: 11 }),// clear overshoot + settle + bouncy: springEase({ stiffness: 260, damping: 8 }), // springy + soft: springEase({ stiffness: 120, damping: 20 }) // gentle, no overshoot + }; + + // ---- FABRIK 2D inverse kinematics ----------------------------------------- + // lengths: array of segment lengths (n segments -> n+1 joints). Returns joint pts. + function fabrik(lengths, origin, target, opt) { + opt = opt || {}; + var iters = opt.iterations || 12, tol = opt.tol || 0.4; + var n = lengths.length, total = lengths.reduce(function (a, b) { return a + b; }, 0); + var pts = [origin.slice()], i; + for (i = 0; i < n; i++) pts.push([origin[0] + lengths[i] * (i + 1), origin[1]]); // seed straight + var dist = Math.hypot(target[0] - origin[0], target[1] - origin[1]); + if (dist > total) { // unreachable: stretch straight + var ux = (target[0] - origin[0]) / dist, uy = (target[1] - origin[1]) / dist, acc = origin.slice(); + pts[0] = origin.slice(); + for (i = 0; i < n; i++) { acc = [acc[0] + ux * lengths[i], acc[1] + uy * lengths[i]]; pts[i + 1] = acc.slice(); } + return pts; + } + for (var it = 0; it < iters; it++) { + pts[n] = target.slice(); // backward + for (i = n - 1; i >= 0; i--) pts[i] = lerpTo(pts[i + 1], pts[i], lengths[i]); + pts[0] = origin.slice(); // forward + for (i = 0; i < n; i++) pts[i + 1] = lerpTo(pts[i], pts[i + 1], lengths[i]); + if (Math.hypot(pts[n][0] - target[0], pts[n][1] - target[1]) < tol) break; + } + return pts; + } + function lerpTo(from, to, len) { + var dx = to[0] - from[0], dy = to[1] - from[1], d = Math.hypot(dx, dy) || 1; + return [from[0] + dx / d * len, from[1] + dy / d * len]; + } + + // ---- Contraption grammar — parametric composable parts -------------------- + // Each returns { g: , ... } with named handles the author wires to motion. + var INK = "#1a1a1a"; + function strokeEl(d, sw) { return el("path", { d: d, fill: "none", stroke: INK, "stroke-width": sw || 4, "stroke-linecap": "round", "stroke-linejoin": "round" }); } + var parts = { + // crank wheel with a handle on the rim — spin the returned `wheel` group + crank: function (cx, cy, r) { + var wheel = el("g", { transform: "" }); + wheel.appendChild(el("circle", { cx: cx, cy: cy, r: r, fill: "#fff", stroke: INK, "stroke-width": 4 })); + wheel.appendChild(el("line", { x1: cx, y1: cy, x2: cx, y2: cy - r + 4, stroke: INK, "stroke-width": 2.4 })); + wheel.appendChild(el("circle", { cx: cx, cy: cy - r + 8, r: r * 0.16, fill: INK })); // handle + var g = el("g", null, [wheel, el("circle", { cx: cx, cy: cy, r: 3, fill: INK })]); + return { g: g, wheel: wheel, pivot: [cx, cy] }; + }, + // gauge with a needle — swing `needle` (pivot at center) + gauge: function (cx, cy, r) { + var needle = el("line", { x1: cx, y1: cy, x2: cx, y2: cy - r + 6, stroke: INK, "stroke-width": 4 }); + var g = el("g", null, [ + el("circle", { cx: cx, cy: cy, r: r, fill: "#fff", stroke: INK, "stroke-width": 4 }), + strokeEl("M" + (cx - r * 0.7) + " " + (cy - r * 0.2) + " A " + (r * 0.7) + " " + (r * 0.7) + " 0 0 1 " + (cx + r * 0.7) + " " + (cy - r * 0.2), 2), + needle, el("circle", { cx: cx, cy: cy, r: 3.5, fill: INK }) + ]); + return { g: g, needle: needle, pivot: [cx, cy] }; + }, + hopper: function (cx, topY, mouthW, depth) { + var hw = mouthW / 2; + return { g: strokeEl("M" + (cx - hw * 0.5) + " " + (topY + depth) + " L" + (cx - hw) + " " + topY + " L" + (cx + hw) + " " + topY + " L" + (cx + hw * 0.5) + " " + (topY + depth)), mouth: [cx, topY] }; + }, + slot: function (x, y, w, h) { return { g: el("rect", { x: x, y: y, width: w, height: h, rx: 2, fill: INK }), out: [x + w, y + h / 2] }; }, + lever: function (cx, cy, len) { + var arm = el("line", { x1: cx, y1: cy, x2: cx + len, y2: cy - len * 0.2, stroke: INK, "stroke-width": 6, "stroke-linecap": "round" }); + var knob = el("circle", { cx: cx + len, cy: cy - len * 0.2, r: 9, fill: INK }); + return { g: el("g", null, [el("circle", { cx: cx, cy: cy, r: 5, fill: INK }), arm, knob]), arm: arm, knob: knob, pivot: [cx, cy] }; + }, + box: function (x, y, w, h, label) { + var g = el("g", null, [el("rect", { x: x, y: y, width: w, height: h, rx: 6, fill: "#fff", stroke: INK, "stroke-width": 4 })]); + if (label) { var t = el("text", { x: x + w / 2, y: y + h / 2 + 6, "text-anchor": "middle", fill: INK, "font-size": 22, "font-family": "Caveat, cursive" }); t.textContent = label; g.appendChild(t); } + return { g: g }; + } + }; + + // ---- Mascot ---------------------------------------------------------------- + // A deadpan ink blob with two FABRIK-posable arms. + function mascot(opt) { + opt = opt || {}; + var x = opt.x || 0, y = opt.y || 0, s = opt.scale || 1; + var bw = 40 * s, bh = 50 * s; + var body = el("ellipse", { cx: x, cy: y, rx: bw, ry: bh, fill: INK }); + var legL = el("path", { d: "M" + (x - 12 * s) + " " + (y + bh * 0.92) + " l" + (-4 * s) + " " + (24 * s), stroke: INK, "stroke-width": 7 * s, fill: "none", "stroke-linecap": "round" }); + var legR = el("path", { d: "M" + (x + 12 * s) + " " + (y + bh * 0.92) + " l" + (4 * s) + " " + (24 * s), stroke: INK, "stroke-width": 7 * s, fill: "none", "stroke-linecap": "round" }); + var eyeL = el("circle", { cx: x - 12 * s, cy: y - 16 * s, r: 7 * s, fill: "#fff" }); + var eyeR = el("circle", { cx: x + 12 * s, cy: y - 16 * s, r: 7 * s, fill: "#fff" }); + var armL = el("path", { d: "", stroke: INK, "stroke-width": 7 * s, fill: "none", "stroke-linecap": "round", "stroke-linejoin": "round" }); + var armR = el("path", { d: "", stroke: INK, "stroke-width": 7 * s, fill: "none", "stroke-linecap": "round", "stroke-linejoin": "round" }); + var shoulderL = [x - bw * 0.7, y - 2 * s], shoulderR = [x + bw * 0.7, y - 2 * s]; + var seg = [32 * s, 30 * s]; + function poseArm(pathEl, shoulder, target) { + var j = fabrik(seg, shoulder, target); + pathEl.setAttribute("d", "M" + r2(j[0][0]) + " " + r2(j[0][1]) + " L" + r2(j[1][0]) + " " + r2(j[1][1]) + " L" + r2(j[2][0]) + " " + r2(j[2][1])); + } + poseArm(armL, shoulderL, [shoulderL[0] - 18 * s, shoulderL[1] + 30 * s]); + poseArm(armR, shoulderR, [shoulderR[0] + 18 * s, shoulderR[1] + 30 * s]); + var g = el("g", null, [armL, armR, body, legL, legR, eyeL, eyeR]); + return { + g: g, body: body, eyeL: eyeL, eyeR: eyeR, armL: armL, armR: armR, + origin: [x, y], shoulderL: shoulderL, shoulderR: shoulderR, + reachL: function (t) { poseArm(armL, shoulderL, t); }, + reachR: function (t) { poseArm(armR, shoulderR, t); } + }; + } + + root.InkTheater = { + el: el, rng: rng, resample: resample, smoothD: smoothD, wobblePts: wobblePts, + inkPath: inkPath, inkRibbon: inkRibbon, boil: boil, + springEase: springEase, ease: ease, fabrik: fabrik, + parts: parts, mascot: mascot, INK: INK + }; +})(window); diff --git a/skills/creative/ink-theater.md b/skills/creative/ink-theater.md new file mode 100644 index 00000000..9012e16f --- /dev/null +++ b/skills/creative/ink-theater.md @@ -0,0 +1,50 @@ +# Ink Theater — hand-drawn "moving art" (creative skill) + +> Style id: `ink-sketch` · Engine: `ink-theater/ink-theater.js` · Runtime: HyperFrames (atelier) +> Technique credit: inspired by Ian's `小黑/Xiaohei` MIT skill. Generic, English, motion-first. + +**What it is:** a minimalist **black-ink-on-white** world where a deadpan mascot *physically performs* an abstract idea by operating an absurd **low-tech contraption**. Not a fixed catalog of scenes — a generic method + a parametric engine. Runs on the `animation` pipeline in atelier mode; it is NOT its own pipeline. + +## The generic method (concept → moving scene) + +The "unlock" from research + the Xiaohei composition rules is a 3-step metaphor generator — do this in the agent, not in code: + +1. **Abstract concept → one physical ACTION** — stuck, leaking, compounding, sorting, fermenting, pushing, sinking, unraveling. +2. **System → one low-tech OBJECT** — press, funnel, well, jar, conveyor, ladder, bridge, cabinet, boulder, gate, scale. +3. **Mascot PERFORMS the action on the object** — it cranks / pushes / pumps / stamps / fishes-out. *If you can delete the mascot and the metaphor survives, it's decoration — redo it.* + +Then stage as beats on one continuous white page with a camera (pan / push). + +## Mined archetypes (pick one, invent fresh specifics) + +| Archetype | Mascot action | Motion recipe | +|---|---|---| +| **Contraption** | operates a machine (crank/lever/pump) | feed in → crank → machine works (steam/gauge) → output pops | +| **Before/after load-shift** | crushed by chaos → relaxed, one key act | left chaos → orange sweep → right structure assembles | +| **Journey / pitfalls** | walks a path; falls in pits or hits nodes | path draws on → walk → pit swallows / node pops → blue return loop | +| **One → many fan** | splits/cuts one source | source splits → arrows draw to N branches → each branch acts | +| **Push / momentum** | shoves a boulder up, it rolls away | slow strained up (power1) → crest → fast roll (power2.in) | +| **Well / extract** | lowers a bucket into noise, scoops the gem | bucket descends → scoop → pull up the one good bit | + +## Color grammar (strict) + +- **black** = structure & mascot · **orange** = flow / arrows ONLY · **red** = the problem / warning · **blue** = the good end-state. +- Pure white paper, ≥35% negative space, subject ~40–60%. Deadpan mascot (white-dot eyes), never cute. + +## Engine cheat-sheet (`InkTheater`) + +- Lines: `inkPath(pts)`, `inkRibbon(pts,{width,taper})` (brush). Boil: `boil(turbEl, tl, {duration})`. +- Motion: `ease.{settle,overshoot,bouncy,soft}` (seek-safe springs) — use overshoot for pops, settle for arrivals, bounce for landings. +- Character: `mascot({x,y,scale})` → `.reachL/.reachR([x,y])` (FABRIK). Follow a moving target via GSAP `onUpdate`. +- Machines: `parts.{crank,gauge,hopper,slot,lever,box}` — compose them. +- Full API + determinism rules: `ink-theater/README.md`. + +## ⚠ Non-negotiables + +- **Captions = HTML overlay `
`s, never SVG ``** — HyperFrames doesn't apply webfonts to SVG text (silent serif fallback). Bundle a static handwriting woff2 locally (no hot-link). See `ink-theater/README.md` → "font gotcha". +- Determinism: closed-form springs, seed-stepped boil off the timeline, no `repeat:-1`, seeded PRNG only. +- One paused `gsap.timeline` on `window.__timelines`. Validate with `lint` + `snapshot` (read the contact-sheet) before render. + +## Reference builds + +- `projects/ink-theater-reel/` — capabilities reel. · `projects/ink-theater-momentum/` — "Momentum" story (handwriting via HTML divs).