{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"wetting-reveal","type":"registry:ui","title":"Wetting Reveal","description":"A gallery plate revealed by a liquid film with a real contact angle. The front is driven by its own curvature and pins on defects in the glass, so a drop rounds itself off, stalls at an edge, and merges with its neighbours into one sheet — and the wetted picture carries the tonality the dry haze was flattening.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/wetting-reveal.tsx","target":"components/ui/wetting-reveal.tsx","content":"'use client';\n\nimport './wetting-reveal.css';\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\n\nimport { useCanvasScene, useReducedMotion, type SceneDrawContext, type SceneSetupContext } from '@/hooks/use-canvas-scene';\n\n/**\n * A gallery card whose picture is uncovered by liquid spreading over the glass.\n *\n * The mask boundary is a closed ring of markers, each moved along its own outward\n * normal by curvature-driven front propagation — the geometric form of the\n * Gibbs-Thomson condition, and the same normal-velocity law a dendrite tip obeys:\n *\n *   v(s) = W(x, y) * V_DRIVE - GAMMA * kappa(s)\n *   kappa(s) = -( P(s - h) - 2 P(s) + P(s + h) ) . n(s) / h^2\n *\n * with n the outward unit normal, h the marker spacing, and W in [0, 1] the local\n * wettability read analytically off the plate — a handful of smooth patches plus a\n * two-wave grain, never a bitmap. The ring is re-parameterised to even arclength every\n * step, so h in that second difference is a real number rather than an average:\n * markers cannot bunch at a finger tip or tear apart in the fjord behind one, which is\n * the failure that kills marker methods on this equation inside a second.\n *\n * This is NOT Washburn imbibition. There is no porous medium here, no\n * L = sqrt(gamma r t / 2 eta), and no t^(1/2) anywhere in the file; Lucas-Washburn\n * describes liquid drawn along a capillary, and a capillary has no free contact line\n * left to curve. It is also none of the three shortcuts this looks like from outside:\n * a clip-path circle() that scales up, an SVG blob with keyframed path morphing, or a\n * radial-gradient mask with an animated stop. All three stay convex and evenly paced\n * for the whole reveal, and convexity is not a taste decision in them — it is the only\n * shape they can express, because none of them carries a curvature term.\n *\n * The one thing to watch under the hand: the wet edge does not stay round. It fingers\n * along the well-wetting saddles of the plate, hangs up on the low-wettability patches\n * until the rest of the front has walked past them, and the dry pockets it leaves shut\n * with a visible snap instead of fading out. That snap is the whole of the -GAMMA *\n * kappa term: a dry pocket is a concave piece of contact line, kappa is negative\n * there, so the pocket's closing speed is the LARGEST on the ring at the moment the\n * pocket is smallest. Every easing curve does the opposite and slows into its finish.\n *\n * Fronts are never merged analytically. Each ring is filled into an accumulating mask\n * bitmap — source-over for the spreading film, destination-out for a dewetting hole —\n * so two overlapping rings union for free and a hole punched through the film\n * subtracts for free. Contour surgery at a topological change is the classic way to\n * get this wrong; compositing is the honest way to never need it. Within a phase the\n * mask only accumulates, and that is contact-angle hysteresis rather than laziness: a\n * line that has passed over a spot leaves it wetted behind itself.\n *\n * Then the film dewets, because a partially wetting film on cold glass is metastable.\n * The same v = drive - GAMMA * kappa runs with the drive reversed and the wettability\n * entering with the opposite sign, so holes nucleate on exactly the patches that\n * pinned the front on the way in and grow once they clear their own critical radius.\n * One equation, two signs, and the card loops with no keyframe in it.\n */\n\n/**\n * Seconds per solver step. The curvature term is a diffusion along the ring, so an\n * explicit step is stable while STEP < h^2 / (2 * GAMMA): 35 ms at the target spacing, but\n * only 6.5 ms once MIN_MARKERS has squeezed h down to 2.2px on a collapsing ring. 1/240 is\n * set by that worst case rather than by the common one.\n */\nconst STEP = 1 / 240;\n/** Substeps one frame may consume. 8 * STEP is 33 ms, so a tab returning from the\n *  background drops its arrears instead of running a minute of solver in one paint. */\nconst MAX_SUBSTEPS = 8;\n/** Seconds of solver run before the first paint, so the plate opens on a blot already\n *  fingering rather than on a bare frost waiting for the first rAF. */\nconst WARM = 0.4;\n\n/** Wetting drive V, px/s, over glass that wets perfectly. Crosses the plate in about\n *  four seconds on its own; the patches are what stretch the reveal to eight. */\nconst V_DRIVE = 88;\n/** Line tension GAMMA, px^2/s. GAMMA / V_DRIVE is the critical radius, 4.1px here: a\n *  blot smaller than that closes on itself rather than growing, which is the reason\n *  SEED_R below is 16 and not 4. */\nconst GAMMA = 360;\n/** Normal-speed ceiling, px/s, at 4 * V_DRIVE. A pocket about to close has kappa\n *  running away and a true speed to match; this keeps one step under 1.5px, under a\n *  third of the marker spacing, so the ring cannot jump across itself in one move. */\nconst V_CEIL = 352;\n/** Fraction of the local drive a contact line may run backwards at. Hysteresis: the\n *  receding angle is below the advancing one. On a patch the drive is zero, so this\n *  floor is zero too and a pinned line stops dead instead of reversing under curvature,\n *  which is the one thing no real contact line does. */\nconst RECEDE = 0.7;\n/** Dewetting drive, px/s, at a fully unwettable spot — half again V_DRIVE, because a\n *  film retracts faster than it spread. */\nconst V_DEWET = 132;\n/** How much good wetting holds a hole shut: drive = V_DEWET * (1 - DEWET_HOLD * W).\n *  0.6 rather than 1 because a partially wetting film is metastable everywhere, not\n *  only on the patches, so a hole past its critical radius keeps going instead of\n *  healing the moment it reaches clean glass. */\nconst DEWET_HOLD = 0.6;\n\n/** Target marker spacing h, px. Fine enough that a 40px finger has eight markers across\n *  it, coarse enough that the stability limit stays eight times the step. */\nconst SPACING = 5;\n/** Floor on the marker count, so curvature always has both neighbours to difference. */\nconst MIN_MARKERS = 12;\n/** Cap on the marker count: 1600px of ring, a plate's perimeter plus the fjords the\n *  patches carve into it. Past this, spacing grows and the solver only gets safer. */\nconst MAX_MARKERS = 320;\n/** Perimeter, px, below which a ring is retired: 2 * pi * 4.1, the circumference at the\n *  critical radius. Below it the ring is collapsing and resampling would drive h under\n *  the stability limit on the way down. */\nconst DEATH_PERIMETER = 26;\n/** How far outside the plate a marker may travel, px. Larger than the biggest critical\n *  radius in the file (6.8px, a hole on clean glass), so a ring parked on this rectangle\n *  rounds its corners outside the visible plate instead of leaving four dry notches. */\nconst BLEED = 11;\n\n/** Seed radius, px, comfortably over the 4.1px critical radius. */\nconst SEED_R = 16;\n/** Dewetting nucleus radius, px, over the 6.8px critical radius on clean glass. */\nconst HOLE_R = 11;\n/** Live rings allowed at once. Retiring the oldest costs nothing already in the mask —\n *  only that ring's remaining advance — and at seven the plate is nearly wet anyway. */\nconst MAX_FRONTS = 7;\n/** Seconds between drops while the plate is held. */\nconst HOLD_PERIOD = 0.2;\n/** Pointer travel, px, required before a held press lays another drop. Holding still\n *  grows the one drop; dragging lays a chain of them. */\nconst HOLD_GAP = 15;\n/** Area growth, px^2/s, under which a ring counts as doing nothing. A ring parked on the\n *  bleed rectangle still jitters at its corners, so speed is the wrong test and enclosed\n *  area is the right one. */\nconst AREA_STALL = 14;\n/** Consecutive stalled steps before a ring is retired: half a second. */\nconst STALL_STEPS = 120;\n/** Seconds a ring may live regardless. The backstop that guarantees the phase machine\n *  keeps moving, since a ring that never retires would hold the reveal open forever. */\nconst MAX_AGE = 24;\n/** Seconds the wet plate rests before the film starts to dewet. */\nconst REST_WET = 2.4;\n/** Seconds the dry plate rests before the next drop lands. */\nconst REST_DRY = 0.9;\n\n/** Peak-to-trough swing of W away from the patches. 0.44 gives a not-quite-two-to-one\n *  spread of speeds, which is enough for fingers and short of a front that shreds. */\nconst GRAIN = 0.44;\n/** Grain wave numbers, rad/px. The two wave vectors are 86px and 105px long and\n *  incommensurate, so their product never repeats inside a card and the front has real\n *  saddles to run along instead of a tiling it would trace out twice. */\nconst GRAIN_A = 0.0631;\nconst GRAIN_B = 0.0374;\nconst GRAIN_C = 0.0289;\nconst GRAIN_D = 0.0523;\n\n/** Downsample factor for the dry plate. An eighth, drawn back up, is a wide box blur for\n *  the price of two blits — and it runs once, in setup, not per frame. */\nconst FROST_DIV = 8;\n/** The meniscus, as stroke widths and alphas, widest first. Three bands rather than one\n *  line because a real contact line is a lens with a soft outer edge, and the widths\n *  halve so the accumulated alpha falls off roughly linearly from the boundary. */\nconst RIM_BANDS: readonly { readonly width: number; readonly alpha: number }[] = [\n  { width: 9, alpha: 0.26 },\n  { width: 4.2, alpha: 0.4 },\n  { width: 1.6, alpha: 1 },\n];\n/** The one accent in the file, spent on the contact line and on nothing else. */\nconst ACCENT = '#78e0cf';\n\n/** A low-wettability patch, in fractions of the plate. Three with an amplitude over 1,\n *  whose cores drive W to exactly zero and pin the front dead; two under 1, which only\n *  slow it, so the plate has both hard stops and slow ground. */\ninterface Patch {\n  readonly u: number;\n  readonly v: number;\n  readonly ru: number;\n  readonly rv: number;\n  readonly amp: number;\n}\n\nconst PATCHES: readonly Patch[] = [\n  { u: 0.3, v: 0.3, ru: 0.115, rv: 0.165, amp: 1.3 },\n  { u: 0.585, v: 0.545, ru: 0.135, rv: 0.185, amp: 1.2 },\n  { u: 0.84, v: 0.26, ru: 0.1, rv: 0.15, amp: 1.1 },\n  { u: 0.735, v: 0.855, ru: 0.115, rv: 0.11, amp: 0.92 },\n  { u: 0.115, v: 0.6, ru: 0.085, rv: 0.135, amp: 0.85 },\n];\n\n/** Where the first drop lands, and where the reset button puts the next one. */\nconst ORIGIN = { u: 0.2, v: 0.82 };\n/** Where the button's drops land, in order. Fixed rather than random, so the card looks\n *  the same on every load and a reviewer can compare two runs. */\nconst DROPS: readonly { readonly u: number; readonly v: number }[] = [\n  { u: 0.78, v: 0.24 },\n  { u: 0.5, v: 0.13 },\n  { u: 0.88, v: 0.7 },\n  { u: 0.13, v: 0.33 },\n  { u: 0.46, v: 0.92 },\n];\n\n/** The card's own text. A gallery card with no caption is just a picture in a box. */\nconst ART_TITLE = 'Still Life with Jug and Two Quinces';\nconst ART_CREDIT = 'Gouache on paper, 1961. Kestner Collection, plate 41.';\n/** The live region's opening line, and what each control says once it has acted. The\n *  plate is aria-hidden, so this text is the only account of it a screen reader gets. */\nconst HINT = 'Press and hold the glass to wet it from where you press.';\nconst AFTER_DROP =\n  'A drop lands. The wet edge fingers where the glass wets well, and hangs up where it does not.';\nconst AFTER_RESET = 'The glass is dry. One drop, low on the left, spreading again.';\nconst REDUCED =\n  'Reduced motion: the glass is painted at the state the spreading settles on, beads and all.';\n\n/** A patch in plate pixels, with the radii pre-inverted: `wettability` runs once per\n *  marker per step, five spots deep, so the divisions come out of the inner loop. */\ninterface Spot {\n  readonly x: number;\n  readonly y: number;\n  readonly ix: number;\n  readonly iy: number;\n  readonly amp: number;\n}\n\n/** One closed contact line. `xs`/`ys` are allocated once at MAX_MARKERS and `n` says how\n *  much of them is live, so re-parameterisation changes a count and never allocates. */\ninterface Front {\n  readonly xs: Float64Array;\n  readonly ys: Float64Array;\n  /** A dewetting hole rather than a spreading blot: same law, drive reversed. */\n  readonly dry: boolean;\n  n: number;\n  area: number;\n  still: number;\n  age: number;\n}\n\n/** The four offscreen plates. `art` is the picture, `frost` the picture behind dry glass,\n *  `mask` the wetted region as it accumulates, and `scratch` is borrowed twice a frame. */\ninterface Layers {\n  readonly art: CanvasRenderingContext2D;\n  readonly frost: CanvasRenderingContext2D;\n  readonly mask: CanvasRenderingContext2D;\n  readonly scratch: CanvasRenderingContext2D;\n}\n\ntype Mode = 'wet' | 'dry';\n\ninterface State {\n  readonly width: number;\n  readonly height: number;\n  readonly spots: readonly Spot[];\n  readonly fronts: Front[];\n  /** Null only if a browser refuses a 2D context for an offscreen plate. */\n  readonly layers: Layers | null;\n  mode: Mode;\n  /** Seconds since the last front retired, which is what times the two rests. */\n  idle: number;\n  /** Unspent seconds, so the solver runs at STEP and not at the display's refresh rate. */\n  carry: number;\n  /** `performance.now()` at the last painted frame, in seconds. */\n  last: number;\n  /** Seconds since the held pointer last laid a drop, and where it laid it. */\n  hold: number;\n  holdX: number;\n  holdY: number;\n  /** Whether the pointer was down last frame, so a press lays one drop and not sixty. */\n  wasDown: boolean;\n  /** Next entry in DROPS the button will reach for. */\n  next: number;\n  /** Set when a phase restarts; cleared by the frame that empties the mask. */\n  wipe: boolean;\n}\n\n/** What the buttons ask of the scene. A ref and not state: `draw` is re-created on every\n *  render but the scene keeps the one it was given, so a counter read here is current\n *  while a captured value would be a frame or more stale. */\ninterface Asks {\n  drops: number;\n  resets: number;\n}\n\n/*\n * Working buffers, at module scope. Rings are stepped and re-parameterised one at a time\n * on one thread, so a single set serves all of them and a solver step allocates nothing.\n */\nconst bufVel = new Float64Array(MAX_MARKERS);\nconst bufNx = new Float64Array(MAX_MARKERS);\nconst bufNy = new Float64Array(MAX_MARKERS);\nconst bufX = new Float64Array(MAX_MARKERS);\nconst bufY = new Float64Array(MAX_MARKERS);\n/** Cumulative arclength, so it needs the closing length as well: MAX_MARKERS + 1. */\nconst bufArc = new Float64Array(MAX_MARKERS + 1);\n\n/**\n * Local wettability W in [0, 1]: 1 on clean glass, 0 where the contact line pins dead.\n *\n * Each patch contributes amp * (1 - d^2)^2 inside its own ellipse, which is C1 at the\n * rim — a hard-edged patch would put a step in the drive and the front would corner on\n * it instead of curving around. On top of that a two-cosine grain, whose product gives\n * saddles rather than a grid of bumps, so a finger has somewhere to run.\n */\nfunction wettability(state: State, x: number, y: number): number {\n  let block = 0;\n  for (const spot of state.spots) {\n    const u = (x - spot.x) * spot.ix;\n    const v = (y - spot.y) * spot.iy;\n    const d2 = u * u + v * v;\n    if (d2 >= 1) continue;\n    const f = 1 - d2;\n    block += spot.amp * f * f;\n  }\n  const g = Math.cos(x * GRAIN_A + y * GRAIN_B) * Math.cos(x * GRAIN_C - y * GRAIN_D);\n  const w = 1 - block - GRAIN * (0.5 - 0.5 * g);\n  return w > 0 ? (w < 1 ? w : 1) : 0;\n}\n\n/**\n * A closed path through points, each one met by the quadratic between the midpoints on\n * either side of it. Nine profile samples become a curve with no facets, and — this is\n * why the jug is built this way rather than from beziers — a mirrored sample list is\n * symmetric by construction, so the shoulder is one number to tune and not four handles.\n * Wants three points or more, which the one profile in this file has six times over.\n */\nfunction smooth(context: CanvasRenderingContext2D, px: readonly number[], py: readonly number[]) {\n  const n = px.length;\n  context.beginPath();\n  context.moveTo((px[n - 1] + px[0]) / 2, (py[n - 1] + py[0]) / 2);\n  for (let i = 0; i < n; i += 1) {\n    const j = i + 1 === n ? 0 : i + 1;\n    context.quadraticCurveTo(px[i], py[i], (px[i] + px[j]) / 2, (py[i] + py[j]) / 2);\n  }\n  context.closePath();\n}\n\n/**\n * A cast shadow: an ellipse of ground darker than the ground, laid down before the form\n * that casts it so the form's own edge stays clean over the top of it.\n */\nfunction shade(context: CanvasRenderingContext2D, cx: number, cy: number, rx: number, ry: number) {\n  context.save();\n  context.translate(cx, cy);\n  context.scale(1, ry / rx);\n  const dark = context.createRadialGradient(0, 0, 0, 0, 0, rx);\n  dark.addColorStop(0, 'rgba(4, 7, 9, 0.62)');\n  dark.addColorStop(0.55, 'rgba(4, 7, 9, 0.28)');\n  dark.addColorStop(1, 'rgba(4, 7, 9, 0)');\n  context.beginPath();\n  context.arc(0, 0, rx, 0, Math.PI * 2);\n  context.fillStyle = dark;\n  context.fill();\n  context.restore();\n}\n\n/** The jug's silhouette: half-width against height, both as fractions of its height, foot\n *  first. Nine samples is the fewest that keeps a foot, a belly, a waist and a lip once\n *  the midpoint smoothing has rounded the corners off them. */\nconst JUG: readonly { readonly t: number; readonly w: number }[] = [\n  { t: 0, w: 0.28 },\n  { t: 0.05, w: 0.33 },\n  { t: 0.2, w: 0.44 },\n  { t: 0.38, w: 0.5 },\n  { t: 0.56, w: 0.46 },\n  { t: 0.7, w: 0.34 },\n  { t: 0.83, w: 0.25 },\n  { t: 0.93, w: 0.23 },\n  { t: 1, w: 0.27 },\n];\n\n/**\n * One quince: a body squashed to 0.88 of its width and lit from the upper left by a\n * radial gradient offset toward the light, plus a stalk. No outline anywhere — the form\n * turns away into the ground, and the gradient's outer stop is that turn.\n */\nfunction fruit(context: CanvasRenderingContext2D, cx: number, cy: number, r: number) {\n  context.save();\n  context.translate(cx, cy);\n  context.scale(1, 0.88);\n  const lit = context.createRadialGradient(-r * 0.34, -r * 0.4, r * 0.05, 0, 0, r * 1.15);\n  lit.addColorStop(0, 'rgba(231, 237, 239, 0.52)');\n  lit.addColorStop(0.42, 'rgba(231, 237, 239, 0.21)');\n  lit.addColorStop(1, 'rgba(231, 237, 239, 0.045)');\n  context.beginPath();\n  context.arc(0, 0, r, 0, Math.PI * 2);\n  context.fillStyle = lit;\n  context.fill();\n  context.restore();\n  context.beginPath();\n  context.moveTo(cx + r * 0.06, cy - r * 0.78);\n  context.quadraticCurveTo(cx + r * 0.24, cy - r * 1, cx + r * 0.19, cy - r * 1.16);\n  context.lineWidth = Math.max(1, r * 0.075);\n  context.lineCap = 'round';\n  context.strokeStyle = 'rgba(231, 237, 239, 0.3)';\n  context.stroke();\n}\n\n/**\n * The picture under the glass, drawn and not loaded: a wall, a table, a ceramic jug and\n * two quinces, all in the one bone tone at a dozen alphas over a cold ground. The jug is\n * opaque earthenware on purpose — a glass decanter with a level standing in it would read\n * as a gauge of something, and there is nothing in this file for a gauge to report.\n */\nfunction paintArt(context: CanvasRenderingContext2D, width: number, height: number) {\n  const wall = context.createLinearGradient(0, 0, 0, height);\n  wall.addColorStop(0, '#151c20');\n  wall.addColorStop(0.62, '#0e1417');\n  wall.addColorStop(1, '#0a0f12');\n  context.fillStyle = wall;\n  context.fillRect(0, 0, width, height);\n\n  // The light: a pool on the wall, up and to the left. It is the whole reason the round\n  // forms below read as lit from a direction rather than as flat discs.\n  const lx = width * 0.32;\n  const ly = height * 0.15;\n  const pool = context.createRadialGradient(lx, ly, 4, lx, ly, height * 1.1);\n  pool.addColorStop(0, 'rgba(231, 237, 239, 0.135)');\n  pool.addColorStop(0.5, 'rgba(231, 237, 239, 0.04)');\n  pool.addColorStop(1, 'rgba(231, 237, 239, 0)');\n  context.fillStyle = pool;\n  context.fillRect(0, 0, width, height);\n\n  const tableY = height * 0.7;\n  const table = context.createLinearGradient(0, tableY, 0, height);\n  table.addColorStop(0, 'rgba(231, 237, 239, 0.115)');\n  table.addColorStop(0.2, 'rgba(231, 237, 239, 0.05)');\n  table.addColorStop(1, 'rgba(231, 237, 239, 0.012)');\n  context.fillStyle = table;\n  context.fillRect(0, tableY, width, height - tableY);\n  // The near edge of the table takes the light along its whole length.\n  context.fillStyle = 'rgba(231, 237, 239, 0.16)';\n  context.fillRect(0, tableY, width, Math.max(1, height * 0.005));\n\n  const base = tableY + height * 0.1;\n  const jh = height * 0.5;\n  const jx = width * 0.36;\n  shade(context, jx + jh * 0.18, base - height * 0.004, jh * 0.74, jh * 0.13);\n\n  const px: number[] = [];\n  const py: number[] = [];\n  for (const sample of JUG) {\n    px.push(jx + sample.w * jh);\n    py.push(base - sample.t * jh);\n  }\n  for (let i = JUG.length - 1; i >= 0; i -= 1) {\n    px.push(jx - JUG[i].w * jh);\n    py.push(base - JUG[i].t * jh);\n  }\n  const cxl = jx - jh * 0.24;\n  const cyl = base - jh * 0.62;\n  const clay = context.createRadialGradient(cxl, cyl, jh * 0.05, jx, base - jh * 0.45, jh);\n  clay.addColorStop(0, 'rgba(231, 237, 239, 0.46)');\n  clay.addColorStop(0.4, 'rgba(231, 237, 239, 0.2)');\n  clay.addColorStop(0.78, 'rgba(231, 237, 239, 0.075)');\n  clay.addColorStop(1, 'rgba(231, 237, 239, 0.03)');\n  smooth(context, px, py);\n  context.fillStyle = clay;\n  context.fill();\n\n  // The handle, hung on the shadow side as one stroked arc from shoulder to belly.\n  context.beginPath();\n  context.moveTo(jx + jh * 0.24, base - jh * 0.8);\n  context.bezierCurveTo(\n    jx + jh * 0.62,\n    base - jh * 0.82,\n    jx + jh * 0.64,\n    base - jh * 0.46,\n    jx + jh * 0.4,\n    base - jh * 0.43,\n  );\n  context.lineWidth = Math.max(1.5, jh * 0.055);\n  context.lineCap = 'round';\n  context.strokeStyle = 'rgba(231, 237, 239, 0.19)';\n  context.stroke();\n\n  // The lip: a dark ellipse for the opening, then a bright arc across the far side of it,\n  // the one place in the picture where the light lands on an edge and not on a surface.\n  const lipY = base - jh;\n  const lipR = jh * 0.27;\n  context.beginPath();\n  context.ellipse(jx, lipY, lipR, lipR * 0.3, 0, 0, Math.PI * 2);\n  context.fillStyle = 'rgba(10, 14, 17, 0.72)';\n  context.fill();\n  context.beginPath();\n  context.ellipse(jx, lipY, lipR, lipR * 0.3, 0, Math.PI * 1.04, Math.PI * 1.96);\n  context.lineWidth = Math.max(1, jh * 0.022);\n  context.strokeStyle = 'rgba(231, 237, 239, 0.5)';\n  context.stroke();\n\n  // Back quince first, so the front one overlaps it rather than the other way about.\n  const qbx = width * 0.775;\n  const qby = tableY + height * 0.035;\n  const qbr = height * 0.088;\n  shade(context, qbx + qbr * 0.34, qby + qbr * 0.78, qbr * 1.5, qbr * 0.34);\n  fruit(context, qbx, qby, qbr);\n\n  const qfx = width * 0.625;\n  const qfy = tableY + height * 0.08;\n  const qfr = height * 0.115;\n  shade(context, qfx + qfr * 0.34, qfy + qfr * 0.8, qfr * 1.6, qfr * 0.36);\n  fruit(context, qfx, qfy, qfr);\n\n  // A vignette, so the plate has corners to it. Last, over everything.\n  const ex = width * 0.5;\n  const ey = height * 0.48;\n  const edge = context.createRadialGradient(ex, ey, height * 0.3, ex, ey, height * 1.05);\n  edge.addColorStop(0, 'rgba(5, 8, 10, 0)');\n  edge.addColorStop(1, 'rgba(5, 8, 10, 0.6)');\n  context.fillStyle = edge;\n  context.fillRect(0, 0, width, height);\n}\n\n/** Specks of sandblasting on the dry plate. 140 over a 3:2 card is about one every\n *  560px^2 — enough tooth to read as ground glass, sparse enough not to become a texture\n *  in its own right and compete with the picture. */\nconst FROST_DOTS = 140;\n\n/**\n * The dry plate: the picture at an eighth scale drawn back up, then a cold film and its\n * specks. Sandblasted glass scatters what is behind it, and wetting the roughness\n * index-matches it so the picture comes into focus — that optical fact is why this reveal\n * reads as liquid instead of as a hole cut in a stencil. Two blits, once, in setup.\n *\n * `scratch` holds the small copy in the meantime. A fifth offscreen plate for a step that\n * happens before the first frame would be a plate idling for the rest of the session.\n */\nfunction paintFrost(layers: Layers, width: number, height: number, dpr: number) {\n  const small = layers.scratch;\n  const sw = Math.max(1, Math.round(width / FROST_DIV));\n  const sh = Math.max(1, Math.round(height / FROST_DIV));\n  small.setTransform(dpr, 0, 0, dpr, 0, 0);\n  small.globalCompositeOperation = 'source-over';\n  small.clearRect(0, 0, width, height);\n  small.imageSmoothingQuality = 'high';\n  small.drawImage(layers.art.canvas, 0, 0, sw, sh);\n\n  const frost = layers.frost;\n  frost.setTransform(dpr, 0, 0, dpr, 0, 0);\n  frost.globalCompositeOperation = 'source-over';\n  frost.clearRect(0, 0, width, height);\n  frost.imageSmoothingQuality = 'high';\n  // Source rect in the scratch bitmap's own device pixels; destination in plate pixels.\n  frost.drawImage(\n    small.canvas,\n    0,\n    0,\n    Math.max(1, Math.round(sw * dpr)),\n    Math.max(1, Math.round(sh * dpr)),\n    0,\n    0,\n    width,\n    height,\n  );\n\n  // The film: the same bone as everything else, a shade heavier down the plate where the\n  // dust settles on one. A second hue here would be a third colour in the card for nothing\n  // — scattering lifts the blacks, it does not tint them.\n  const veil = frost.createLinearGradient(0, 0, 0, height);\n  veil.addColorStop(0, 'rgba(231, 237, 239, 0.13)');\n  veil.addColorStop(1, 'rgba(231, 237, 239, 0.185)');\n  frost.fillStyle = veil;\n  frost.fillRect(0, 0, width, height);\n\n  // The specks, on a golden-angle spiral: deterministic, so the plate is the same on\n  // every load, and neither a PRNG nor a noise bitmap has to exist to place them.\n  const span = Math.hypot(width, height) * 0.5;\n  frost.fillStyle = 'rgba(231, 237, 239, 0.05)';\n  for (let i = 0; i < FROST_DOTS; i += 1) {\n    const a = i * 2.399963;\n    const r = Math.sqrt((i + 0.5) / FROST_DOTS) * span;\n    const x = width * 0.5 + Math.cos(a) * r;\n    const y = height * 0.5 + Math.sin(a) * r;\n    frost.beginPath();\n    frost.arc(x, y, 2 + (i % 5), 0, Math.PI * 2);\n    frost.fill();\n  }\n\n  small.clearRect(0, 0, width, height);\n}\n\n/**\n * The four offscreen plates, each DPR-scaled so every coordinate above and below is a\n * plate pixel. Returns null rather than throwing if a context is refused — the caller has\n * a real answer for that case, and it is not an exception.\n */\nfunction buildLayers(width: number, height: number, dpr: number): Layers | null {\n  const make = (): CanvasRenderingContext2D | null => {\n    const canvas = document.createElement('canvas');\n    canvas.width = Math.max(1, Math.round(width * dpr));\n    canvas.height = Math.max(1, Math.round(height * dpr));\n    const context = canvas.getContext('2d');\n    if (!context) return null;\n    context.setTransform(dpr, 0, 0, dpr, 0, 0);\n    return context;\n  };\n  const art = make();\n  const frost = make();\n  const mask = make();\n  const scratch = make();\n  if (!art || !frost || !mask || !scratch) return null;\n  return { art, frost, mask, scratch };\n}\n\n/**\n * A fresh contact line: markers on a circle at increasing theta, which is what makes\n * (ty, -tx) the OUTWARD normal for every ring in the file. Get that winding wrong on one\n * ring and it grows inward while its neighbours grow outward.\n */\nfunction ring(x: number, y: number, r: number, dry: boolean): Front {\n  let n = Math.round((2 * Math.PI * r) / SPACING);\n  if (n < MIN_MARKERS) n = MIN_MARKERS;\n  else if (n > MAX_MARKERS) n = MAX_MARKERS;\n  const xs = new Float64Array(MAX_MARKERS);\n  const ys = new Float64Array(MAX_MARKERS);\n  for (let i = 0; i < n; i += 1) {\n    const a = (i / n) * Math.PI * 2;\n    xs[i] = x + Math.cos(a) * r;\n    ys[i] = y + Math.sin(a) * r;\n  }\n  return { xs, ys, dry, n, area: Math.PI * r * r, still: 0, age: 0 };\n}\n\n/** The enclosed area, by the shoelace sum. Signed area would tell winding too, but a\n *  pinched ring is legitimately part negative, so only the magnitude is asked for. */\nfunction ringArea(front: Front): number {\n  const { xs, ys, n } = front;\n  let sum = 0;\n  for (let i = 0; i < n; i += 1) {\n    const j = i + 1 === n ? 0 : i + 1;\n    sum += xs[i] * ys[j] - xs[j] * ys[i];\n  }\n  return Math.abs(sum) * 0.5;\n}\n\n/** Lay a ring, retiring the oldest if the plate is already crowded. Seeds are clamped\n *  into the plate: a drag under pointer capture reports positions off the glass, and a\n *  drop centred out there would spend its life as an arc in one corner. */\nfunction drop(state: State, x: number, y: number, dry: boolean) {\n  if (state.fronts.length >= MAX_FRONTS) state.fronts.shift();\n  const cx = x < 0 ? 0 : x > state.width ? state.width : x;\n  const cy = y < 0 ? 0 : y > state.height ? state.height : y;\n  state.fronts.push(ring(cx, cy, dry ? HOLE_R : SEED_R, dry));\n  state.idle = 0;\n}\n\n/**\n * One solver step for one ring: v = W * V_DRIVE - GAMMA * kappa, along each marker's own\n * outward normal.\n *\n * The second difference is taken against the normal, which is where the minus sign in\n * front of GAMMA goes. On a circle of radius R with angular spacing d, the difference\n * comes to 2R(cos d - 1) . n ~= -R d^2, and h^2 = R^2 d^2, so bend / h^2 is -1/R = -kappa\n * exactly — hence `drive + curve * bend` below with curve = GAMMA / h^2, and no separate\n * kappa ever computed. Velocities are gathered for the whole ring before any marker\n * moves; interleaving the two would difference this step's neighbour against last step's\n * and quietly halve the curvature.\n */\nfunction advance(state: State, front: Front, dt: number): boolean {\n  const { xs, ys, n } = front;\n  let perim = 0;\n  for (let i = 0; i < n; i += 1) {\n    const j = i + 1 === n ? 0 : i + 1;\n    const dx = xs[j] - xs[i];\n    const dy = ys[j] - ys[i];\n    perim += Math.sqrt(dx * dx + dy * dy);\n  }\n  if (perim < DEATH_PERIMETER) return false;\n  // The spacing the last re-parameterisation left, so this is a measured h and not a\n  // nominal one — SPACING is the target, this is what the ring actually has.\n  const h = perim / n;\n  const curve = GAMMA / (h * h);\n  for (let i = 0; i < n; i += 1) {\n    const p = i === 0 ? n - 1 : i - 1;\n    const q = i + 1 === n ? 0 : i + 1;\n    const tx = xs[q] - xs[p];\n    const ty = ys[q] - ys[p];\n    const len = Math.sqrt(tx * tx + ty * ty) || 1;\n    const nx = ty / len;\n    const ny = -tx / len;\n    const bend = (xs[p] - 2 * xs[i] + xs[q]) * nx + (ys[p] - 2 * ys[i] + ys[q]) * ny;\n    const wet = wettability(state, xs[i], ys[i]);\n    // One law, two signs. Spreading is driven by wettability; retraction is driven by the\n    // want of it, so the patches that pinned the front are where the film tears first.\n    const drive = front.dry ? V_DEWET * (1 - DEWET_HOLD * wet) : V_DRIVE * wet;\n    let v = drive + curve * bend;\n    if (v > V_CEIL) v = V_CEIL;\n    // Hysteresis, and the one place this stops being a pure geometric flow: a line may\n    // recede at a fraction of its own drive, so where the drive is zero it stops dead\n    // rather than being pulled backwards by its neighbours' curvature.\n    const back = -RECEDE * drive;\n    if (v < back) v = back;\n    bufVel[i] = v;\n    bufNx[i] = nx;\n    bufNy[i] = ny;\n  }\n  const minX = -BLEED;\n  const minY = -BLEED;\n  const maxX = state.width + BLEED;\n  const maxY = state.height + BLEED;\n  for (let i = 0; i < n; i += 1) {\n    const move = bufVel[i] * dt;\n    const x = xs[i] + bufNx[i] * move;\n    const y = ys[i] + bufNy[i] * move;\n    xs[i] = x < minX ? minX : x > maxX ? maxX : x;\n    ys[i] = y < minY ? minY : y > maxY ? maxY : y;\n  }\n  return true;\n}\n\n/**\n * Re-parameterise the ring to even arclength. This is not tidying: h in the curvature\n * term is the marker spacing, and without this step the spacing at a fast finger tip runs\n * away from the spacing in the slow fjord behind it, so the same second difference means\n * two different curvatures on one ring. That is what kills a marker method on this\n * equation inside a second, and it shows up as a front that tears rather than fingers.\n */\nfunction resample(front: Front): boolean {\n  const { xs, ys, n } = front;\n  bufArc[0] = 0;\n  for (let i = 0; i < n; i += 1) {\n    const j = i + 1 === n ? 0 : i + 1;\n    const dx = xs[j] - xs[i];\n    const dy = ys[j] - ys[i];\n    bufArc[i + 1] = bufArc[i] + Math.sqrt(dx * dx + dy * dy);\n  }\n  const perim = bufArc[n];\n  if (perim < DEATH_PERIMETER) return false;\n  let m = Math.round(perim / SPACING);\n  if (m < MIN_MARKERS) m = MIN_MARKERS;\n  else if (m > MAX_MARKERS) m = MAX_MARKERS;\n  const gap = perim / m;\n  let seg = 0;\n  for (let i = 0; i < m; i += 1) {\n    const want = i * gap;\n    while (seg < n - 1 && bufArc[seg + 1] < want) seg += 1;\n    const span = bufArc[seg + 1] - bufArc[seg];\n    const f = span > 1e-9 ? (want - bufArc[seg]) / span : 0;\n    const j = seg + 1 === n ? 0 : seg + 1;\n    bufX[i] = xs[seg] + (xs[j] - xs[seg]) * f;\n    bufY[i] = ys[seg] + (ys[j] - ys[seg]) * f;\n  }\n  for (let i = 0; i < m; i += 1) {\n    xs[i] = bufX[i];\n    ys[i] = bufY[i];\n  }\n  front.n = m;\n  return true;\n}\n\n/** Back to a dry plate with one drop on it: what the reset control does, and what the dry\n *  phase does of its own accord once its rest is up. */\nfunction restart(state: State) {\n  state.fronts.length = 0;\n  state.mode = 'wet';\n  state.wipe = true;\n  // `drop` zeroes the rest timer, which is the thing that starts the reveal moving.\n  drop(state, state.width * ORIGIN.u, state.height * ORIGIN.v, false);\n}\n\n/** One step of the whole plate: every ring advanced and re-parameterised, the retired\n *  ones dropped, and the phase machine turned over once the plate has gone quiet. */\nfunction step(state: State, dt: number) {\n  const fronts = state.fronts;\n  for (let i = fronts.length - 1; i >= 0; i -= 1) {\n    const front = fronts[i];\n    front.age += dt;\n    let alive = advance(state, front, dt) && resample(front);\n    if (alive) {\n      const area = ringArea(front);\n      // Growth, not speed: a ring parked on the bleed rectangle still jitters at its\n      // corners, so a speed test never fires and the phase machine would never move on.\n      front.still = area - front.area < AREA_STALL * dt ? front.still + 1 : 0;\n      front.area = area;\n      if (front.still > STALL_STEPS || front.age > MAX_AGE) alive = false;\n    }\n    if (!alive) fronts.splice(i, 1);\n  }\n  if (fronts.length > 0) {\n    state.idle = 0;\n    return;\n  }\n  state.idle += dt;\n  if (state.mode === 'wet') {\n    if (state.idle < REST_WET) return;\n    // A partially wetting film on cold glass is metastable, so it tears — and it tears\n    // first on exactly the patches that pinned the front on the way in.\n    for (const spot of state.spots) drop(state, spot.x, spot.y, true);\n    state.mode = 'dry';\n    state.idle = 0;\n    return;\n  }\n  if (state.idle < REST_DRY) return;\n  restart(state);\n}\n\n/**\n * Build the plate's state for one size. `setup` runs again on every resize, so nothing\n * here is resized in place: the offscreen plates are rebuilt, the patches are re-projected\n * into pixels, and the reveal starts over rather than being stretched sideways.\n */\nfunction build(scene: SceneSetupContext, snap: boolean): State {\n  const { width, height, dpr } = scene;\n  const spots: Spot[] = PATCHES.map((patch) => ({\n    x: patch.u * width,\n    y: patch.v * height,\n    ix: 1 / Math.max(1, patch.ru * width),\n    iy: 1 / Math.max(1, patch.rv * height),\n    amp: patch.amp,\n  }));\n  const layers = buildLayers(width, height, dpr);\n  const state: State = {\n    width,\n    height,\n    spots,\n    fronts: [],\n    layers,\n    mode: 'wet',\n    idle: 0,\n    carry: 0,\n    last: performance.now() / 1000,\n    hold: HOLD_PERIOD,\n    holdX: 0,\n    holdY: 0,\n    wasDown: false,\n    next: 0,\n    wipe: false,\n  };\n  if (layers) {\n    paintArt(layers.art, width, height);\n    paintFrost(layers, width, height, dpr);\n  }\n  if (snap) {\n    // Reduced motion: the answer painted rather than approached. The mask is filled\n    // outright and the ring list left empty, so the picture is clear, every bead the\n    // solver would have trapped is already shut, and there is no contact line to draw.\n    if (layers) {\n      layers.mask.globalCompositeOperation = 'source-over';\n      layers.mask.fillStyle = '#000000';\n      layers.mask.fillRect(0, 0, width, height);\n    }\n    return state;\n  }\n  drop(state, width * ORIGIN.u, height * ORIGIN.v, false);\n  // Warm the solver, so the card opens on a blot already fingering rather than on bare\n  // frost waiting for its first frame.\n  for (let t = 0; t < WARM; t += STEP) step(state, STEP);\n  return state;\n}\n\n/** The ring as a closed path. Straight segments between markers: they are five pixels\n *  apart, so a curve through them would move the boundary by less than the antialiasing\n *  of the fill that follows. */\nfunction trace(context: CanvasRenderingContext2D, front: Front) {\n  const { xs, ys, n } = front;\n  context.beginPath();\n  context.moveTo(xs[0], ys[0]);\n  for (let i = 1; i < n; i += 1) context.lineTo(xs[i], ys[i]);\n  context.closePath();\n}\n\n/**\n * Fill every live ring into the accumulating mask: source-over for a spreading film,\n * destination-out for a hole opening in one. This is the entire topology handling. Two\n * overlapping rings union because their fills union; a hole subtracts because its fill\n * subtracts; nothing is ever cut and stitched.\n *\n * The bead falls out of the fill rule rather than out of a special case. When a fjord\n * pinches off, the loop that separates encircles the trapped dry spot with the opposite\n * winding to the main loop, so under nonzero fill the two cancel and the bead stays dry.\n * It then shuts at GAMMA / r, which runs away as r goes to zero — that is the snap. And\n * nothing has to be cut when the loop finally turns itself inside out, because a loop\n * under the critical radius is driven back to nothing by the same v: it cannot reopen as\n * a hole, and re-parameterisation hands it fewer markers every step until it is gone.\n *\n * The mask is not cleared between frames, and that is hysteresis rather than laziness: a\n * contact line that has passed over a spot leaves it wetted behind itself.\n */\nfunction stamp(state: State, layers: Layers) {\n  const mask = layers.mask;\n  mask.fillStyle = '#000000';\n  for (const front of state.fronts) {\n    mask.globalCompositeOperation = front.dry ? 'destination-out' : 'source-over';\n    trace(mask, front);\n    mask.fill('nonzero');\n  }\n  mask.globalCompositeOperation = 'source-over';\n}\n\n/**\n * Paint the plate: the picture behind dry glass, the picture behind wet glass wherever the\n * mask says so, a sheen over both, and the contact line last and in the accent.\n */\nfunction compose(scene: SceneDrawContext<State>, layers: Layers) {\n  const { context, width, height, dpr, state } = scene;\n  context.drawImage(layers.frost.canvas, 0, 0, width, height);\n\n  // The wet region: the sharp picture, cut to the mask on the scratch plate, so the main\n  // canvas never has to carry a clip path with a fjord-edged blot in it.\n  const scratch = layers.scratch;\n  scratch.setTransform(dpr, 0, 0, dpr, 0, 0);\n  scratch.globalCompositeOperation = 'source-over';\n  scratch.clearRect(0, 0, width, height);\n  scratch.drawImage(layers.art.canvas, 0, 0, width, height);\n  scratch.globalCompositeOperation = 'destination-in';\n  scratch.drawImage(layers.mask.canvas, 0, 0, width, height);\n  context.drawImage(scratch.canvas, 0, 0, width, height);\n\n  // Glass, in front of both: one raking band, at the alpha a window has rather than the\n  // alpha a highlight in a mockup has.\n  const sheen = context.createLinearGradient(0, 0, width * 0.72, height);\n  sheen.addColorStop(0, 'rgba(231, 237, 239, 0.055)');\n  sheen.addColorStop(0.35, 'rgba(231, 237, 239, 0.012)');\n  sheen.addColorStop(1, 'rgba(231, 237, 239, 0)');\n  context.fillStyle = sheen;\n  context.fillRect(0, 0, width, height);\n\n  if (state.fronts.length === 0) return;\n\n  /*\n   * The meniscus, as the union of the rings' boundaries. Stroke every ring into scratch,\n   * then delete the interior of every ring from it: what survives is the outer contour of\n   * the union, so the seams where one ring crosses another are gone without anyone having\n   * computed an intersection. Strokes go down in white and are tinted at the end, because\n   * three overlapping bands of a translucent accent would each darken the last.\n   */\n  scratch.globalCompositeOperation = 'source-over';\n  scratch.clearRect(0, 0, width, height);\n  scratch.strokeStyle = '#ffffff';\n  scratch.lineJoin = 'round';\n  for (const band of RIM_BANDS) {\n    scratch.lineWidth = band.width;\n    scratch.globalAlpha = band.alpha;\n    for (const front of state.fronts) {\n      trace(scratch, front);\n      scratch.stroke();\n    }\n  }\n  scratch.globalAlpha = 1;\n  scratch.globalCompositeOperation = 'destination-out';\n  scratch.fillStyle = '#000000';\n  for (const front of state.fronts) {\n    trace(scratch, front);\n    scratch.fill();\n  }\n  scratch.globalCompositeOperation = 'source-in';\n  scratch.fillStyle = ACCENT;\n  scratch.fillRect(0, 0, width, height);\n  scratch.globalCompositeOperation = 'source-over';\n  context.drawImage(scratch.canvas, 0, 0, width, height);\n}\n\n/**\n * Drain what the controls asked for, read the pointer, and advance the solver by the wall\n * clock in fixed steps. A scene is handed no elapsed time, so the clock is read here and\n * the accumulator belongs to the state.\n */\nfunction run(scene: SceneDrawContext<State>, asks: Asks) {\n  const { state, pointer } = scene;\n  const now = performance.now() / 1000;\n  let dt = now - state.last;\n  state.last = now;\n  if (dt < 0) dt = 0;\n  const budget = MAX_SUBSTEPS * STEP;\n  // A tab back from the background owes a minute of solver. It does not get to pay that in\n  // one paint: the arrears are dropped and the plate carries on from where it stopped.\n  if (dt > budget) dt = budget;\n\n  if (asks.resets > 0) {\n    asks.resets = 0;\n    asks.drops = 0;\n    restart(state);\n  }\n  for (let i = 0; i < asks.drops; i += 1) {\n    const place = DROPS[state.next % DROPS.length];\n    state.next += 1;\n    drop(state, place.u * state.width, place.v * state.height, state.mode === 'dry');\n  }\n  asks.drops = 0;\n\n  /*\n   * Press and hold. The press lays one drop at once; holding still grows that drop, and\n   * dragging lays a chain of them, which is why travel and not only time is the test.\n   * On a plate that is already wet a press punches a hole instead — the glass cannot be\n   * wetted twice, and a control that quietly did nothing would be worse than one that\n   * does the opposite thing honestly.\n   */\n  if (pointer.down && pointer.inside) {\n    state.hold += dt;\n    const moved = Math.hypot(pointer.x - state.holdX, pointer.y - state.holdY);\n    if (!state.wasDown || (state.hold >= HOLD_PERIOD && moved >= HOLD_GAP)) {\n      drop(state, pointer.x, pointer.y, state.mode === 'dry');\n      state.hold = 0;\n      state.holdX = pointer.x;\n      state.holdY = pointer.y;\n    }\n    state.wasDown = true;\n  } else {\n    state.wasDown = false;\n  }\n\n  // The clamp above is what bounds this loop: carry can never exceed the budget, so the\n  // most it can spend is MAX_SUBSTEPS steps and no guard counter is needed to say so.\n  state.carry += dt;\n  if (state.carry > budget) state.carry = budget;\n  while (state.carry >= STEP) {\n    step(state, STEP);\n    state.carry -= STEP;\n  }\n}\n\nexport type WettingRevealProps = { compact?: boolean };\n\n/**\n * `compact` is the 298x240 catalogue card: the plate and its title, with the credit, both buttons\n * and the status line dropped. Nothing interactive is lost — the plate itself is the control, since\n * a press places a drop and a hold keeps feeding it — and the mask is solved on the card's own grid\n * rather than sampled from a larger one. The buttons leave the tab order too: the card frame is\n * `aria-hidden` and pointer-live, and its title link is the accessible path to the item.\n */\nexport function WettingReveal({ compact = false }: WettingRevealProps) {\n  const reduced = useReducedMotion();\n  const [status, setStatus] = useState(HINT);\n  /** Bumped by both controls, so the effect below has something to depend on. */\n  const [nudge, setNudge] = useState(0);\n  /** The controls' side of the channel into the scene. */\n  const asksRef = useRef<Asks>({ drops: 0, resets: 0 });\n\n  const setup = (scene: SceneSetupContext) => build(scene, reduced);\n\n  const draw = (scene: SceneDrawContext<State>) => {\n    const { context, width, height, state } = scene;\n    const asks = asksRef.current;\n    /*\n     * `reduced` is read live here rather than latched into the state, because the hook\n     * replaces this closure on every render and rebuilds the scene when the preference\n     * flips — so this is always the current answer, and a copy would only be able to go\n     * stale. With it set, the mask is already full and the asks are drained and dropped:\n     * there is no reveal left for a drop to change, and the status line says exactly that\n     * rather than pretending a press did something.\n     */\n    if (reduced) {\n      asks.drops = 0;\n      asks.resets = 0;\n    } else {\n      run(scene, asks);\n    }\n\n    context.clearRect(0, 0, width, height);\n    const layers = state.layers;\n    if (!layers) {\n      // No offscreen context to be had. The picture, straight onto the plate, at the state\n      // the reveal settles on — the same answer reduced motion is given, which is why this\n      // branch is a real fallback and not a stub.\n      paintArt(context, width, height);\n      return;\n    }\n    if (state.wipe) {\n      state.wipe = false;\n      layers.mask.globalCompositeOperation = 'source-over';\n      layers.mask.clearRect(0, 0, width, height);\n    }\n    stamp(state, layers);\n    compose(scene, layers);\n  };\n\n  const { stageRef, canvasRef, requestRender } = useCanvasScene<State>({ setup, draw });\n\n  // The controls write to a ref, which is what the scene reads. This asks for the frame\n  // that drains it, so a press lands even with the loop paused or stopped outright.\n  useEffect(() => {\n    requestRender();\n  }, [nudge, requestRender]);\n\n  const addDrop = useCallback(() => {\n    asksRef.current.drops += 1;\n    setNudge((count) => count + 1);\n    setStatus(AFTER_DROP);\n  }, []);\n\n  const resetGlass = useCallback(() => {\n    asksRef.current.resets += 1;\n    setNudge((count) => count + 1);\n    setStatus(AFTER_RESET);\n  }, []);\n\n  return (\n    <div className=\"wetting-reveal-stage\" data-compact={compact ? 'true' : undefined}>\n      <figure className=\"wetting-reveal-card\">\n        {/* The plate holds the canvas and nothing else: it takes pointer capture on a\n            press, so a control placed inside it would lose the click that started there. */}\n        <div ref={stageRef} className=\"wetting-reveal-glass\" aria-hidden=\"true\">\n          <canvas ref={canvasRef} />\n        </div>\n        <figcaption className=\"wetting-reveal-caption\">\n          <div className=\"wetting-reveal-lines\">\n            <h3 className=\"wetting-reveal-title\">{ART_TITLE}</h3>\n            <p className=\"wetting-reveal-credit\">{ART_CREDIT}</p>\n          </div>\n          <div className=\"wetting-reveal-controls\" role=\"group\" aria-label=\"Wet or dry the glass\">\n            <button\n              type=\"button\"\n              className=\"wetting-reveal-key\"\n              tabIndex={compact ? -1 : undefined}\n              onClick={addDrop}\n            >\n              Add a drop\n            </button>\n            <button\n              type=\"button\"\n              className=\"wetting-reveal-key\"\n              tabIndex={compact ? -1 : undefined}\n              onClick={resetGlass}\n            >\n              Reset the glass\n            </button>\n          </div>\n        </figcaption>\n      </figure>\n      <p className=\"wetting-reveal-status\" role=\"status\">\n        {reduced ? REDUCED : status}\n      </p>\n    </div>\n  );\n}\n\nexport default WettingReveal;\n","type":"registry:ui"},{"path":"components/ui/wetting-reveal.css","target":"components/ui/wetting-reveal.css","content":"/*\n * Two colours and a tint: bone `#e7edef` type over a near-black ground, and the accent\n * `#78e0cf` spent on one thing only — the contact line the solver moves. The picture\n * under the glass is drawn in the same bone at low alphas, so nothing here is a second\n * hue pretending to be a highlight.\n */\n.wetting-reveal-stage {\n  position: relative;\n  display: grid;\n  place-content: center;\n  width: 100%;\n  min-height: 26rem;\n  padding: 2rem 1.25rem;\n  overflow: hidden;\n  border-radius: 0.875rem;\n  background: radial-gradient(125% 115% at 50% 0%, #0e1418 0%, #090d10 58%, #05080a 100%);\n  color: #e7edef;\n}\n\n/* The gallery card: a mount, a plate, and a label. The mount's padding is the whole\n   reason the plate reads as glazed rather than as a full-bleed picture. */\n.wetting-reveal-card {\n  position: relative;\n  width: min(23rem, 100%);\n  margin: 0;\n  padding: 0.75rem 0.75rem 0.875rem;\n  border: 1px solid rgba(231, 237, 239, 0.1);\n  border-radius: 0.875rem;\n  background: linear-gradient(180deg, rgba(231, 237, 239, 0.05), rgba(231, 237, 239, 0.015));\n}\n\n/*\n * The plate. `overflow: hidden` here is what rounds the liquid's corners — the solver\n * knows nothing about a border radius, and its markers are allowed to park eleven\n * pixels outside this box so that a corner rounded at the critical radius still ends\n * up outside the visible rect instead of leaving four dry notches.\n */\n.wetting-reveal-glass {\n  position: relative;\n  width: 100%;\n  aspect-ratio: 3 / 2;\n  overflow: hidden;\n  border-radius: 0.5rem;\n  background: #0a0e11;\n  cursor: crosshair;\n  /* `pan-y`, not `none`: the plate is most of the card, and a surface that swallows\n     vertical touches traps a phone inside a scrolling gallery of them. */\n  touch-action: pan-y;\n  -webkit-user-select: none;\n  user-select: none;\n}\n\n.wetting-reveal-glass canvas {\n  position: absolute;\n  inset: 0;\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n\n/* The label is a sibling of the plate, never a child of it: the plate takes pointer\n   capture while a drop is being placed, and a button inside it would lose its click. */\n.wetting-reveal-caption {\n  display: flex;\n  flex-wrap: wrap;\n  align-items: flex-end;\n  justify-content: space-between;\n  gap: 0.625rem 1rem;\n  margin-top: 0.8125rem;\n}\n\n.wetting-reveal-lines {\n  min-width: 0;\n}\n\n.wetting-reveal-title {\n  margin: 0;\n  font-size: 0.9375rem;\n  font-weight: 500;\n  line-height: 1.25;\n  letter-spacing: -0.012em;\n}\n\n.wetting-reveal-credit {\n  margin: 0.3125rem 0 0;\n  font-size: 0.75rem;\n  line-height: 1.4;\n  color: rgba(231, 237, 239, 0.5);\n}\n\n.wetting-reveal-controls {\n  display: flex;\n  gap: 0.375rem;\n}\n\n.wetting-reveal-key {\n  appearance: none;\n  margin: 0;\n  padding: 0.375rem 0.75rem;\n  border: 1px solid rgba(231, 237, 239, 0.16);\n  border-radius: 999px;\n  background: rgba(9, 13, 16, 0.55);\n  font: inherit;\n  font-size: 0.75rem;\n  font-weight: 500;\n  color: rgba(231, 237, 239, 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.wetting-reveal-key:hover {\n  border-color: rgba(120, 224, 207, 0.5);\n  color: #f1fbf8;\n}\n\n.wetting-reveal-key:focus-visible {\n  outline: 2px solid rgba(120, 224, 207, 0.8);\n  outline-offset: 2px;\n}\n\n/* A live region, because both buttons act on a plate that is `aria-hidden`. Without it\n   a screen-reader user presses a control whose only effect is invisible to them. */\n.wetting-reveal-status {\n  max-width: 23rem;\n  margin: 0.9375rem 0 0;\n  font: 500 0.6875rem/1.55 ui-monospace, 'SFMono-Regular', Menlo, monospace;\n  letter-spacing: 0.045em;\n  color: rgba(231, 237, 239, 0.36);\n}\n\n/* Under about 26rem the label takes a second row and the status line a third, so the\n   section is told to grow rather than squeezing the plate out of proportion. */\n@media (max-width: 26rem) {\n  .wetting-reveal-stage {\n    min-height: 29rem;\n    padding: 1.5rem 1rem;\n  }\n\n  .wetting-reveal-card {\n    padding: 0.625rem 0.625rem 0.75rem;\n  }\n\n  .wetting-reveal-title {\n    font-size: 0.875rem;\n  }\n\n  .wetting-reveal-caption {\n    align-items: flex-start;\n    flex-direction: column;\n  }\n\n  .wetting-reveal-controls {\n    width: 100%;\n  }\n\n  .wetting-reveal-key {\n    flex: 1 1 auto;\n  }\n}\n\n/*\n * What reduced motion removes is the spreading, not the picture. `setup` fills the mask\n * outright, so the plate is painted at the state the solver would have reached — every\n * front run out, every trapped bead closed — and the rAF loop never starts. The buttons\n * stay live and stay honest: with the plate already at its answer there is no reveal\n * left for a drop to change, and the status line says so rather than pretending.\n */\n@media (prefers-reduced-motion: reduce) {\n  .wetting-reveal-key {\n    transition: none;\n  }\n}\n\n/*\n * The card variant: the 298x240 catalogue frame, at that real size and never scaled. The plate\n * keeps `aspect-ratio: 3 / 2` wherever the frame has width for it, and the mask is re-solved for\n * whatever box it lands in either way — the front is run on the card's own grid, never sampled\n * from a larger one.\n *\n * The credit, both buttons and the status line go; the plate and its title stay. The plate is the\n * control here — a press places a drop and a hold keeps feeding it — so the card loses nothing\n * interactive by dropping the buttons, and it gains the height they were taking. The buttons still\n * leave the tab order under `compact` as well as the layout, so the invariant lives in the JSX\n * rather than depending on this file.\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 29rem stage.\n */\n.wetting-reveal-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, which\n     for a mount whose only sized child is an `aspect-ratio` plate is nothing at all. Stretched,\n     the track is the card frame and `width: 100%` on the card resolves against it. */\n  place-content: stretch;\n  padding: 0.4375rem;\n  /* The card frame rounds and clips already. */\n  border-radius: 0;\n}\n\n/*\n * A flex column, and the plate is the flexible child. `aspect-ratio: 3 / 2` on its own is only\n * safe while the frame is exactly 298 wide: the grid hands a phone a 357px card, `width: 100%`\n * then asks for a 218px plate, and the mount plus the label push 35px past the 240 the frame\n * clips at. Bounded by height instead — `flex: 1 1 0` sets the height from what is left, the\n * ratio derives the width from that, and `max-width: 100%` keeps a tall frame from pushing the\n * plate out sideways — the proportion is held wherever there is width for it and the slack goes to\n * the margins, where a mount is supposed to have it. Where there is not, `max-width` wins and the\n * plate fills: the front is re-solved on that grid, so a squarer plate is a squarer pour.\n */\n.wetting-reveal-stage[data-compact='true'] .wetting-reveal-card {\n  display: flex;\n  flex-direction: column;\n  width: 100%;\n  min-height: 0;\n  padding: 0.5rem 0.5rem 0.5625rem;\n  border-radius: 0.75rem;\n}\n\n.wetting-reveal-stage[data-compact='true'] .wetting-reveal-glass {\n  flex: 1 1 0;\n  width: auto;\n  min-height: 0;\n  max-width: 100%;\n  margin-inline: auto;\n}\n\n.wetting-reveal-stage[data-compact='true'] .wetting-reveal-credit,\n.wetting-reveal-stage[data-compact='true'] .wetting-reveal-controls,\n.wetting-reveal-stage[data-compact='true'] .wetting-reveal-status {\n  display: none;\n}\n\n.wetting-reveal-stage[data-compact='true'] .wetting-reveal-caption {\n  align-items: center;\n  flex-direction: row;\n  margin-top: 0.5rem;\n}\n\n.wetting-reveal-stage[data-compact='true'] .wetting-reveal-title {\n  font-size: 0.875rem;\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","transitions"],"docs":"https://ui.artbloom.tech/artbloom/animations/wetting-reveal"}}