{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"moire-scratch","type":"registry:ui","title":"Moire Scratch","description":"A promo code under two printed gratings, revealed by the beat between them rather than by an eraser mask. Both films are box-filtered at exactly one pitch, so the carriers integrate away and only the beat survives — and the gain is the beat over the pitch: eleven pixels of hand sweeps the bright band clear across the panel.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/moire-scratch.tsx","target":"components/ui/moire-scratch.tsx","content":"'use client';\n\nimport './moire-scratch.css';\n\nimport { type KeyboardEvent, useEffect, useId, useRef, useState } from 'react';\n\nimport { useCanvasScene, useReducedMotion, type SceneDrawContext, type SceneSetupContext } from '@/hooks/use-canvas-scene';\n\n/**\n * A promo code behind two real gratings, revealed by the beat between them.\n *\n * THE FILMS. Each is an amplitude grating of pitch p, transmittance\n *\n *   T(u) = (1 + cos(2*pi*u / p)) / 2\n *\n * measured along its own axis. The lower film is fixed, u1 = x. The upper one is\n * rotated by theta about the code and shifted along its own axis by s:\n *\n *   u2 = cx + (x - cx)*cos(theta) + (y - cy)*sin(theta) - s\n *\n * The visible mask is the product T1*T2, which is what light that has crossed both\n * films carries. Expanding the product, with phi = 2*pi*u/p,\n *\n *   T1*T2 = [ 1 + cos(phi1) + cos(phi2) + cos(phi1-phi2)/2 + cos(phi1+phi2)/2 ] / 4\n *\n * and the fourth term is the beat. phi1 - phi2 advances as 2*pi*x*(1/p1 - 1/p2), so its\n * period is\n *\n *   B = p1*p2 / |p1 - p2|\n *\n * about two hundred and forty times either pitch here. That is the bright band, and it\n * is painted nowhere: it is what is left over when two cosines are multiplied.\n *\n * NOT POINT-SAMPLED, AND NOT PIXEL-SAMPLED EITHER. A 1.9 px grating is under two device\n * pixels on a 1x display, past Nyquist, so a point sample of it folds: 0.526 cycles per\n * pixel against a Nyquist of 0.5 comes back as a 19 px ripple that beats with the pixel\n * grid instead of with the other film, drifts the wrong way when the film moves, and lands\n * on a different period on every display. Averaging over one pixel does not fix it. A box\n * is not a brick wall; it only takes the fold down to sinc(1/1.9), which is 0.6 of it.\n *\n * So what is painted is the average of the product over a square aperture of side d, and\n * that has a closed form. The box average of cos(k.r) over the square is the cosine times\n *\n *   sinc(kx*d / (2*pi)) * sinc(ky*d / (2*pi)),      sinc(z) = sin(pi*z) / (pi*z)\n *\n * one factor for each of the four terms above — four scalars per frame, since d and both\n * wavevectors are constant across a frame. This is the average of the product and not the\n * product of the averages: integrating each film alone and then multiplying leaves the beat\n * at sinc(d/p1)*sinc(d/p2) of its strength while inflating the carriers, which is how a\n * correct-looking anti-alias can throw away most of the contrast it was protecting.\n *\n * Taking d = p1 puts sinc's first zero exactly on the fixed film's own bars, so they\n * integrate to nothing; p2 is within half a percent of p1, so its bars go with them, and so\n * does the sum term at 1/p1 + 1/p2. The beat's own factor is sinc(1.9/465), which is one.\n * What is left on the panel is the beat and nothing else — which is what an eye does with\n * two gratings it cannot resolve, and the reason the fringe is identical on a 1x and a 2x\n * display instead of carrying a different false pattern on each.\n *\n * IT IS NOT two overlaid repeating-linear-gradients: the compositor point-samples those,\n * so at this pitch they alias exactly as above and visibly crawl the moment either one\n * moves. It is not a keyframed band sweeping a mask either — there is no band in this\n * file, no gradient stop that travels, and nothing that knows where the code is.\n *\n * THE ONE VISIBLE CONSEQUENCE is the gain. The fringe sits where phi1 - phi2 = 0, which\n * gives dx/ds = B/p2: shift the upper film by one pitch and the band moves a whole beat\n * period. The upper film is the finer of the two, which is what puts the sign there and\n * sends the band the same way as the hand. Geared down to a fifteenth for that hand, one\n * pixel of pointer travel still moves the reveal fifteen pixels, so the code does not fade\n * up — it arrives out of a dark panel, in a movement too small to aim. Drag up or down\n * instead and the beat vector k1 - k2 swings: a quarter of a degree of skew takes the\n * bands past 45 degrees, and four degrees lays them nearly flat — at which point the beat\n * period has shrunk from 465 px to under thirty, the word spans a whole period of it, and\n * no shift can light more than half the code at once.\n *\n * CHANGED FROM THE SPEC, AND WHY. The skew was asked to fan the bands into a rosette,\n * and two straight gratings cannot make one: the product of two plane waves is a single\n * plane wave, so these fringes are straight and parallel at every angle. A rosette needs\n * a third grating or a curved one, and either would cost the closed form above — the\n * beat period would no longer be p1*p2/|p1 - p2|, which is the equation this draft\n * exists to solve. What the same equation gives under rotation is bigger anyway. Because\n * 1/p1 - cos(theta)/p2 stays near zero while sin(theta)/p2 does not, the beat direction\n * atan2(-sin(theta)/p2, 1/p1 - cos(theta)/p2) leaves zero at about the same gain the\n * shift has. The bands swing through a right angle; they do not fan.\n */\n\n/**\n * Seconds per substep. Nothing here integrates a force, so 1/240 buys no stability;\n * 1/120 is the rate the travel to the solved shift advances at, so the button's sweep\n * crosses the panel at the same speed on a 60 Hz and a 144 Hz display.\n */\nconst STEP = 1 / 120;\n/** Substeps a frame may consume. A tab restored after a minute must not replay a minute. */\nconst MAX_SUB = 6;\n/**\n * Lower film pitch, CSS px. Under two on purpose: the bars are then past Nyquist on a 1x\n * display, which is what makes the aperture below load-bearing instead of decoration on a\n * pitch that could have been sampled safely. It also sets the angular gain, B/p, so a pitch\n * this fine is what puts a right angle of band swing inside half a degree of skew.\n */\nconst PITCH = 1.9;\n/**\n * Sampling aperture, in pitches. One, because sinc's first zero is at d = p: an aperture of\n * exactly one pitch annihilates both films' bars and passes the beat at sinc(1.9/465),\n * which is one. Under a pitch and the 19 px fold is still on the panel at 1x; far over one\n * and the aperture starts taking contrast out of the beat itself.\n */\nconst APERTURE = 1;\n/**\n * Beat period as a multiple of the panel width, which is what fixes the upper film's\n * pitch. Above one, so exactly one bright band is over the panel at a time and the code\n * is lit by a band rather than striped by four of them. At two the band never fully\n * leaves and the code is never properly hidden.\n */\nconst BEAT_SPAN = 1.25;\n/**\n * Fraction of the panel width the code is set to. The bright part of a beat is about four\n * tenths of its period, which at BEAT_SPAN 1.25 is half the panel — so at this width the\n * word just fits inside one band, and anything wider could never be lit end to end.\n */\nconst CODE_FILL = 0.52;\n/**\n * Where the code sits, as a fraction of the panel height. Above centre, to leave the\n * strip along the bottom for the selectable chip. It is also the rotation centre, which\n * is why the solved shift comes out the same at every skew.\n */\nconst CODE_Y = 0.44;\n/**\n * Px the bright band travels per px of pointer travel. The raw gain is B/p2, about two\n * hundred and forty, and at two hundred and forty a mouse cannot address the band at all\n * — consecutive samples land in unrelated places. Fifteen keeps the arrival sudden and\n * still leaves a window a hand can hold still inside.\n */\nconst FRINGE_GAIN = 15;\n/**\n * Slider steps in one full sweep of the band. At 120 one arrow key is about four px of\n * band travel, fine enough to walk into the fringe deliberately, and a page key crosses\n * the whole sweep in ten presses.\n */\nconst NOTCHES = 120;\nconst PAGE_STEP = 12;\n/**\n * Degrees of skew at full vertical deflection. The beat direction is atan(theta*B/p2), so\n * it passes 45 degrees at about a quarter of one degree; four is where the beat period,\n * which falls as B*cos(that direction), has shrunk to the extent of the code measured\n * across the bands — the word then spans a whole period and the reveal is genuinely out of\n * reach. Stopping short of that only looks dramatic: the bands end up lying along the word,\n * so it stays lit however far they have swung.\n */\nconst SKEW_MAX = 4;\n/**\n * Px of vertical drag for full skew. Most of the panel's height, so extinguishing the code\n * takes a deliberate pull; the few px a horizontal sweep wanders tilt the bands visibly and\n * leave the fringe reachable.\n */\nconst SKEW_TRAVEL = 110;\n/**\n * Degrees the skew button latches, for a keyboard with no vertical drag. The word still\n * fits inside one band there, so the toggle shows most of a right angle of swing without\n * putting the code out of reach on the way.\n */\nconst SKEW_LATCH = 0.3;\n/**\n * Full sweeps per second while the button travels to the solved shift. At 0.55 the band\n * crosses the panel in under a second: fast enough to read as an arrival, slow enough\n * that the gain is legible on the way there.\n */\nconst SLEW = 0.55;\n/**\n * Fractions of the reading's calibrated swing at which the code enters and leaves the\n * accessibility tree. Two values and not one, because at this gain the reading crosses any\n * single threshold several times during one slow sweep of the hand, and a live region that\n * announces the same code four times is worse than one that announces it late.\n */\nconst LIT_HI = 0.52;\nconst LIT_LO = 0.36;\n/**\n * Reading stride in device px. The reading is a weighted mean over some thousands of\n * samples, so every other pixel on each axis is four times cheaper and the same number\n * to three places.\n */\nconst SENSE_STRIDE = 2;\n/** Floor on the calibrated swing, so a panel too small to hold the code cannot divide by nothing. */\nconst SWING_FLOOR = 0.02;\n\n/**\n * Scale from squared transmittance to film alpha. Squared, because transmittance is not\n * brightness and a print's response to it is not linear — and because squaring turns the\n * beat's 3:1 swing in transmittance into 9:1 in what is painted, which is what makes a band\n * read as a band. Not cubed: at gamma three the bright part of a beat comes out narrower\n * than the code and the word can never be lit end to end. 4.4 puts the brightest band a\n * little over six tenths of the way to the film's white.\n */\nconst FILM_GAIN = 4.4;\n/**\n * The same curve taken twice over for the code, so the letters are amber only inside the\n * core of a band. At a single gamma they keep an eleven percent warm ghost in the dark,\n * which on a card whose whole job is to hide a code until it is earned is a spoiler.\n */\nconst INK_GAIN = 2.6;\n/**\n * How far the film is held back under the letters, so the code reads as ink lying in the\n * band rather than as a brighter patch of the same grey.\n */\nconst INK_CLEAR = 0.62;\n/**\n * The code's own type: weight, tracking as a fraction of the size, and the bounds the\n * fitted size is held between so even a narrow panel gets legible letters.\n */\nconst CODE_WEIGHT = 600;\nconst CODE_TRACK = 0.1;\nconst CODE_MIN = 13;\nconst CODE_MAX = 34;\nconst CODE_FONT = 'ui-monospace, \"SFMono-Regular\", Menlo, monospace';\n/** The reward. Invented, and shaped like a real one so the card is not obviously a demo. */\nconst PROMO = 'FRINGE40-K9Q2';\n/**\n * Plate, film and fringe as RGB triples: the paint loop mixes them per pixel, and a\n * colour string is re-parsed on every assignment where three numbers are not.\n */\nconst PLATE_RGB = [7, 10, 16] as const;\nconst FILM_RGB = [219, 231, 244] as const;\nconst ACCENT_RGB = [255, 207, 125] as const;\n\nconst DEG = Math.PI / 180;\n\ninterface MoireState {\n  /** Device-pixel size of the backing store, which is the resolution the mask is built at. */\n  readonly dw: number;\n  readonly dh: number;\n  readonly dpr: number;\n  readonly image: ImageData;\n  readonly data: Uint8ClampedArray;\n  /** Glyph coverage per device pixel, 0..1. The code, and the only weight the reading uses. */\n  readonly glyph: Float32Array;\n  /** Device-pixel bounds of the inked area, so the reading walks the word and not the panel. */\n  readonly inkX0: number;\n  readonly inkX1: number;\n  readonly inkY0: number;\n  readonly inkY1: number;\n  /**\n   * Per-column phasors, rebuilt each frame: cos and sin of the fixed film's phase, which\n   * depends on x alone, and of the moving film's along-x part. With these a row costs two\n   * trig calls for its own phase and a pixel costs none at all.\n   */\n  readonly lowCos: Float64Array;\n  readonly lowSin: Float64Array;\n  readonly topCos: Float64Array;\n  readonly topSin: Float64Array;\n  readonly pitchA: number;\n  readonly pitchB: number;\n  /** Beat period in CSS px, p1*p2/|p1 - p2|. The gain is this over p2. */\n  readonly beat: number;\n  readonly cx: number;\n  readonly cy: number;\n  /** The shift, in notches, that puts a fringe maximum on the code. Solved, not searched. */\n  readonly solved: number;\n  /**\n   * Midpoint and half-swing of the reading, taken at the two extremal shifts. Written\n   * once, at the end of the rebuild, because the reading they calibrate needs the rest of\n   * the state to exist before it can be taken at all.\n   */\n  centre: number;\n  swing: number;\n  /** Upper film shift in notches, wrapped into [0, NOTCHES). Written by the hand or the travel. */\n  phase: number;\n  /** Upper film rotation, radians. */\n  skew: number;\n  /** Where the travel is headed, in notches, or null when nothing is travelling. */\n  target: number | null;\n  /**\n   * Timestamp of the previous painted frame, and the unspent remainder of it. The hook\n   * hands `draw` no elapsed time, so the seconds come from here; zero means no frame has\n   * been painted yet and the first one is charged a single step rather than a wild delta.\n   */\n  clock: number;\n  carry: number;\n  /** Hold the films at the solved shift and skip the travel. Set under prefers-reduced-motion. */\n  snap: boolean;\n}\n\n/** sin(pi*z)/(pi*z), the box-average factor. One at z = 0, where the limit is removable. */\nfunction sinc(z: number): number {\n  if (z === 0) return 1;\n  const a = Math.PI * z;\n  return Math.sin(a) / a;\n}\n\n/**\n * Box average of cos(k.r) over a square aperture of side d, as a factor on the cosine. A\n * square is separable, so it is one sinc per axis; sinc is even, so the sign of either\n * component makes no difference.\n */\nfunction box(kxd: number, kyd: number): number {\n  return sinc(kxd / (2 * Math.PI)) * sinc(kyd / (2 * Math.PI));\n}\n\n/**\n * Wrap a shift into one pitch of the moving film. This is not a clamp being dodged: the\n * films are periodic, so a shift of one whole pitch is the identical configuration, and the\n * axis genuinely has no ends.\n */\nfunction wrap(phase: number): number {\n  const at = phase % NOTCHES;\n  return at < 0 ? at + NOTCHES : at;\n}\n\n/**\n * Everything about one frame's pair of films that does not vary across the panel: the two\n * wavevectors, the moving film's phase at the code, and one box factor per term of the\n * product. The factors live here because the aperture and both wavevectors are constant over\n * a frame, so the integral costs four calls rather than four per pixel.\n */\ninterface FilmPass {\n  /** Fixed film's phase gradient, radians per CSS px along x. */\n  readonly kLow: number;\n  /** Moving film's phase gradients along x and y, and its phase at the code's centre. */\n  readonly kTopX: number;\n  readonly kTopY: number;\n  readonly baseTop: number;\n  /** Box factors on the four terms: each film's own bars, the beat, and the sum. */\n  readonly aLow: number;\n  readonly aTop: number;\n  readonly aBeat: number;\n  readonly aSum: number;\n}\n\nfunction pass(state: MoireState, phase: number, skew: number): FilmPass {\n  const { pitchA, pitchB, cx } = state;\n  const kLow = (2 * Math.PI) / pitchA;\n  const kTop = (2 * Math.PI) / pitchB;\n  const kTopX = kTop * Math.cos(skew);\n  const kTopY = kTop * Math.sin(skew);\n  // Side of the aperture, in CSS px. A multiple of the pitch and not of the pixel, which is\n  // the one deliberate departure from integrating over the pixel itself: see APERTURE.\n  const d = APERTURE * pitchA;\n  const shift = (phase / NOTCHES) * pitchB;\n  return {\n    kLow,\n    kTopX,\n    kTopY,\n    baseTop: kTop * (cx - shift),\n    aLow: box(kLow * d, 0),\n    aTop: box(kTopX * d, kTopY * d),\n    aBeat: box((kLow - kTopX) * d, -kTopY * d),\n    aSum: box((kLow + kTopX) * d, kTopY * d),\n  };\n}\n\n/**\n * The mask at one pixel: the box-averaged product, from the two films' phasors there.\n *\n *   4*T1*T2 = 1 + cos(phi1) + cos(phi2) + [cos(phi1-phi2) + cos(phi1+phi2)] / 2\n *\n * with every term carrying its own factor. The difference and sum cosines come out of the\n * phasors by the angle-addition rule, so nothing in here calls a trig function — and nothing\n * in here knows which term is going to survive the aperture, which is why changing the pitch\n * or the aperture changes what the panel shows instead of breaking the derivation.\n */\nfunction maskOf(film: FilmPass, cLow: number, sLow: number, cTop: number, sTop: number): number {\n  const beat = cLow * cTop + sLow * sTop;\n  const sum = cLow * cTop - sLow * sTop;\n  return (\n    0.25 *\n    (1 + film.aLow * cLow + film.aTop * cTop + 0.5 * (film.aBeat * beat + film.aSum * sum))\n  );\n}\n\n/**\n * The reading: mean transmittance where the letters are, weighted by their own coverage.\n * This is the only thing that decides whether the code is legible, and it is a measurement\n * of the same mask that gets painted rather than a comparison against a remembered shift —\n * so it falls correctly when a skew shrinks the beat period below the extent of the word,\n * without anything having to know that it should.\n *\n * Sampled every other device pixel on each axis. It is a mean over several thousand samples,\n * so the stride is four times cheaper and the same number to three places.\n */\nfunction sense(state: MoireState, film: FilmPass): number {\n  const { glyph, dw, dpr, cx, cy } = state;\n  let sum = 0;\n  let weight = 0;\n  for (let py = state.inkY0; py <= state.inkY1; py += SENSE_STRIDE) {\n    const rowPhase = film.baseTop + film.kTopY * ((py + 0.5) / dpr - cy);\n    const row = py * dw;\n    for (let px = state.inkX0; px <= state.inkX1; px += SENSE_STRIDE) {\n      const ink = glyph[row + px];\n      if (ink <= 0) continue;\n      const x = (px + 0.5) / dpr;\n      const low = film.kLow * x;\n      const top = rowPhase + film.kTopX * (x - cx);\n      sum += ink * maskOf(film, Math.cos(low), Math.sin(low), Math.cos(top), Math.sin(top));\n      weight += ink;\n    }\n  }\n  return weight > 0 ? sum / weight : 0;\n}\n\n/** Coverage field for the code, plus the device-pixel box the ink actually occupies. */\ninterface Ink {\n  readonly glyph: Float32Array;\n  readonly x0: number;\n  readonly x1: number;\n  readonly y0: number;\n  readonly y1: number;\n}\n\n/** Width of the code at a given size, including the tracking between its characters. */\nfunction trackedWidth(paper: CanvasRenderingContext2D, size: number): number {\n  let total = 0;\n  for (let i = 0; i < PROMO.length; i += 1) {\n    total += paper.measureText(PROMO.charAt(i)).width + size * CODE_TRACK;\n  }\n  return total - size * CODE_TRACK;\n}\n\n/**\n * The code, rasterised once into a coverage field. Real text in the platform's own\n * monospace face, so no font file ships with this; drawn one character at a time so it\n * can carry the tracking a printed code has; and fitted by measuring the string rather\n * than by a size that happens to look right at one panel width.\n */\nfunction inkCode(box: {\n  width: number;\n  dw: number;\n  dh: number;\n  dpr: number;\n  cx: number;\n  cy: number;\n}): Ink {\n  const { width, dw, dh, dpr, cx, cy } = box;\n  const glyph = new Float32Array(dw * dh);\n  const sheet = document.createElement('canvas');\n  sheet.width = dw;\n  sheet.height = dh;\n  const paper = sheet.getContext('2d');\n  if (!paper) return { glyph, x0: 0, x1: 0, y0: 0, y1: 0 };\n\n  paper.setTransform(dpr, 0, 0, dpr, 0, 0);\n  paper.textBaseline = 'middle';\n  paper.fillStyle = '#ffffff';\n\n  const trial = 40;\n  paper.font = `${CODE_WEIGHT} ${trial}px ${CODE_FONT}`;\n  const measured = trackedWidth(paper, trial);\n  const size = Math.max(\n    CODE_MIN,\n    Math.min(CODE_MAX, (trial * width * CODE_FILL) / Math.max(1, measured)),\n  );\n  paper.font = `${CODE_WEIGHT} ${size}px ${CODE_FONT}`;\n\n  let pen = cx - trackedWidth(paper, size) / 2;\n  for (let i = 0; i < PROMO.length; i += 1) {\n    const ch = PROMO.charAt(i);\n    paper.fillText(ch, pen, cy);\n    pen += paper.measureText(ch).width + size * CODE_TRACK;\n  }\n\n  const bytes = paper.getImageData(0, 0, dw, dh).data;\n  let x0 = dw;\n  let x1 = -1;\n  let y0 = dh;\n  let y1 = -1;\n  for (let py = 0; py < dh; py += 1) {\n    const row = py * dw;\n    for (let px = 0; px < dw; px += 1) {\n      const alpha = bytes[(row + px) * 4 + 3];\n      if (alpha === 0) continue;\n      glyph[row + px] = alpha / 255;\n      if (px < x0) x0 = px;\n      if (px > x1) x1 = px;\n      if (py < y0) y0 = py;\n      if (py > y1) y1 = py;\n    }\n  }\n  // A panel too small for even CODE_MIN leaves nothing inked; the reading then has an\n  // empty box to walk and returns zero, which reads as \"not legible\" and is correct.\n  if (x1 < x0 || y1 < y0) return { glyph, x0: 0, x1: 0, y0: 0, y1: 0 };\n  return { glyph, x0, x1, y0, y1 };\n}\n\n/** The reading as a fraction of its swing: 1 at a fringe maximum on the code, -1 at a minimum. */\nfunction reading(state: MoireState, film: FilmPass): number {\n  return (sense(state, film) - state.centre) / state.swing;\n}\n\n/**\n * Derive the pair of films for this panel, then calibrate the reading.\n *\n * The upper pitch is not a second arbitrary number. The beat period B is chosen as a\n * multiple of the panel width, and p2 = p1*B / (B + p1) is the pitch that produces it —\n * the inverse of B = p1*p2/(p1 - p2). So the composition holds at every width: a phone\n * gets a band the same fraction of its panel as a desktop does, and the code stays the\n * same fraction of a band. It comes out just under p1, and that ordering is deliberate:\n * with the finer film on top the band travels the same way as the film, so a rightward\n * drag sends the fringe right. The other ordering is equally real moire and sends it the\n * other way, which reads as a bug rather than as physics.\n *\n * The solved shift is algebra, not a search. At the code's centre the rotation drops out\n * of u2, so the beat phase there is 2*pi*(cx/p1 - (cx - s)/p2) = 2*pi*(s/p2 - cx/B), and\n * the code sits in a fringe maximum whenever s/p2 - cx/B is a whole number. In notches,\n * where s = phase*p2/NOTCHES, that is phase = NOTCHES*cx/B, modulo NOTCHES — and being\n * independent of theta is why the button lands the band whatever the skew is doing.\n */\nfunction build({ context, width, height, dpr }: SceneSetupContext, snap: boolean): MoireState {\n  const dw = Math.max(1, Math.round(width * dpr));\n  const dh = Math.max(1, Math.round(height * dpr));\n  const cx = width / 2;\n  const cy = height * CODE_Y;\n\n  const pitchA = PITCH;\n  const beat = Math.max(width * BEAT_SPAN, pitchA * 4);\n  const pitchB = (pitchA * beat) / (beat + pitchA);\n\n  const image = context.createImageData(dw, dh);\n  const data = image.data;\n  // Opaque once, so the paint loop writes three bytes per pixel rather than four.\n  for (let i = 3; i < data.length; i += 4) data[i] = 255;\n\n  const ink = inkCode({ width, dw, dh, dpr, cx, cy });\n\n  const state: MoireState = {\n    dw,\n    dh,\n    dpr,\n    image,\n    data,\n    glyph: ink.glyph,\n    inkX0: ink.x0,\n    inkX1: ink.x1,\n    inkY0: ink.y0,\n    inkY1: ink.y1,\n    lowCos: new Float64Array(dw),\n    lowSin: new Float64Array(dw),\n    topCos: new Float64Array(dw),\n    topSin: new Float64Array(dw),\n    pitchA,\n    pitchB,\n    beat,\n    cx,\n    cy,\n    solved: wrap((NOTCHES * cx) / beat),\n    centre: 0.25,\n    swing: SWING_FLOOR,\n    phase: 0,\n    skew: 0,\n    target: null,\n    clock: 0,\n    carry: 0,\n    snap,\n  };\n\n  /*\n   * Calibrate the reading against itself. Its swing is not a number anyone can write down in\n   * advance: it is the beat's box factor times the mean of cos(phi1 - phi2) over whatever\n   * shape the fitted letters came out at whatever width this panel is, plus whatever the\n   * aperture left of the carriers. So take it by measurement — read at the solved shift and\n   * at half a sweep from it, which are the two extremes, and keep the midpoint as zero and\n   * the half-difference as the full swing. Every later reading is a fraction of that, which\n   * is what lets one pair of thresholds mean the same thing on a phone and on a desktop.\n   */\n  const bright = sense(state, pass(state, state.solved, 0));\n  const dark = sense(state, pass(state, state.solved + NOTCHES / 2, 0));\n  state.centre = (bright + dark) / 2;\n  state.swing = Math.max(SWING_FLOOR, (bright - dark) / 2);\n\n  /*\n   * The code starts in the darkest fringe there is: hidden, and exactly half a sweep from\n   * the answer. Under reduced motion it starts at the answer instead — the loop never runs\n   * there, and a reader who asked for no movement should not have to win a game of aim to\n   * get a discount code.\n   */\n  state.phase = snap ? state.solved : wrap(state.solved + NOTCHES / 2);\n\n  return state;\n}\n\n/**\n * One fixed step of the travel to the solved shift, taken only when the reveal button has\n * asked for one. Constant speed rather than an ease: the shift is a position on a periodic\n * axis with nothing to give it momentum, so a rate is the honest way to move it, and an\n * ease-out here would be a second animation sitting on top of the solver. The direction is\n * the shorter way round the sweep, because a wrapped axis offers two.\n */\nfunction step(state: MoireState): void {\n  const target = state.target;\n  if (target === null) return;\n  const rate = SLEW * NOTCHES * STEP;\n  let gap = wrap(target - state.phase);\n  if (gap > NOTCHES / 2) gap -= NOTCHES;\n  if (Math.abs(gap) <= rate) {\n    state.phase = target;\n    state.target = null;\n    return;\n  }\n  state.phase = wrap(state.phase + Math.sign(gap) * rate);\n}\n\n/** The accumulator. Real seconds in, whole steps out, and never more than MAX_SUB of them. */\nfunction advance(state: MoireState, seconds: number): void {\n  state.carry = Math.min(state.carry + seconds, STEP * MAX_SUB);\n  while (state.carry >= STEP) {\n    state.carry -= STEP;\n    step(state);\n  }\n}\n\n/**\n * The mask: the product of the two films, averaged over the aperture, painted.\n *\n * There is no trigonometry in the inner loop. Each film's phase is linear in x and y, so a\n * column can carry the phasor of its x part and a row costs two trig calls for its own y\n * phase — the phasor at a pixel is then the column's rotated by the row's, four multiplies\n * and two adds. That is what makes a full-resolution product affordable at 2x on a phone.\n */\nfunction render({ context, state }: SceneDrawContext<MoireState>): void {\n  const { dw, dh, dpr, data, glyph, lowCos, lowSin, topCos, topSin, cx, cy } = state;\n  const film = pass(state, state.phase, state.skew);\n\n  for (let px = 0; px < dw; px += 1) {\n    const x = (px + 0.5) / dpr;\n    const low = film.kLow * x;\n    lowCos[px] = Math.cos(low);\n    lowSin[px] = Math.sin(low);\n    const along = film.kTopX * (x - cx);\n    topCos[px] = Math.cos(along);\n    topSin[px] = Math.sin(along);\n  }\n\n  const plateR = PLATE_RGB[0];\n  const plateG = PLATE_RGB[1];\n  const plateB = PLATE_RGB[2];\n  const filmR = FILM_RGB[0];\n  const filmG = FILM_RGB[1];\n  const filmB = FILM_RGB[2];\n\n  let at = 0;\n  for (let py = 0; py < dh; py += 1) {\n    const rowPhase = film.baseTop + film.kTopY * ((py + 0.5) / dpr - cy);\n    const rowCos = Math.cos(rowPhase);\n    const rowSin = Math.sin(rowPhase);\n    const row = py * dw;\n    for (let px = 0; px < dw; px += 1) {\n      const cTop = topCos[px] * rowCos - topSin[px] * rowSin;\n      const sTop = topSin[px] * rowCos + topCos[px] * rowSin;\n      const mask = maskOf(film, lowCos[px], lowSin[px], cTop, sTop);\n      // Squared and scaled: what a print does with transmittance, and what turns the beat's\n      // three-to-one swing into the nine-to-one a band needs to read as a band.\n      const shade = Math.min(1, mask * mask * FILM_GAIN);\n      // The letters are holes in the film rather than paint on top of it: where there is ink\n      // the film gives back less of its own grey, so the code lies in the band instead of\n      // over it — and stays darker than the film everywhere the band is not.\n      const cover = glyph[row + px];\n      const veil = shade * (1 - cover * INK_CLEAR);\n      let r = plateR + (filmR - plateR) * veil;\n      let g = plateG + (filmG - plateG) * veil;\n      let b = plateB + (filmB - plateB) * veil;\n      if (cover > 0) {\n        // The one accent, and it exists only where ink and the core of a bright fringe\n        // coincide. In a dark band there is nothing here for it to multiply.\n        const glow = cover * Math.min(1, shade * shade * INK_GAIN);\n        r += (ACCENT_RGB[0] - r) * glow;\n        g += (ACCENT_RGB[1] - g) * glow;\n        b += (ACCENT_RGB[2] - b) * glow;\n      }\n      data[at] = r;\n      data[at + 1] = g;\n      data[at + 2] = b;\n      at += 4;\n    }\n  }\n\n  /*\n   * putImageData ignores the transform the hook installs, which is what is wanted here: it\n   * writes the backing store one device pixel at a time, at the resolution the mask was\n   * evaluated at. Paint the same field through a scaled path instead and the browser would\n   * resample it on the way to the screen — a second sampling, uncontrolled, of exactly the\n   * kind the aperture above exists to get right.\n   */\n  context.putImageData(state.image, 0, 0);\n}\n\n/**\n * The card. The film layer takes the pointer capture and holds nothing but the canvas, so\n * every real control is a sibling below the panel, where its own click and its own focus\n * ring still land. Whether the code exists in the accessibility tree is read off the mask\n * rather than off a flag: the calibrated reading over the letters, with a wide hysteresis\n * band so a bar crossing the stems cannot make it flap.\n */\nexport type MoireScratchProps = { compact?: boolean };\n\n/**\n * `compact` is the 298x240 catalogue card: the copy, the rail and the skew button are dropped and\n * the panel keeps the box. No constant changes for it — `BEAT_SPAN` fixes the beat period as a\n * multiple of the panel width, so a card pane is given its own pitch pair and shows the same\n * fraction of a period the stage does. `Reveal the code` stays, since one press is enough to slew\n * the band onto the word; both buttons keep their pointer and leave the tab order, the card frame\n * being `aria-hidden` with its own title link to the item.\n */\nexport function MoireScratch({ compact = false }: MoireScratchProps) {\n  const reduced = useReducedMotion();\n  const uid = useId();\n  const railId = `${uid}-rail`;\n\n  const [notch, setNotch] = useState(0);\n  const [skewed, setSkewed] = useState(false);\n  const [lit, setLit] = useState(false);\n  /** Bumped by every control, so one effect can ask a frozen loop for its frame. */\n  const [nudges, setNudges] = useState(0);\n\n  /*\n   * The scene runs in a loop React does not drive, so it owns the shift and the skew and\n   * these refs are the channel both ways. A control leaves a request here; the next painted\n   * frame takes it, applies it to the solver, and writes back what the DOM has to show.\n   * Nothing inside `draw` reads React state directly.\n   */\n  const wantPhase = useRef<number | null>(null);\n  const wantSkew = useRef<number | null>(null);\n  const wantSolved = useRef(false);\n  const notchRef = useRef(0);\n  const litRef = useRef(false);\n  const snapRef = useRef(reduced);\n  snapRef.current = reduced;\n  /** Shift as an offset from the solved one, plus the skew, carried across a resize rebuild. */\n  const heldRef = useRef<{ offset: number; skew: number } | null>(null);\n\n  const { stageRef, canvasRef, requestRender } = useCanvasScene<MoireState>({\n    setup: (context) => {\n      const state = build(context, snapRef.current);\n      // A resize rebuilds both films at the new width, so the shift is restored relative to\n      // the solved one rather than absolutely: the band stays where the hand left it\n      // instead of jumping because the beat period changed under it.\n      const held = heldRef.current;\n      if (held) {\n        state.phase = wrap(state.solved + held.offset);\n        state.skew = held.skew;\n      }\n      return state;\n    },\n    draw: (frame) => {\n      const { state, pointer } = frame;\n      state.snap = snapRef.current;\n\n      const asked = wantPhase.current;\n      if (asked !== null) {\n        wantPhase.current = null;\n        state.phase = wrap(asked);\n        state.target = null;\n      }\n      const tilt = wantSkew.current;\n      if (tilt !== null) {\n        wantSkew.current = null;\n        state.skew = tilt;\n      }\n      if (wantSolved.current) {\n        wantSolved.current = false;\n        state.skew = 0;\n        if (state.snap) state.phase = state.solved;\n        else state.target = state.solved;\n      }\n\n      /*\n       * The hand, read as travel since the last painted frame rather than as a position in\n       * the panel: a press must not throw the films to wherever the pointer landed, and a\n       * sweep that leaves the panel and comes back must not be charged for the gap.\n       */\n      if (pointer.down) {\n        const dx = pointer.x - pointer.lastX;\n        const dy = pointer.y - pointer.lastY;\n        if (dx !== 0) {\n          state.phase = wrap(state.phase + (dx * FRINGE_GAIN * NOTCHES) / state.beat);\n          state.target = null;\n        }\n        if (dy !== 0) {\n          const span = SKEW_MAX * DEG;\n          const next = state.skew + (dy * span) / SKEW_TRAVEL;\n          state.skew = Math.max(-span, Math.min(span, next));\n        }\n      }\n\n      // Real seconds, from the previous frame's timestamp. Capped, because a tab that was\n      // backgrounded hands back a delta of minutes and the accumulator would spend the\n      // whole travel in one frame.\n      const now = performance.now();\n      const seconds = state.clock > 0 ? Math.min(0.25, (now - state.clock) / 1000) : STEP;\n      state.clock = now;\n      if (!state.snap) advance(state, seconds);\n\n      render(frame);\n\n      heldRef.current = { offset: wrap(state.phase - state.solved), skew: state.skew };\n\n      const shown = Math.round(state.phase) % NOTCHES;\n      if (shown !== notchRef.current) {\n        notchRef.current = shown;\n        setNotch(shown);\n      }\n\n      // The same measurement the calibration was taken with, against the same swing.\n      const level = reading(state, pass(state, state.phase, state.skew));\n      const legible = litRef.current ? level > LIT_LO : level > LIT_HI;\n      if (legible !== litRef.current) {\n        litRef.current = legible;\n        setLit(legible);\n      }\n    },\n  });\n\n  /** Point the films at a shift a control chose. Absolute, wrapped, and it cancels a travel. */\n  const aim = (to: number) => {\n    const at = wrap(to);\n    wantPhase.current = at;\n    const shown = Math.round(at) % NOTCHES;\n    if (shown !== notchRef.current) {\n      notchRef.current = shown;\n      setNotch(shown);\n    }\n    setNudges((count) => count + 1);\n  };\n\n  /** Where along the rail a press landed, in notches. */\n  const railAt = (rail: HTMLElement, clientX: number) => {\n    const rect = rail.getBoundingClientRect();\n    if (rect.width <= 0) return;\n    aim(Math.round(((clientX - rect.left) / rect.width) * NOTCHES));\n  };\n\n  const onRailKey = (event: KeyboardEvent<HTMLDivElement>) => {\n    const key = event.key;\n    let to: number | null = null;\n    if (key === 'ArrowRight' || key === 'ArrowUp') to = notchRef.current + 1;\n    else if (key === 'ArrowLeft' || key === 'ArrowDown') to = notchRef.current - 1;\n    else if (key === 'PageUp') to = notchRef.current + PAGE_STEP;\n    else if (key === 'PageDown') to = notchRef.current - PAGE_STEP;\n    else if (key === 'Home') to = 0;\n    else if (key === 'End') to = NOTCHES - 1;\n    if (to === null) return;\n    // Arrows scroll the page and Home jumps it; here the rail is the thing being addressed.\n    event.preventDefault();\n    aim(to);\n  };\n\n  const onReveal = () => {\n    wantSolved.current = true;\n    setSkewed(false);\n    setNudges((count) => count + 1);\n  };\n\n  const onSkew = () => {\n    const next = !skewed;\n    setSkewed(next);\n    wantSkew.current = next ? SKEW_LATCH * DEG : 0;\n    setNudges((count) => count + 1);\n  };\n\n  /*\n   * The one path from React back into the scene. Under reduced motion there is no loop to\n   * pick a request up, so a control's frame is asked for here rather than from inside the\n   * handler that made the request — and `reduced` is in the list because the hook tears the\n   * scene down and rebuilds it when that flips.\n   */\n  useEffect(() => {\n    requestRender();\n  }, [nudges, reduced, requestRender]);\n\n  return (\n    <div className=\"moire-scratch-stage\" data-compact={compact ? 'true' : undefined}>\n      <div className=\"moire-scratch-card\">\n        <p className=\"moire-scratch-kicker\">Autumn drop</p>\n        <h3 className=\"moire-scratch-title\">Your code is under the film</h3>\n        <p className=\"moire-scratch-lead\">\n          One code per customer, good on everything in the drop until the thirtieth. Slide the\n          film across the panel until the code catches the light.\n        </p>\n\n        <div className=\"moire-scratch-pane\">\n          <div className=\"moire-scratch-film\" ref={stageRef} aria-hidden=\"true\">\n            <canvas ref={canvasRef} />\n          </div>\n          <div className=\"moire-scratch-veil\">\n            {lit ? <code className=\"moire-scratch-code\">{PROMO}</code> : null}\n          </div>\n        </div>\n\n        <p className=\"moire-scratch-status\" role=\"status\">\n          {lit\n            ? `Promo code in the fringe: ${PROMO}`\n            : 'The code is still dark. Slide the film across, or reveal it outright.'}\n        </p>\n\n        <div className=\"moire-scratch-rail\">\n          <span className=\"moire-scratch-rail-label\" id={railId}>\n            Film position\n          </span>\n          <div\n            className=\"moire-scratch-slider\"\n            role=\"slider\"\n            tabIndex={compact ? -1 : 0}\n            aria-labelledby={railId}\n            aria-valuemin={0}\n            aria-valuemax={NOTCHES - 1}\n            aria-valuenow={notch}\n            aria-valuetext={`Notch ${notch} of ${NOTCHES}`}\n            onKeyDown={onRailKey}\n            onPointerDown={(event) => {\n              event.currentTarget.setPointerCapture(event.pointerId);\n              railAt(event.currentTarget, event.clientX);\n            }}\n            onPointerMove={(event) => {\n              if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;\n              railAt(event.currentTarget, event.clientX);\n            }}\n          >\n            <span className=\"moire-scratch-track\" />\n            <span\n              className=\"moire-scratch-thumb\"\n              style={{ left: `${(notch / NOTCHES) * 100}%` }}\n            />\n          </div>\n        </div>\n\n        <div className=\"moire-scratch-foot\">\n          <button\n            type=\"button\"\n            className=\"moire-scratch-button\"\n            tabIndex={compact ? -1 : undefined}\n            onClick={onReveal}\n          >\n            Reveal the code\n          </button>\n          <button\n            type=\"button\"\n            className=\"moire-scratch-button moire-scratch-skew\"\n            aria-pressed={skewed}\n            tabIndex={compact ? -1 : undefined}\n            onClick={onSkew}\n          >\n            Skew the top film\n          </button>\n        </div>\n      </div>\n\n      <p className=\"moire-scratch-hint\">Drag across &middot; up or down to skew</p>\n    </div>\n  );\n}\n\nexport default MoireScratch;\n\n\n\n\n\n","type":"registry:ui"},{"path":"components/ui/moire-scratch.css","target":"components/ui/moire-scratch.css","content":"/*\n * The room the card sits in. Near-black plate, one near-white for the film, one warm\n * accent that appears only inside the bright fringe: two colours and a tint, so the\n * card belongs to the same set as the rest of the catalogue.\n */\n.moire-scratch-stage {\n  position: relative;\n  display: grid;\n  place-content: center;\n  width: 100%;\n  min-height: 33rem;\n  padding: 2.25rem 1.5rem;\n  overflow: hidden;\n  border-radius: 0.75rem;\n  background: radial-gradient(125% 115% at 50% 0%, #0b1118 0%, #070a10 58%, #04060a 100%);\n  color: #e8eef6;\n}\n\n/* `min()` rather than a media query for the width: at 390px the card is the viewport\n   less the stage padding, so nothing in here can overflow sideways. */\n.moire-scratch-card {\n  width: min(26rem, 100%);\n  padding: 1.375rem;\n  border: 1px solid rgba(255, 255, 255, 0.09);\n  border-radius: 1rem;\n  background: linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012));\n}\n\n.moire-scratch-kicker {\n  margin: 0 0 0.4375rem;\n  font: 500 0.6875rem/1 ui-monospace, 'SFMono-Regular', Menlo, monospace;\n  letter-spacing: 0.15em;\n  text-transform: uppercase;\n  color: rgba(232, 238, 246, 0.44);\n}\n\n.moire-scratch-title {\n  margin: 0 0 0.5rem;\n  font-size: 1.25rem;\n  font-weight: 500;\n  line-height: 1.2;\n  letter-spacing: -0.025em;\n  color: #f3f8ff;\n}\n\n.moire-scratch-lead {\n  margin: 0;\n  font-size: 0.8125rem;\n  line-height: 1.5;\n  color: rgba(232, 238, 246, 0.6);\n}\n\n/*\n * The panel the two films are laid over. Its height is its own, because the only\n * children are absolutely placed: the film layer that owns the pointer, and the layer\n * the revealed code sits on. `overflow: hidden` is what rounds the corners of the\n * fringe — the solver knows nothing about the border radius.\n */\n.moire-scratch-pane {\n  position: relative;\n  min-height: 11rem;\n  margin: 1rem 0 0.875rem;\n  overflow: hidden;\n  border: 1px solid rgba(255, 255, 255, 0.08);\n  border-radius: 0.625rem;\n  background: #070a10;\n  isolation: isolate;\n}\n\n/*\n * The film layer, and the only thing in it is the canvas. This element takes the\n * pointer capture, so every real control is a sibling outside it — both buttons and the\n * slider live below the panel, where a click and a focus ring of their own still land.\n *\n * `pan-y` rather than `none`: the reveal is driven by sideways travel, so a vertical\n * touch can be handed back to the page instead of trapping it inside a card.\n */\n.moire-scratch-film {\n  position: absolute;\n  z-index: 0;\n  inset: 0;\n  cursor: ew-resize;\n  touch-action: pan-y;\n}\n\n.moire-scratch-film canvas {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n\n/* The sibling layer over the film. Deaf to the pointer as a whole, so a drag that\n   crosses it still slides the film; the code inside takes its events back. */\n.moire-scratch-veil {\n  position: absolute;\n  z-index: 1;\n  inset: auto 0 0 0;\n  display: flex;\n  justify-content: flex-end;\n  padding: 0.625rem 0.6875rem;\n  pointer-events: none;\n}\n\n/*\n * The code as real text, once the fringe has actually lit it. A code you cannot select\n * is useless, so this is a `<code>` a reader can drag over and copy — not a picture of\n * one. It is the only element on the card that carries the accent as a fill.\n */\n.moire-scratch-code {\n  padding: 0.3125rem 0.5625rem;\n  border: 1px solid rgba(255, 207, 125, 0.42);\n  border-radius: 0.375rem;\n  background: rgba(12, 9, 5, 0.82);\n  font: 600 0.8125rem/1 ui-monospace, 'SFMono-Regular', Menlo, monospace;\n  letter-spacing: 0.11em;\n  color: #ffcf7d;\n  user-select: all;\n  pointer-events: auto;\n}\n\n/*\n * What a screen reader is given instead of the fringe. The panel is `aria-hidden`, so\n * this line carries the state, and it is polite rather than assertive because the\n * reading flips as the hand moves and an assertive region would interrupt every sweep.\n * Hidden the standard way — clipped to nothing but still rendered, so it is announced\n * rather than skipped the way `display: none` would be.\n */\n.moire-scratch-status {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  margin: -1px;\n  padding: 0;\n  overflow: hidden;\n  border: 0;\n  clip-path: inset(50%);\n  white-space: nowrap;\n}\n\n.moire-scratch-rail {\n  display: grid;\n  gap: 0.375rem;\n  margin: 0 0 0.875rem;\n}\n\n.moire-scratch-rail-label {\n  font: 500 0.6875rem/1 ui-monospace, 'SFMono-Regular', Menlo, monospace;\n  letter-spacing: 0.12em;\n  text-transform: uppercase;\n  color: rgba(232, 238, 246, 0.4);\n}\n\n/*\n * The keyboard path onto the same axis the drag drives, geared down so it is landable:\n * the film shift a whole sweep needs is under two pixels, and no arrow key can address\n * two pixels. Its own element rather than an `<input type=\"range\">` because the value it\n * carries wraps — the film is periodic, so the last step is adjacent to the first — and\n * a native range clamps at both ends instead.\n */\n.moire-scratch-slider {\n  position: relative;\n  height: 1.5rem;\n  cursor: pointer;\n  touch-action: none;\n}\n\n.moire-scratch-track {\n  position: absolute;\n  inset: 50% 0 auto;\n  height: 2px;\n  transform: translateY(-50%);\n  border-radius: 999px;\n  background: rgba(232, 238, 246, 0.12);\n}\n\n.moire-scratch-thumb {\n  position: absolute;\n  top: 50%;\n  width: 0.75rem;\n  height: 0.75rem;\n  border-radius: 999px;\n  background: rgba(232, 238, 246, 0.72);\n  transform: translate(-50%, -50%);\n}\n\n.moire-scratch-slider:hover .moire-scratch-thumb {\n  background: #f3f8ff;\n}\n\n.moire-scratch-slider:focus-visible {\n  outline: 2px solid rgba(255, 207, 125, 0.75);\n  outline-offset: 3px;\n  border-radius: 0.25rem;\n}\n\n.moire-scratch-foot {\n  display: flex;\n  flex-wrap: wrap;\n  align-items: center;\n  gap: 0.5rem;\n}\n\n.moire-scratch-button {\n  appearance: none;\n  margin: 0;\n  padding: 0.4375rem 0.875rem;\n  border: 1px solid rgba(232, 238, 246, 0.16);\n  border-radius: 999px;\n  background: rgba(7, 10, 16, 0.6);\n  font: inherit;\n  font-size: 0.75rem;\n  font-weight: 500;\n  color: rgba(232, 238, 246, 0.78);\n  white-space: nowrap;\n  cursor: pointer;\n  transition:\n    border-color 160ms ease,\n    background-color 160ms ease,\n    color 160ms ease;\n}\n\n.moire-scratch-button:hover {\n  border-color: rgba(255, 207, 125, 0.45);\n  color: #f3f8ff;\n}\n\n/* The tint: the one accent, held under a fifth of an alpha. */\n.moire-scratch-button[aria-pressed='true'] {\n  border-color: rgba(255, 207, 125, 0.55);\n  background: rgba(255, 207, 125, 0.14);\n  color: #fff6e6;\n}\n\n.moire-scratch-button:focus-visible {\n  outline: 2px solid rgba(255, 207, 125, 0.75);\n  outline-offset: 2px;\n}\n\n.moire-scratch-hint {\n  position: absolute;\n  right: 0.875rem;\n  bottom: 0.75rem;\n  margin: 0;\n  font: 500 0.6875rem/1 ui-monospace, 'SFMono-Regular', Menlo, monospace;\n  letter-spacing: 0.08em;\n  text-transform: uppercase;\n  color: rgba(232, 238, 246, 0.26);\n  pointer-events: none;\n}\n\n/*\n * On a phone the title and the lead both take another line, so the stage grows rather\n * than the panel being squeezed. The films are re-derived at the new width — the beat\n * period is a multiple of the panel, not a fixed number of pixels — so the fringe still\n * arrives one band at a time and the code still fits inside one.\n */\n@media (max-width: 26rem) {\n  .moire-scratch-stage {\n    min-height: 35rem;\n    padding: 1.75rem 1rem;\n  }\n\n  .moire-scratch-card {\n    padding: 1.125rem;\n  }\n\n  .moire-scratch-title {\n    font-size: 1.125rem;\n  }\n\n  .moire-scratch-foot {\n    flex-direction: column;\n    align-items: stretch;\n  }\n\n  .moire-scratch-hint {\n    display: none;\n  }\n}\n\n/*\n * What reduced motion switches off is the sweep, not the panel. The loop never starts,\n * so nothing moves of its own accord, and the first painted frame is the film pair\n * already at the shift that puts the bright band on the code — the answer the solver\n * would have slewed to, held from the start, so the code is readable without a game of\n * precision. Dragging still works, because the hook repaints once per pointer move and\n * the mask is recomputed from scratch each time; what is gone is the constant-speed\n * travel the button would otherwise show. The rules below are the two colour fades.\n */\n@media (prefers-reduced-motion: reduce) {\n  .moire-scratch-button {\n    transition: none;\n  }\n\n  .moire-scratch-slider:hover .moire-scratch-thumb {\n    background: rgba(232, 238, 246, 0.72);\n  }\n}\n\n/*\n * The card variant: the 298x240 catalogue frame, at that real size and never scaled. Nothing has\n * to be re-tuned for it, because `BEAT_SPAN` sets the beat period as a multiple of the panel\n * rather than in pixels — a card pane gets its own pitch pair, the same fraction of a period is\n * on screen, and the code still lands inside one band.\n *\n * The copy and the rail go, and so does the skew button: at this width the two labels wrap onto\n * two rows and cost the panel more height than the second control is worth. `Reveal the code` is\n * the one that stays, because it gives the card a payoff in one press — the band slews onto the\n * word and the `<code>` chip appears — and the film itself still takes a drag. Both buttons leave\n * the tab order under `compact`, the card frame being `aria-hidden` with its own title link.\n *\n * Beats the `max-width: 26rem` block above on specificity rather than order, so a 390px catalogue\n * does not hand this card a 35rem stage.\n */\n.moire-scratch-stage[data-compact='true'] {\n  min-height: 0;\n  height: 100%;\n  /* `place-content: center` leaves the single track sized from the card's own content, so with\n     the copy and the rail hidden the panel would be as wide as the one remaining button and the\n     film would be solved for that box. Stretched, the track is the card frame. */\n  place-content: stretch;\n  padding: 0.625rem;\n  /* The card frame rounds and clips already. */\n  border-radius: 0;\n}\n\n.moire-scratch-stage[data-compact='true'] .moire-scratch-card {\n  width: 100%;\n  padding: 0.6875rem;\n  border-radius: 0.75rem;\n}\n\n.moire-scratch-stage[data-compact='true'] .moire-scratch-kicker,\n.moire-scratch-stage[data-compact='true'] .moire-scratch-title,\n.moire-scratch-stage[data-compact='true'] .moire-scratch-lead,\n.moire-scratch-stage[data-compact='true'] .moire-scratch-rail,\n.moire-scratch-stage[data-compact='true'] .moire-scratch-skew,\n.moire-scratch-stage[data-compact='true'] .moire-scratch-hint {\n  display: none;\n}\n\n.moire-scratch-stage[data-compact='true'] .moire-scratch-pane {\n  min-height: 9.5rem;\n  margin: 0 0 0.625rem;\n}\n\n.moire-scratch-stage[data-compact='true'] .moire-scratch-veil {\n  padding: 0.5rem 0.5625rem;\n}\n\n/* One button left, so the column the phone rules would stack it into is not wanted here. */\n.moire-scratch-stage[data-compact='true'] .moire-scratch-foot {\n  align-items: center;\n  flex-direction: row;\n}\n\n\n\n","type":"registry:file"},{"path":"hooks/use-canvas-scene.ts","target":"hooks/use-canvas-scene.ts","content":"\"use client\"\n\nimport { useCallback, useEffect, useRef, useState } from \"react\"\n\n/**\n * The canvas preamble every 2D scene needs, in one place: a DPR-scaled backing\n * store, a rebuild on resize, a loop that stops when the stage scrolls out of\n * view, pointer tracking with per-frame deltas, and teardown.\n *\n * A scene supplies two functions. `setup` builds whatever mutable state the\n * animation owns and is re-run whenever the stage changes size, so the state can\n * be sized to the stage without ever being resized in place. `draw` paints one\n * frame from that state — it is called with the transform already scaled to\n * device pixels, so every coordinate in it is a CSS pixel.\n */\n\nexport type ScenePointer = {\n  x: number\n  y: number\n  /** Position at the previous painted frame, so `x - lastX` is a frame delta. */\n  lastX: number\n  lastY: number\n  down: boolean\n  inside: boolean\n}\n\nexport type SceneSetupContext = {\n  context: CanvasRenderingContext2D\n  width: number\n  height: number\n  dpr: number\n}\n\nexport type SceneDrawContext<State> = SceneSetupContext & {\n  state: State\n  pointer: ScenePointer\n  /** Painted frames since the last rebuild. Useful for every-Nth-frame work. */\n  frame: number\n}\n\nexport type CanvasSceneOptions<State> = {\n  setup: (context: SceneSetupContext) => State\n  draw: (context: SceneDrawContext<State>) => void\n}\n\nexport type CanvasScene = {\n  /** The sizing element. Owns the pointer listeners and is what is observed. */\n  stageRef: (node: HTMLDivElement | null) => void\n  canvasRef: (node: HTMLCanvasElement | null) => void\n  /** Paint one frame now. The escape hatch for a paused or reduced-motion loop. */\n  requestRender: () => void\n}\n\n/** Live `prefers-reduced-motion`. False during SSR and the first paint. */\nexport function useReducedMotion() {\n  const [reduced, setReduced] = useState(false)\n\n  useEffect(() => {\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    setReduced(query.matches)\n    const onChange = () => setReduced(query.matches)\n    query.addEventListener(\"change\", onChange)\n    return () => query.removeEventListener(\"change\", onChange)\n  }, [])\n\n  return reduced\n}\n\nexport function useCanvasScene<State>(options: CanvasSceneOptions<State>): CanvasScene {\n  const reduced = useReducedMotion()\n\n  /*\n   * `draw` is usually an inline closure, so it is a new function on every\n   * render. Reading it through a ref keeps the loop from being torn down and\n   * the scene from being rebuilt each time the component re-renders.\n   */\n  const optionsRef = useRef(options)\n  optionsRef.current = options\n\n  const stage = useRef<HTMLDivElement | null>(null)\n  const canvas = useRef<HTMLCanvasElement | null>(null)\n\n  /*\n   * Plain ref assignment, with no state behind it. React attaches refs during\n   * the commit phase, before passive effects run, so the effect below already\n   * sees both nodes on the first mount — which is why these used to bump a\n   * `mounted` counter for nothing: the two `setMounted` calls batched into one\n   * re-render, the counter went 0 → 2, and the effect's dependency on it tore\n   * the live scene down and rebuilt it. Every scene was constructed, measured\n   * and warmed twice on every mount, four times under StrictMode in dev.\n   *\n   * The requirement this trades for that: a consumer must render the stage and\n   * the canvas unconditionally, in the same commit as the component itself. All\n   * thirteen do. Gating the canvas behind a flag would leave the effect bailing\n   * on the null guard with nothing to re-run it.\n   */\n  const stageRef = useCallback((node: HTMLDivElement | null) => {\n    stage.current = node\n  }, [])\n  const canvasRef = useCallback((node: HTMLCanvasElement | null) => {\n    canvas.current = node\n  }, [])\n\n  /** Set once the scene is live, so `requestRender` before that is a no-op. */\n  const render = useRef<(() => void) | null>(null)\n  const requestRender = useCallback(() => render.current?.(), [])\n\n  useEffect(() => {\n    const stageNode = stage.current\n    const canvasNode = canvas.current\n    if (!stageNode || !canvasNode) return\n\n    const context = canvasNode.getContext(\"2d\")\n    if (!context) return\n\n    const pointer: ScenePointer = {\n      x: 0,\n      y: 0,\n      lastX: 0,\n      lastY: 0,\n      down: false,\n      inside: false,\n    }\n\n    let state: State | null = null\n    let width = 0\n    let height = 0\n    let dpr = 1\n    let frame = 0\n    let loop = 0\n    let pending = 0\n    let visible = true\n\n    /** Rebuild the backing store and the scene state for the current size. */\n    const measure = () => {\n      // `offsetWidth`/`offsetHeight`, not `getBoundingClientRect()`: the rect is\n      // post-transform, so a scene sitting inside a scaled ancestor measured its\n      // own frame at the scaled size, sized the backing store to that, and then\n      // had CSS scale the result a second time — the scene ran at a fraction of\n      // the box it was drawn into. The catalogue's scaled-poster branch is the\n      // one place that happens, and it is reachable again the moment an\n      // animation is registered without a card composition. These two properties\n      // are the untransformed layout box; both are integers, which is what the\n      // rounding below already reduced the rect to.\n      const nextWidth = Math.max(1, stageNode.offsetWidth)\n      const nextHeight = Math.max(1, stageNode.offsetHeight)\n      const nextDpr = Math.min(2, window.devicePixelRatio || 1)\n      if (nextWidth === width && nextHeight === height && nextDpr === dpr && state) return\n\n      width = nextWidth\n      height = nextHeight\n      dpr = nextDpr\n      canvasNode.width = Math.round(width * dpr)\n      canvasNode.height = Math.round(height * dpr)\n      canvasNode.style.width = `${width}px`\n      canvasNode.style.height = `${height}px`\n      frame = 0\n      state = optionsRef.current.setup({ context, width, height, dpr })\n    }\n\n    const paint = () => {\n      if (!state) return\n      // Re-applied every frame: a scene is free to install its own transform\n      // for a cell or a sprite, and most do.\n      context.setTransform(dpr, 0, 0, dpr, 0, 0)\n      optionsRef.current.draw({ context, width, height, dpr, state, pointer, frame })\n      pointer.lastX = pointer.x\n      pointer.lastY = pointer.y\n      frame += 1\n    }\n\n    /** One frame on the next tick, coalescing however many were asked for. */\n    const paintOnce = () => {\n      if (pending) return\n      pending = requestAnimationFrame(() => {\n        pending = 0\n        measure()\n        paint()\n      })\n    }\n    render.current = paintOnce\n\n    const tick = () => {\n      loop = requestAnimationFrame(tick)\n      if (visible) paint()\n    }\n\n    const start = () => {\n      if (loop || reduced) return\n      loop = requestAnimationFrame(tick)\n    }\n    const stop = () => {\n      if (!loop) return\n      cancelAnimationFrame(loop)\n      loop = 0\n    }\n\n    const at = (event: PointerEvent) => {\n      const rect = stageNode.getBoundingClientRect()\n      // The rect is the right thing to subtract here — `clientX` is viewport\n      // space and so is the rect — but the difference comes back in *rendered*\n      // pixels, and a scene reads `pointer` in the scene pixels `measure()` set\n      // up from the untransformed box. Under a CSS scale those two disagree, so\n      // divide the transform back out. `rect.width / offsetWidth` is the scale\n      // actually in force, whatever produced it, and it is exactly 1 when there\n      // is none.\n      const scale = stageNode.offsetWidth > 0 ? rect.width / stageNode.offsetWidth : 1\n      pointer.x = (event.clientX - rect.left) / (scale || 1)\n      pointer.y = (event.clientY - rect.top) / (scale || 1)\n      // A frozen loop still owes the user feedback for a drag.\n      if (reduced) paintOnce()\n    }\n\n    const onEnter = (event: PointerEvent) => {\n      pointer.inside = true\n      at(event)\n      pointer.lastX = pointer.x\n      pointer.lastY = pointer.y\n    }\n    const onMove = (event: PointerEvent) => {\n      pointer.inside = true\n      at(event)\n    }\n    const onDown = (event: PointerEvent) => {\n      pointer.down = true\n      at(event)\n      // Capture keeps a drag alive past the edge of the stage, which is where\n      // a hard throw naturally ends up.\n      stageNode.setPointerCapture(event.pointerId)\n    }\n    const onUp = (event: PointerEvent) => {\n      pointer.down = false\n      at(event)\n      if (stageNode.hasPointerCapture(event.pointerId)) {\n        stageNode.releasePointerCapture(event.pointerId)\n      }\n    }\n    const onLeave = () => {\n      pointer.inside = false\n      pointer.down = false\n      if (reduced) paintOnce()\n    }\n\n    stageNode.addEventListener(\"pointerenter\", onEnter)\n    stageNode.addEventListener(\"pointermove\", onMove)\n    stageNode.addEventListener(\"pointerdown\", onDown)\n    stageNode.addEventListener(\"pointerup\", onUp)\n    stageNode.addEventListener(\"pointercancel\", onUp)\n    stageNode.addEventListener(\"pointerleave\", onLeave)\n\n    const resizes = new ResizeObserver(() => paintOnce())\n    resizes.observe(stageNode)\n\n    /*\n     * An animation nobody can see is heat. The observer both pauses the loop\n     * and, on the way back in, repaints immediately rather than waiting a frame.\n     */\n    const views = new IntersectionObserver(\n      (entries) => {\n        visible = entries.some((entry) => entry.isIntersecting)\n        if (visible) {\n          start()\n          paintOnce()\n        } else {\n          stop()\n        }\n      },\n      { rootMargin: \"120px\" },\n    )\n    views.observe(stageNode)\n\n    measure()\n    paint()\n    start()\n\n    return () => {\n      render.current = null\n      stop()\n      if (pending) cancelAnimationFrame(pending)\n      resizes.disconnect()\n      views.disconnect()\n      stageNode.removeEventListener(\"pointerenter\", onEnter)\n      stageNode.removeEventListener(\"pointermove\", onMove)\n      stageNode.removeEventListener(\"pointerdown\", onDown)\n      stageNode.removeEventListener(\"pointerup\", onUp)\n      stageNode.removeEventListener(\"pointercancel\", onUp)\n      stageNode.removeEventListener(\"pointerleave\", onLeave)\n    }\n  }, [reduced])\n\n  return { stageRef, canvasRef, requestRender }\n}\n","type":"registry:hook"}],"meta":{"kind":"animations","categories":["masks","micro"],"docs":"https://ui.artbloom.tech/artbloom/animations/moire-scratch"}}