{
  "about": "An entry of UpFork's ledger. Check it by hand as https://upfork.dev/docs/ledger says, or at https://upfork.dev/verify/E-38",
  "entry": {
    "seq": 38,
    "kind": "article-version",
    "record": "{\"ai\":{\"apps\":[\"Claude\"],\"models\":[\"Claude Opus 5.5\"]},\"article\":\"build-a-cursor-following-mascot-hero-from-an-ai-video-maple-s-frame-engine-a\",\"author\":{\"account\":\"a_9d470b566acab5f65bdc\",\"handle\":\"ramin-valadan\"},\"body\":\"## What it is\\n\\nMaple is a hero section for a steakhouse with a filmed character in it. A cartoon Hereford cow in a knitted scarf stands at the end of the counter and watches the board of steaks: she looks down at the plate the guest points at, follows the pointer along the board, lifts her head into a smile when a cut is picked, and says her line beside her head. She is not a 3D model and she is not drawn in code: she is an AI-generated video, cut into frames and steered by the pointer.\\n\\n::preview\\n\\nThis article is for anyone, person or AI, who wants to build a hero like it. It covers how to turn a short character video into frames you can steer, how to map the pointer to a pose, how to move a filmed character so it feels alive, how to draw and recolour it in WebGL, and how to keep the page around it at 60 fps. Every number was measured in Chrome on a laptop with an Intel Iris Plus (an integrated GPU). Each lesson sits beside the step where it was learned: what we got wrong or didn't know, and what fixed it.\\n\\n## The idea: film the character once, then scrub the film\\n\\nA 3D model of a furry character needs a modeller, a rig and a renderer in the browser; a Lottie looks flat. A video looks real, but it only plays forward. The middle way is to film the character doing the few motions you need, then treat each motion as an axis you can move along, frame by frame, in either direction, at any speed.\\n\\nMaple needs three motions, cut from one 1280×720, 24 fps, 361-frame video:\\n\\n| Clip | Source frames | What it does |\\n| --- | --- | --- |\\n| `lower` | 31 → 91 | from looking ahead to looking down at the board; plays once, as the intro |\\n| `axis` | 150 → 229 | the gaze axis: her head turns from far left to slightly right while she looks down |\\n| `rise` | 229 → 301 | lifts her head into the smile; played backwards, it closes the smile |\\n\\nTwo stills are the posters: frame 18 (neutral) is the page's LCP image, frame 320 (smiling) stands in for reduced motion.\\n\\nWhat makes it work is that `axis` ends on the very frame where `rise` begins. Laid end to end they are one track, and every pose Maple shows after the intro is a position on it: looking at a plate is a point on the axis part, smiling is the far end.\\n\\n::file{path=\\\"components/maple-hero/engine/controller.ts\\\" lines=\\\"1-6\\\"}\\n\\nSo the build has two halves: a pipeline, run once on the developer's machine, that turns the video into small keyed frames, and a runtime in the browser that picks a position on the track every frame and draws it.\\n\\n## Step 1: from video to keyed frames\\n\\nThe pipeline is a Node script. Only its output ships: WebP atlases, two posters and a generated manifest. Its stages:\\n\\n```text\\n1. extract   every frame with ffmpeg, decoded with the HD (BT.709) matrix, as browsers decode video\\n2. key       the flat background away: colour + alpha per frame\\n3. measure   the union crop, the background colour, the nose track, the junction frame\\n4. thin      each clip by equal motion, so scrubbing feels even\\n5. pack      frames into WebP atlases (lossy, with alpha) for a desktop and a mobile tier,\\n             bake the posters, write the manifest\\n```\\n\\nDecoding with the same matrix the browser uses matters for step 3: colours measured on the frames are then the colours the guest sees.\\n\\n### Keying a character off a flat background\\n\\nThe video was made on a plain, light grey background (#c9c3b9). A chroma key doesn't suit a cow that is white, cream and grey herself, so the keyer works on d, each pixel's colour distance from the background, and decides from where a pixel is, not only its colour:\\n\\n- a flood fill from the frame's border through clear background (d < 10), then 3 px further through the soft edge;\\n- enclosed holes (the gaps between her legs): background-coloured regions that don't touch the border, larger than 250 px;\\n- the contact shadow baked under her hooves, grown from the background in the floor zone (the bottom 4.5% of her height) and kept as a soft shadow of its own;\\n- a soft alpha edge, colour decontamination, speck removal, and a temporal median on edges that don't move, so still outlines don't shimmer from frame to frame.\\n\\n:::lesson{title=\\\"Flood-filling a light grey background with a loose colour threshold eats shaded white fur\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** keying a cartoon cow off a flat light-grey background could use one generous flood fill from the frame's border: every connected pixel within a colour distance of 22 of the background is background.\\n**What happened:** the fill wandered into her shaded white fur, which is close to the background's grey, and cut holes in her.\\n**The fix:** the fill runs only through clear background (distance under 10) and then at most 3 px further, through the soft edge; enclosed gaps (between the legs) are found separately, as background-coloured regions over 250 px that don't touch the border.\\n**Evidence:** commit 45b98db, the frame pipeline (\\\"shaded white fur no longer floods\\\").\\n:::\\n\\n:::lesson{title=\\\"Growing a baked contact shadow into the background also swallows a hoof of the same grey\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** the soft contact shadow baked into the video under the character's hooves could be found by growing the background region downward through grey pixels in the floor zone.\\n**What happened:** the grow went into a hoof's outer toe, which is as light a grey as the shadow: in review, Ramin saw her hooves eaten away.\\n**The fix:** the grow stops at a hard edge, a step of more than 5% of the background's luminance between neighbouring pixels. A shadow fades; a hoof has an outline. Both hooves are now whole in every pose, on light and dark palettes.\\n**Evidence:** commit 65c5d32 (the `step = 0.05` limit in the keyer's `floorShadow`).\\n:::\\n\\n:::lesson{title=\\\"AI video renders bake a rim light into a character's outline: keyed onto a dark page it shows as a pale halo\\\" kind=\\\"tools\\\" by=\\\"ai\\\"}\\n**I assumed:** once the background was keyed away, the character's edge pixels only needed the background's colour removed (decontamination), and the outline would sit on any page.\\n**What happened:** the render had lit her outline with a rim light, invisible against its own grey background but a pale halo round her on the dark palettes.\\n**The fix:** edge colours lean on the fur just inside the outline, and a solid outline pixel may be at most 36 levels brighter than that interior fur (`rimAllowance`).\\n**Evidence:** commit 45b98db (\\\"decontaminated colour that leans on the fur inside and drops the render's baked rim\\\").\\n:::\\n\\n### Measure the frames, not the brief\\n\\nTwo things come from the frames themselves, and both were first written from guesses in the brief:\\n\\n- **Where her nose is in each frame of the axis.** That is what makes her look at things. The neutral muzzle sits at (623, 190) in source pixels and the axis sweeps it from x = 513 to 701. The pipeline records the nose per frame and fits a yaw table to it: an isotonic (never decreasing) fit, so the table never turns back on itself.\\n- **Which pixels are scarf.** The scarf is recoloured to match each palette, live, in the shader (Step 5), so the mask has to catch the knit and nothing else.\\n\\n:::lesson{title=\\\"An RGB 'pink' test for a cartoon nose also catches a cream face in warm light; track the nose by hue\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** a cartoon cow's pink nose could be found per frame with an RGB rule (red well above green and blue).\\n**What happened:** in the video's warm light her cream face passed the same test, so the nose track jumped onto her cheeks.\\n**The fix:** the nose is found in HSV: hue 4–23.5°, saturation 0.22–0.5, value at least 0.72. The yaw table is an isotonic fit of that track.\\n**Evidence:** commit 34d2116; \\\"Nose track\\\" under the decisions in the project's README.\\n:::\\n\\n:::lesson{title=\\\"Measure a recolour mask on the real frames: the mustard knit sat at hue 27–45°, not the 38–56° written down\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** the scarf mask could use the hue band the brief gave for mustard yellow: 38–56°, saturation at least 0.42.\\n**What happened:** measured on the frames, most of the knit sits at hue 27–45°, so the mask missed it; and widening the band naively took in her irises, horns and cream belly, which live just below.\\n**The fix:** the mask runs 26–48° with feathered ends, asks for scarf-level saturation and value below 30–35° (where the irises, horns and belly are), and a pixel next to scarf that is roughly yellow counts as scarf, so no mustard outline is left after recolouring.\\n**Evidence:** commit 45b98db (\\\"Scarf mask retuned to the measured knit\\\"); `?debug=mask` shows the mask over every palette.\\n:::\\n\\n### Restore the source before blaming the pipeline\\n\\nMaple looked soft, most of all with her head down or turning. Every stage was measured on its own before anything was changed.\\n\\n:::lesson{title=\\\"A soft-looking frame sequence came from a 1.9 Mbps 720p source, not from WebP or WebGL: measure stage by stage\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** the softness came from our own steps: the lossy WebP atlases or the renderer's scaling.\\n**What happened:** measured stage by stage, WebP and the renderer lost little. The source was the limit: a 720p video at 1.9 Mbps had smeared her fur and the knit before any of our code ran.\\n**The fix:** the frames the clips use (220) were restored with Real-ESRGAN: realesr-general-x4v3 blended half and half with its light-clean-up sibling, run 4× on the region around her, then Lanczos back to the source size. Same size and weight, crisp fur and knit; about 45 minutes on a laptop CPU. The shader adds a light contrast-adaptive sharpening (0.65) for screens at 125–150% that draw her larger than her frames.\\n**Evidence:** commit 13a0a0a.\\n:::\\n\\n### Thin by motion, pack coarse to fine\\n\\nA video's frames are evenly spaced in time, not in motion: her head moves quickly in some stretches and barely at all in others. The pipeline sums the difference between consecutive frames along each clip and keeps frames at equal steps of that sum, so moving the pointer by the same distance moves her by the same amount anywhere on the axis.\\n\\nThe frames are packed into WebP atlases with alpha, ordered coarse to fine: a clip's first atlas already holds frames spread over the whole clip, and later atlases fill in between. Desktop frames are 468×716, 4.2 MB in 12 atlases; the mobile tier is 1.6 MB in 7; the posters are 39 KB and 26 KB.\\n\\n## Step 2: load only what the device can hold\\n\\nAn atlas's file size says little about its cost: once decoded it takes width × height × 4 bytes of memory, whatever it weighed as WebP. So the frame store picks a tier, then keeps a budget of decoded atlases by device class:\\n\\n::file{path=\\\"components/maple-hero/engine/frame-store.ts\\\" lines=\\\"20-39\\\"}\\n\\nAtlases are fetched after load and in idle time, four at a time: the first atlas of `axis`, `rise` and `lower`, then the rest coarse to fine. The clips on her current and next route stay decoded; the rest are released least recently used and decoded again from their kept blobs (with `createImageBitmap`, off the main thread) when needed. Until a frame's atlas is in, the nearest decoded frame of the same clip stands in, so the pointer works from the first coarse pass. The intro's `lower` clip is let go for good once it has played: after the intro, Maple holds 142 MB decoded on the GPU.\\n\\n## Step 3: from the pointer to a pose\\n\\nHer gaze is an angle. Measured at her muzzle, θ is the direction to the target: 0 is straight down, negative is toward the screen's left.\\n\\n::file{path=\\\"components/maple-hero/engine/gaze.ts\\\" lines=\\\"19-27\\\"}\\n\\nThe calibration maps θ from −76° (the axis's first frame) to +14° (its last) onto u from 0 to 1. Then u goes through the yaw table measured in Step 1, inverted, to a source frame. Where the table is flat (a few frames with the same yaw), the inversion returns the middle of the run, so she never snaps to one end of it.\\n\\nThe target depends on what the guest is doing, in this order: a script (the intro, or the smile after a pick); a plate pointed at or focused with the keyboard (she looks at that plate's centre); the pointer over the board; and the pointer resting above the board for 450 ms, which makes her look back up and smile. A mouse click doesn't hold her gaze after the pointer leaves: only keyboard focus does. On touch screens she looks at the plate the touch settled on.\\n\\n:::lesson{title=\\\"Calibrate a character's gaze on the shortest, widest hero it will be shown in\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** a gaze calibration that put her nose on each plate at 1440×900 would hold at every size, since the board scales with the hero.\\n**What happened:** in a short, wide hero (1519×629, a 1920×1080 screen at 125% with browser toolbars) the plates sit further left of her and a little higher, and with the far-left angle at −62°, then −68°, two of the leftmost plates got the same frame: she looked at both with one pose.\\n**The fix:** the axis's first frame maps to −76°. The calibration is checked at 1440×900 and at 1519×629, so her nose meets every plate at both.\\n**Evidence:** commit f41f0b8; `gaze.angleFarLeft` in hero-settings.ts.\\n:::\\n\\n## Step 4: move her like something alive\\n\\nHow she moves between two poses matters as much as the poses. A filmed character that snaps to the pointer looks like a scrubbed video; one that eases linearly looks mechanical. Maple moves along her track on a critically damped spring (damping 2√k, so it never rings) with two limits: a top speed and a top acceleration. The top acceleration makes every move start and end gently, and the spring brakes in time to stop on its target: going toward it, her speed never exceeds √(2 · a · distance).\\n\\n::file{path=\\\"components/maple-hero/engine/spring.ts\\\" lines=\\\"21-48\\\"}\\n\\nThe values are in source frames: stiffness 120, top speed 70 frames per second along the axis, top acceleration 420 frames per second². The spring advances in sub-steps of 1/240 s, so a long frame doesn't make it unstable, and it lands on its target instead of passing it.\\n\\nThe top speed changes along the track: the lift into the smile has its own speed (2.6× the source's), and the change from the axis's speed is eased over the 14 axis frames before the junction, so she brakes into the lift instead of hitting it. On her way to a smile she turns at twice her looking speed.\\n\\n:::lesson{title=\\\"Two motions joined end to end need one spring over both, or the character stops dead at the join\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** the turn (the gaze axis) and the lift into the smile were separate motions: a spring on the axis, then the lift played at a constant speed.\\n**What happened:** she stopped dead at the frame where the two clips meet (229) before every smile, and the lift itself looked mechanical.\\n**The fix:** the axis and the lift are one track, end to end, and one spring with a top acceleration moves her along all of it. A look, a smile and the way back down all start and end gently and pass from the turn into the lift without stopping.\\n**Evidence:** commit 65c5d32 (\\\"Before, there were two springs, a dead stop at frame 229, and a constant-speed lift\\\").\\n:::\\n\\nA pick needs an answer at once. After a click, the full smile from the far-left plate used to take about 3 s, and her line waited for it. Now her line starts typing 0.35 s after the click, as her head begins to lift: from the far-left plate the line types from about 0.7 s and the smile lands at 2.3 s.\\n\\n## Step 5: draw two exact neighbours, never a stand-in\\n\\nA position on the track is rarely a whole frame: it falls between two. Blending the two is the obvious answer, but how much to blend decides whether the turn reads as motion or as a double exposure:\\n\\n::file{path=\\\"components/maple-hero/engine/renderer.ts\\\" lines=\\\"40-48\\\"}\\n\\nEach frame is drawn alone for the first and last quarter of the way to its neighbour, with a smooth crossfade only in the middle half. And blending happens only between the two exact neighbours, both decoded:\\n\\n::file{path=\\\"components/maple-hero/engine/renderer.ts\\\" lines=\\\"64-71\\\"}\\n\\n:::lesson{title=\\\"Blending frames further apart in a scrubbed turn reads as a double image, not as motion\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** a quarter of the head turn's frames could be dropped (80 down to 60, 0.8 MB saved) and the gaps covered by blending each pair of neighbours.\\n**What happened:** neighbours further apart differ more, and the 50/50 blend between them read as a soft double image of her head while she turned.\\n**The fix:** all 80 axis frames are back (desktop frames 3.2 → 4.2 MB). Each frame is drawn alone for the first and last quarter of the way to the next, with a smoothstep crossfade only in the middle half; and a frame is never blended with a stand-in several frames away: until its neighbour is decoded, the nearest frame is drawn alone.\\n**Evidence:** commit 13a0a0a; `HOLD` and `blendWeight` in renderer.ts.\\n:::\\n\\n### Recolour, sharpen and light in one shader\\n\\nThe WebGL2 fragment shader does the rest in one pass. The frames carry premultiplied alpha, so colour is divided by alpha first. The scarf mask is a feathered hue band in HSV with stricter floors for saturation and value at the low end (Step 1), plus the neighbour rule for its blended edge. The new colour keeps the knit's shading: the target is the palette's scarf colour scaled by the pixel's luminance relative to the scarf's mean, raised to a gamma. Sharpening follows AMD's contrast-adaptive sharpening (CAS): detail is lifted against the four neighbours, less where local contrast is already high, and not at all on the silhouette, where it would draw a halo on the page. Last, a rim light catches the edge where the silhouette falls away toward the light.\\n\\n::file{path=\\\"components/maple-hero/engine/renderer-gl.ts\\\" lines=\\\"108-127\\\"}\\n\\nWithout WebGL2, a Canvas 2D renderer draws the same blend, without the recolour and the rim light: every palette keeps the native mustard scarf there.\\n\\n## Step 6: a loop that draws only when something changed, and sleeps\\n\\nThe runtime is framework-free TypeScript with one `requestAnimationFrame` loop, paused when the hero is off-screen or the tab is hidden. React holds no state that changes per frame or per pointer move: the plates, the card and the speech talk to Maple through a small bus object that the loop reads every frame. The loop draws only when the frame, the blend, the palette or the size has changed, and once she has been settled for 45 frames with nothing pending, it parks until an input wakes it:\\n\\n::file{path=\\\"components/maple-hero/engine/runtime.ts\\\" lines=\\\"460-487\\\"}\\n\\nAt rest, script time fell from 52 ms to 17 ms per 6 s once the loop could sleep.\\n\\n## Step 7: her lines are a quotation, not a bubble\\n\\n:::lesson{title=\\\"A speech bubble over a filmed mascot covers its face: set its lines beside the head, placed per frame\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** a cream speech bubble over the stage, its tail following her mouth, was the natural way for a mascot to talk.\\n**What happened:** in review, Ramin found the bubble covered her face for most of her lines, and a floating card of tasting notes over the plates hid the embers that rise from a picked plate.\\n**The fix:** her lines are set beside her head like a pull quote (a brass quote mark, an italic line, her name) with a brass leader drawn from a dot at her cheek. The pipeline records her head's edges (cheeks, ears, top) in every frame, and the runtime places the quote each frame from them: left of her head, else right, else above it on phones, always clear of the copy column. The tasting notes moved into the board's caption line.\\n**Evidence:** commit 65c5d32.\\n:::\\n\\nHer lines go to a polite live region only when the guest caused them, so a screen reader isn't interrupted by the intro.\\n\\n## Step 8: the board of photographed steaks\\n\\nThe six plates are photographs, shot at 45° and set on a walnut counter seen in perspective, the same deck Maple stands on. They were made with an image model as edits of one template photo, so every plate shares the camera, the porcelain and the light, with no side dishes, and the bone of the tomahawk stays inside the frame. The doneness lens shows six cross-section slices, 22–24 KB each at 360 px, loaded with the card.\\n\\n:::lesson{title=\\\"Cut a white plate out of a photo by its gold rim, not by keying: in shade it keys away with a grey background\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** plate photos on a plain grey background could be cut out with the same colour key as the character.\\n**What happened:** a white plate in shade keyed away with the grey background, and its soft shadow clung to the rim.\\n**The fix:** the cut-out is the plate's gold rim. An axis-aligned ellipse is fitted to the gold pixels with RANSAC over 4-pixel samples, the rim's outer edge is found along 360 rays, and the ellipse is refitted by least squares. The white lip under the front edge is kept, and a bone past the rim is added from seared pixels browner than the background, convex-hulled. Every plate's porcelain is balanced to one white, and each cut-out records its rim box so every rim is drawn the same width.\\n**Evidence:** commits a4cc0ab and f41f0b8 (`findRim` and `ellipseFrom` in the pipeline's keyer).\\n:::\\n\\n:::lesson{title=\\\"Panels of one AI-generated grid are not one subject: the model redraws each, so they can't be registered automatically\\\" kind=\\\"tools\\\" by=\\\"ai\\\"}\\n**I assumed:** the doneness slices could come from one generated picture of five panels (blue to well done), lined up by cross-correlation so the lens crossfades from one doneness to the next without a jump.\\n**What happened:** registration by normalised cross-correlation failed: the model had drawn a different steak in every panel, so there was no common image to align.\\n**The fix:** each square was placed by hand, centred on the cut face and sized so the crust, the cut face and the slate sit at the same height in all six. The lens crossfades without a jump.\\n**Evidence:** commit a4cc0ab (\\\"Each square is centred on the cut face … so the lens crossfades without a jump\\\").\\n:::\\n\\n## Step 9: keep the page around her at 60 fps\\n\\nMaple's frames are cheap to draw; the danger is the rest of the hero making Chrome repaint the page. Sweeping the pointer over the board for 10 s gives frame times of p50 16.7 ms, p95 17.0 ms and max 17.3 ms, with no React commits. Each of these was found by tracing a frame that wasn't.\\n\\nThe plates lift on hover. Their shadows only fade:\\n\\n::file{path=\\\"components/maple-hero/parts/Plate.tsx\\\" lines=\\\"19-36\\\"}\\n\\n:::lesson{title=\\\"Scaling a two-gradient shadow with motion on hover repaints it at every size: a 50 ms frame\\\" kind=\\\"tools\\\" by=\\\"ai\\\" packages=\\\"motion@14.4\\\"}\\n**I assumed:** scaling a plate's contact shadow (two radial gradients) up on hover, animated with motion, was a cheap transform.\\n**What happened:** the first hover took a 50 ms frame on an Intel Iris Plus: the scaled layer was painted again at its new size.\\n**The fix:** the contact shadow only fades (opacity 1 → 0.4); the wide, soft shadow that does scale is a single gradient, cheap to paint; and the photo itself only moves up, never scales.\\n**Evidence:** commit f41f0b8: frame times back to p95 17.0 ms, max 17.3 ms.\\n:::\\n\\n:::lesson{title=\\\"Chrome paints a layer at opacity 0 only when it first shows: rest hidden layers at 0.001\\\" kind=\\\"tools\\\" by=\\\"ai\\\"}\\n**I assumed:** a hover layer waiting at `opacity: 0` was already painted and would only need compositing when it faded in.\\n**What happened:** Chrome painted it at its first show, and two hidden layers painted together on the first hover, one more long frame.\\n**The fix:** hidden layers rest at opacity 0.001, invisible but painted ahead of time (`HIDDEN` in Plate.tsx).\\n**Evidence:** commit f41f0b8.\\n:::\\n\\nThe brass rail under the plates has a glint that sweeps along it every 11 seconds:\\n\\n::file{path=\\\"components/maple-hero/maple-hero.css\\\" lines=\\\"1175-1186\\\"}\\n\\n:::lesson{title=\\\"Chrome won't run CSS keyframes on the compositor when they move in container units or a percentage of the box\\\" kind=\\\"tools\\\" by=\\\"ai\\\"}\\n**I assumed:** a glint animated with `transform: translateX()` in its keyframes runs on the compositor whatever unit it moves in, so container units (`cqw`) were fine for a rail that scales with the hero.\\n**What happened:** with `cqw`, or a percentage of its own box, in the keyframes, Chrome restyled the page on every frame of the animation.\\n**The fix:** the keyframes move in px and vw only (`translateX(-240px)` to `translateX(calc(100vw + 20px))`), with the rail's `overflow: hidden` doing the clipping.\\n**Evidence:** commit 65c5d32 (\\\"the rail glint no longer uses container units in its keyframes\\\"); the comment above `.mh-rail-glint`.\\n:::\\n\\nThe \\\"Open now\\\" pill breathes:\\n\\n::file{path=\\\"components/maple-hero/maple-hero.css\\\" lines=\\\"471-479\\\"}\\n\\n:::lesson{title=\\\"A pulsing box-shadow in CSS keyframes repaints the whole page every frame\\\" kind=\\\"tools\\\" by=\\\"ai\\\"}\\n**I assumed:** a soft pulse round the status dot, animated as a growing `box-shadow`, was a small effect with a small cost.\\n**What happened:** every frame of the pulse repainted the page, not just the dot, for as long as the restaurant was open.\\n**The fix:** the pulse is a ring of its own (`::after`) that grows with `transform: scale()` and fades with `opacity`, which the compositor runs without painting. The atmosphere behind everything is its own layer (`will-change: transform; contain: strict`), painted once.\\n**Evidence:** the comment on `.mh-status[data-open] .mh-status-dot::after` in maple-hero.css.\\n:::\\n\\nEmbers rise from a plate when it is picked. They play when a counter prop changes:\\n\\n::file{path=\\\"components/maple-hero/parts/Embers.tsx\\\" lines=\\\"17-18\\\"}\\n\\n:::lesson{title=\\\"In React StrictMode an event emitted from a parent's effect can miss a newly mounted child: pass a counter prop\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\" packages=\\\"react@19.2\\\"}\\n**I assumed:** a picked plate's embers could start on a \\\"picked\\\" event sent from the parent's effect, with the ember canvas, mounted for the selected plate, listening for it.\\n**What happened:** in development with StrictMode, the newly mounted child was mounted again after the parent's effect had sent the event, so the listener that heard it was gone and no embers rose.\\n**The fix:** the pick is a prop: a counter the parent increments. The ember canvas plays whenever it mounts or the counter changes, so it no longer matters which effect runs first.\\n**Evidence:** `pick` in Embers.tsx and Plate.tsx (\\\"a new value makes embers rise from the selected plate\\\").\\n:::\\n\\n## Step 10: layout by width, rhythm by the hero's own height\\n\\nThe hero lays itself out by its own size, not the window's, since it may sit in a preview frame, a card or a page. The desktop layout starts at 1,180 px of hero width; tablet from 600 px; under that, phones. Height never switches the layout: a short desktop hero (most laptops, and 1519×629 at 125% scaling) keeps the desktop layout and tightens its rhythm in tiers under 880, 800 and 722 px of height. Maple is sized as 74% of the hero's height, never wider than 34% of its width. Two containers carry this: the hero queries its width, and the frame inside it is a size container for the height tiers.\\n\\n::file{path=\\\"components/maple-hero/maple-hero.css\\\" lines=\\\"2208-2215\\\"}\\n\\n:::lesson{title=\\\"A CSS size container cannot query itself: put the variables its container query sets on its children\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** `@container mh (max-height: 879px) { .mh-frame { --mh-counter: 83cqh } }` would change the variable on the container `.mh-frame` itself when the hero is short.\\n**What happened:** nothing changed: a container query matches only the container's descendants, never the container, so the rule never applied to `.mh-frame`.\\n**The fix:** the rules set their variables on `.mh-frame > *`, the container's children, which inherit them down the tree.\\n**Evidence:** commit 438744a (\\\"container rules now set their variables on the frame's children: a container cannot query itself\\\").\\n:::\\n\\n## Step 11: without JavaScript, with reduced motion, and without an inline script\\n\\nWithout JavaScript the hero still works: the neutral poster, the copy, and the plates as links to the menu. With reduced motion there is no intro, no scrubbing and no embers: the smiling poster stands, and her lines still show beside its head. The plates make an entrance only when scripts run and motion is allowed, and the CSS has to know that from the first paint, before any script has run:\\n\\n::file{path=\\\"components/maple-hero/maple-hero.css\\\" lines=\\\"1383-1401\\\"}\\n\\n:::lesson{title=\\\"CSS knows whether scripts will run from the first paint: @media (scripting) replaces the inline 'js' class script\\\" kind=\\\"my-mistake\\\" by=\\\"ai\\\"}\\n**I assumed:** to hide the plates before their entrance only when JavaScript would run, the hero needed an inline script that marks it as scripted before first paint, the old `class=\\\"js\\\"` trick.\\n**What happened:** UpFork's code-safety scan flagged the inline `<script>` as high risk: React doesn't run it, and its code can't be reviewed with the rest.\\n**The fix:** `@media (scripting: enabled) and (prefers-reduced-motion: no-preference)` holds the plates off-stage before their entrance, with an animation that reveals them after 3 s whatever happens; `@media (scripting: none)` keeps the doneness card out. Both apply from the first paint, with no script at all.\\n**Evidence:** commit 269adf5, checked in Chrome with JavaScript on, off, and with reduced motion.\\n:::\\n\\n## What it measured\\n\\nOn the Intel Iris Plus laptop, in Chrome:\\n\\n- **Frames:** desktop 4.2 MB in 12 atlases (all 80 axis frames, restored), mobile 1.6 MB in 7; posters 39 KB and 26 KB; 142 MB decoded on the GPU after the intro.\\n- **Smoothness:** p50 16.7 ms, p95 17.0 ms, max 17.3 ms while sweeping the board, with no React commits.\\n- **JavaScript:** 32.4 KB gzipped eager for the hero (React and Next excluded); motion's animation features (45.8 KB) and the frame engine (10.4 KB) load after first paint.\\n- **Fonts:** 289 KB for all four, cut to the weights and features the hero uses (348 KB before).\\n- **Loading:** LCP 1.6–2.0 s on desktop and about 1.1 s on a phone over fast 4G, with the poster as the LCP element; CLS under 0.001, with fallback fonts matched to the web fonts' metrics.\\n- **Accessibility:** axe finds 0 violations on every palette; text contrast is at least 4.5:1 everywhere.\\n\\n## Make it yours\\n\\nEverything Maple says and shows is in `hero-settings.ts`: the copy, the menu and its prices, the doneness table, her lines, the five palettes, the gaze calibration and every timing. Every brand fact of the invented restaurant, Copperhorn, is marked `// PLACEHOLDER — replace with real data`. The component takes four props: `palette` (one of the five, or `\\\"auto\\\"` to follow light and dark), `settings`, `units` (`\\\"F\\\"` or `\\\"C\\\"`) and `onReserve`, called with the picked cut and doneness by \\\"Book a table\\\" and \\\"Reserve this cut\\\".\\n\\nTo build a hero like this around your own character, the order that worked: film one video in which the motions you need meet end to end on the same frame; key it and measure it on its real frames; restore the source if it's soft; thin by motion; move it with one spring with a top acceleration; blend only exact neighbours; and trace every frame of the page around it, not just the character.\\n\\n::install\",\"bodySha256\":\"66e1a25930e40065090faff8a73b9752da0eb46ef420d7d3c90f6e857fa507b1\",\"kind\":\"behind-the-build\",\"product\":\"restaurant-hero-section-react\",\"productRevision\":1,\"publishedAt\":\"2026-10-26T08:14:31.193Z\",\"revision\":1,\"site\":\"https://upfork.dev\",\"summary\":\"How Maple turns a 720p AI video into a keyed, recolourable cow that follows the cursor at 60 fps: the frame pipeline, gaze mapping, one-spring motion, WebGL drawing, and what surprised us.\",\"title\":\"Build a cursor-following mascot hero from an AI video: Maple's frame engine and 19 lessons\",\"type\":\"article-version\",\"url\":\"https://upfork.dev/articles/build-a-cursor-following-mascot-hero-from-an-ai-video-maple-s-frame-engine-a\"}",
    "recordHash": "e1bfe5cafdf16fb4727abd7c27b7ec67eadbfd356ea2460f7b69087d37d41234",
    "prevHash": "6238fb6ae31dbc6e9708a350bac658c3b82b9bd59078b751b050ed15d76b5497",
    "entryHash": "973490bf5b3522f347e3b75d230d731ee4f498dec59c7cc9fc889d71f7174e17",
    "keyId": "k1",
    "signature": "78H5Z8FwgZ2byqF1e5AFdwYILcLsRW3pBMjzJq+EP3L2p7fkFMcCp2TQUmiqktKpmr4k+yqeYpOw5Y5MMl5pDw==",
    "recordedAt": "2026-10-26T08:15:08.798339+00:00",
    "publicKey": "VuHuHXAtO3XAqgnwvMenp0f/3vTV061hnAIizYvwh40=",
    "algorithm": "Ed25519"
  },
  "path": [],
  "checkpoint": null,
  "keys": "https://upfork.dev/ledger/keys.json"
}