{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"shutter-compare","type":"registry:ui","title":"Shutter Compare","description":"A before/after slider whose divider is a focal-plane shutter. The second curtain is latched one exposure time after the first rather than pinned to it, so past the sync speed the reveal arrives as a travelling band instead of an edge.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/shutter-compare.tsx","target":"components/ui/shutter-compare.tsx","content":"'use client';\n\nimport './shutter-compare.css';\n\nimport { useEffect, useRef, useState, type KeyboardEvent } from 'react';\n\nimport {\n  useCanvasScene,\n  useReducedMotion,\n  type SceneDrawContext,\n  type SceneSetupContext,\n} from '@/hooks/use-canvas-scene';\n\n/**\n * A before/after comparison slider whose divider is the trailing edge of a\n * focal-plane shutter curtain.\n *\n * Two curtains cross the frame. Each is a mass on a spring released by a latch and\n * integrates m·x'' = −k·(x − x_rest) − b·x' + F_drive, with the mass absorbed into\n * the constants. The two force terms are gated by position, which is the split a\n * mechanical shutter actually has: the mainspring supplies a constant F_drive\n * through free travel and is cut at a buffer an eighth of the frame from the stop,\n * and the return spring −k·(x − x_rest) is engaged only inside that buffer. So the\n * traverse is flat and the arrest is a spring, rather than the quarter-sine one\n * spring pulling from end to end would give.\n *\n * The second curtain is not geometrically pinned to the first. It is latched, and\n * its latch is released one exposure time after the first curtain's, so the slit is\n * whatever gap the two have opened up by then — at full travel, frame · exposure /\n * traverse. That ratio is the whole mechanism. At 1/250 the gap is exactly one\n * frame: the X-sync limit of a mechanical shutter, and the reason a flash cannot be\n * used faster than it. Above it the slit is narrower than the frame, so the reveal\n * is not a divider crossing an already-open gate but a moving band — one edge of\n * the frame is exposed measurably later than the other, which is the honest\n * behaviour of a focal-plane shutter and the reason rolling-shutter distortion\n * exists at all.\n *\n * Dragging sets the slit position directly, so the handle leads and the curtains\n * lag behind it under their springs; letting go leaves the springs to travel to\n * rest. A press far from the divider is a full release: both curtains traverse at\n * mainspring speed, which is the only time the gap reaches its stated width.\n *\n * STEP is 1/120. The springs are not stiff: the return spring is k = 784 s⁻² per\n * unit mass, so ω is 28 rad/s and ω·STEP is 0.23 — an eighth of what semi-implicit\n * Euler carries. 1/240 would buy nothing but heat, because the fast part of the\n * motion is the drive-limited traverse and that is a straight line no step size\n * renders better.\n *\n * What is compared is one drawing under two treatments, both composed here: the\n * same five-room plan as a setting-out wireframe — grid, single-weight outlines,\n * door swings, a dimension chain — on the side the slit has not reached, and as a\n * rendered plan with poché walls, floor tints and filled fittings on the side it\n * has passed.\n *\n * Under prefers-reduced-motion the loop never starts, so `setup` composes the still\n * itself: the curtains parted at the commanded position by the gap the selected\n * exposure implies, wireframe left of the divider, rendered plan right of it, and\n * the lit slit between the two curtain edges at its real width.\n *\n * The one thing here that is a rendering choice rather than the mechanism: the frame\n * keeps what the slit has passed over, the way film does, and dragging back resets\n * it. A shutter cannot un-expose. A comparison slider has to be reversible.\n */\n\n/** Seconds per step. */\nconst STEP = 1 / 120;\n/** 10 steps span 83ms, so a 12fps frame is still integrated in full. */\nconst MAX_SUBSTEPS = 10;\n/** Return-spring rate per unit curtain mass, s⁻². ω = 28 rad/s. */\nconst K = 784;\n/** Damping per unit mass, s⁻¹. ζ = 0.92, which puts the stop at about 0.2s and no ring. */\nconst B = 51.5;\n/**\n * Seconds a curtain takes to cross the whole frame under the mainspring. A real\n * one does it in about 4ms, which is nothing to look at; the ratio exposure /\n * traverse is the only quantity that sets the slit width, and the shutter speeds\n * below are scaled against this so that ratio is preserved exactly.\n */\nconst TRAVEL = 0.3;\n/**\n * Fraction of the frame the return spring owns at the end of the travel. With\n * TRAVEL, this fixes ω: arresting v = frame/TRAVEL inside BUFFER·frame without\n * overshoot needs ω ≈ 1/(TRAVEL·BUFFER) = 28, and the frame width cancels — which\n * is why one ω works at every card size.\n */\nconst BUFFER = 0.12;\n/** The exposure whose slit is exactly one frame wide. A real shutter's X-sync speed. */\nconst SYNC = 250;\nconst SPEEDS = [60, 250, 1000, 4000];\nconst START_SPEED = 250;\nconst START_VALUE = 45;\nconst KEY_STEP = 2;\nconst PAGE_STEP = 10;\n\nconst ACCENT = '255, 201, 120';\nconst INK = '236, 243, 255';\n\n/** The setting-out grid the plan is drawn on. Both treatments read from it. */\nconst GRID_W = 12;\nconst GRID_H = 8;\n\n/** Rooms, as an exact partition of the grid: [x, y, w, h]. */\nconst ROOMS = [\n  [0, 0, 7, 5],\n  [7, 0, 5, 5],\n  [0, 5, 4, 3],\n  [4, 5, 2, 3],\n  [6, 5, 6, 3],\n];\n\n/** Fittings. Outlines under the wireframe, filled masses under the render. */\nconst BLOCKS = [\n  [0.6, 3.5, 3.6, 1],\n  [4.7, 2.5, 1.7, 1.4],\n  [7.4, 0.4, 4.2, 0.8],\n  [8.5, 2.3, 2.2, 1],\n  [0.4, 5.4, 1.1, 2.2],\n  [2.5, 5.4, 1.1, 1.1],\n  [7.1, 5.6, 2.4, 1.9],\n  [10.4, 5.4, 1.2, 2.2],\n];\n\n/** The two fittings the rendered side picks out in the accent. Indices into BLOCKS. */\nconst LIT = [1, 6];\n\n/** Door swings: [hinge x, hinge y, radius, quarter turn the leaf opens through]. */\nconst DOORS = [\n  [7, 1.75, 0.95, 0],\n  [4.9, 5, 0.9, 1],\n  [4, 6.3, 0.85, 3],\n  [6, 6.3, 0.85, 0],\n];\n\n/** The lines the plan is dimensioned from, in grid units. */\nconst DIM_X = [0, 7, 12];\nconst DIM_Y = [0, 5, 8];\n\ninterface Curtain {\n  /** The edge that matters, in px from the left of the frame. */\n  x: number;\n  v: number;\n  /** Where this curtain's return spring wants that edge. */\n  rest: number;\n}\n\ninterface State {\n  clock: number;\n  carry: number;\n  snap: boolean;\n  /** Plan geometry. All of it is derived from the frame size, in `setup`. */\n  ox: number;\n  oy: number;\n  scale: number;\n  wall: number;\n  dim: number;\n  /** The first curtain's trailing edge: the divider between the two treatments. */\n  first: Curtain;\n  /** The second curtain's leading edge: the far side of the slit. */\n  second: Curtain;\n  /** Seconds of latch left on the second curtain. Above zero it is held shut. */\n  hold: number;\n  /** Commanded divider position in px, written from the slider value each frame. */\n  cmd: number;\n  lastCmd: number;\n  /** Seconds the second curtain's latch waits — the exposure time, scaled. */\n  delay: number;\n  /** The gap that delay opens at full travel. Only used to place the still frame. */\n  slit: number;\n  /** Mainspring force per unit mass, set so a full traverse takes TRAVEL seconds. */\n  drive: number;\n  /** Where the return spring takes over from the mainspring, in px. */\n  buffer: number;\n}\n\nconst px = (s: State, u: number): number => s.ox + u * s.scale;\nconst py = (s: State, v: number): number => s.oy + v * s.scale;\n\n/**\n * One fixed step of m·x'' = −k·(x − x_rest) − b·x' + F_drive for one curtain.\n *\n * Both force terms are gated on how far the curtain still has to go. Outside the\n * buffer only the mainspring and the damper act, so the curtain settles onto a\n * terminal velocity of drive/b and holds it — a flat traverse, which is what a\n * curtain crossing a gate does. Inside the buffer the mainspring is cut and the\n * return spring takes the velocity out. Letting the return spring pull across the\n * whole frame instead would make the traverse a quarter-sine: fastest in the middle,\n * asymptotically slow at the end, and the slit would change width as it crossed.\n */\nfunction advance(c: Curtain, drive: number, buffer: number): void {\n  const gap = c.rest - c.x;\n  const free = Math.abs(gap) > buffer;\n  const push = free ? Math.sign(gap) * drive : 0;\n  const spring = free ? 0 : K * gap;\n  c.v += (spring - B * c.v + push) * STEP;\n  c.x += c.v * STEP;\n}\n\n/**\n * The latch, then both curtains.\n *\n * A command toward the open side is a release: the first curtain is let go at once\n * and the second is held where it stands for the exposure time, which is what opens\n * the slit. A command the other way is a re-cock, and winding a shutter back pulls\n * both curtains together, so the latch is dropped and no slit opens on the way back.\n */\nfunction step(s: State): void {\n  if (s.cmd < s.lastCmd - 0.05) {\n    // Only re-armable once the second curtain has caught the first. Mid-exposure\n    // the latch is already spent and cannot be spent again.\n    if (s.hold <= 0 && s.second.x - s.first.x < s.buffer * 0.5) {\n      s.hold = s.delay;\n      s.second.rest = s.second.x;\n    }\n  } else if (s.cmd > s.lastCmd + 0.05) {\n    s.hold = 0;\n  }\n  s.lastCmd = s.cmd;\n\n  s.first.rest = s.cmd;\n  if (s.hold > 0) s.hold -= STEP;\n  else s.second.rest = s.cmd;\n\n  advance(s.first, s.drive, s.buffer);\n  advance(s.second, s.drive, s.buffer);\n}\n\n/**\n * The mid-exposure still: the first curtain on the commanded divider, the second\n * parted from it by the gap this exposure implies at full travel, both stationary.\n * This is what `prefers-reduced-motion` gets, and it is also the first painted frame\n * when the loop is running — a shutter caught open reads as a mechanism, where a\n * closed one reads as a line down a picture.\n */\nfunction part(s: State, width: number): void {\n  s.first.x = s.cmd;\n  s.first.v = 0;\n  s.first.rest = s.cmd;\n  s.second.x = Math.min(width, s.cmd + s.slit);\n  s.second.v = 0;\n  s.second.rest = s.second.x;\n  s.hold = 0;\n  s.lastCmd = s.cmd;\n}\n\n/** The side the slit has not reached: the plan as it is set out, not as it is built. */\nfunction latent(ctx: CanvasRenderingContext2D, s: State): void {\n  ctx.lineJoin = 'miter';\n  ctx.lineWidth = 1;\n\n  ctx.strokeStyle = `rgba(${INK}, 0.07)`;\n  ctx.beginPath();\n  for (let u = 0; u <= GRID_W; u += 1) {\n    ctx.moveTo(px(s, u), py(s, 0));\n    ctx.lineTo(px(s, u), py(s, GRID_H));\n  }\n  for (let v = 0; v <= GRID_H; v += 1) {\n    ctx.moveTo(px(s, 0), py(s, v));\n    ctx.lineTo(px(s, GRID_W), py(s, v));\n  }\n  ctx.stroke();\n\n  ctx.strokeStyle = `rgba(${INK}, 0.44)`;\n  ctx.beginPath();\n  for (const r of ROOMS) {\n    ctx.rect(px(s, r[0]), py(s, r[1]), r[2] * s.scale, r[3] * s.scale);\n  }\n  ctx.stroke();\n\n  ctx.strokeStyle = `rgba(${INK}, 0.24)`;\n  ctx.beginPath();\n  for (const b of BLOCKS) {\n    ctx.rect(px(s, b[0]), py(s, b[1]), b[2] * s.scale, b[3] * s.scale);\n  }\n  ctx.stroke();\n\n  // Door leaf and swing, one path each: a shared path would join the end of one arc\n  // to the start of the next with a line across the plan.\n  ctx.strokeStyle = `rgba(${INK}, 0.3)`;\n  for (const d of DOORS) {\n    const from = (d[3] * Math.PI) / 2;\n    const r = d[2] * s.scale;\n    ctx.beginPath();\n    ctx.moveTo(px(s, d[0]), py(s, d[1]));\n    ctx.lineTo(px(s, d[0]) + Math.cos(from) * r, py(s, d[1]) + Math.sin(from) * r);\n    ctx.arc(px(s, d[0]), py(s, d[1]), r, from, from + Math.PI / 2);\n    ctx.stroke();\n  }\n\n  // The dimension chain, in the margin `setup` reserved for it.\n  const chainY = s.oy - s.dim + 4;\n  const chainX = s.ox - s.dim + 4;\n  ctx.strokeStyle = `rgba(${INK}, 0.1)`;\n  ctx.beginPath();\n  for (const u of DIM_X) {\n    ctx.moveTo(px(s, u), chainY + 3);\n    ctx.lineTo(px(s, u), py(s, 0));\n  }\n  for (const v of DIM_Y) {\n    ctx.moveTo(chainX + 3, py(s, v));\n    ctx.lineTo(px(s, 0), py(s, v));\n  }\n  ctx.stroke();\n\n  ctx.strokeStyle = `rgba(${INK}, 0.26)`;\n  ctx.beginPath();\n  ctx.moveTo(px(s, 0), chainY);\n  ctx.lineTo(px(s, GRID_W), chainY);\n  ctx.moveTo(chainX, py(s, 0));\n  ctx.lineTo(chainX, py(s, GRID_H));\n  for (const u of DIM_X) {\n    ctx.moveTo(px(s, u), chainY - 4);\n    ctx.lineTo(px(s, u), chainY + 4);\n  }\n  for (const v of DIM_Y) {\n    ctx.moveTo(chainX - 4, py(s, v));\n    ctx.lineTo(chainX + 4, py(s, v));\n  }\n  ctx.stroke();\n}\n\n/** The side the slit has passed: the same plan built. */\nfunction exposed(ctx: CanvasRenderingContext2D, s: State): void {\n  ctx.lineJoin = 'miter';\n\n  ROOMS.forEach((r, i) => {\n    ctx.fillStyle = `rgba(${INK}, ${i % 2 ? 0.055 : 0.095})`;\n    ctx.fillRect(px(s, r[0]), py(s, r[1]), r[2] * s.scale, r[3] * s.scale);\n  });\n\n  // Poché: the room outlines stroked at wall thickness, so every shared edge gets\n  // one wall of the right weight and the perimeter gets half of one.\n  ctx.strokeStyle = `rgba(${INK}, 0.62)`;\n  ctx.lineWidth = s.wall;\n  ctx.beginPath();\n  for (const r of ROOMS) {\n    ctx.rect(px(s, r[0]), py(s, r[1]), r[2] * s.scale, r[3] * s.scale);\n  }\n  ctx.stroke();\n\n  BLOCKS.forEach((b, i) => {\n    ctx.fillStyle = LIT.includes(i) ? `rgba(${ACCENT}, 0.72)` : `rgba(${INK}, 0.28)`;\n    ctx.fillRect(px(s, b[0]), py(s, b[1]), b[2] * s.scale, b[3] * s.scale);\n  });\n}\n\n/**\n * The divider the two treatments meet on, with its shading falling across the side\n * the reveal has not reached yet.\n */\nfunction divider(ctx: CanvasRenderingContext2D, height: number, x: number): void {\n  const fade = ctx.createLinearGradient(x, 0, x - 30, 0);\n  fade.addColorStop(0, 'rgba(5, 8, 14, 0.6)');\n  fade.addColorStop(1, 'rgba(5, 8, 14, 0)');\n  ctx.fillStyle = fade;\n  ctx.fillRect(x - 30, 0, 30, height);\n\n  ctx.strokeStyle = `rgba(${ACCENT}, 0.9)`;\n  ctx.lineWidth = 1.5;\n  ctx.beginPath();\n  ctx.moveTo(x, 0);\n  ctx.lineTo(x, height);\n  ctx.stroke();\n}\n\nfunction paint(ctx: CanvasRenderingContext2D, width: number, height: number, s: State): void {\n  const a = Math.max(0, Math.min(width, s.first.x));\n  const b = Math.max(a, Math.min(width, s.second.x));\n\n  ctx.clearRect(0, 0, width, height);\n\n  if (a > 0.5) {\n    ctx.save();\n    ctx.beginPath();\n    ctx.rect(0, 0, a, height);\n    ctx.clip();\n    latent(ctx, s);\n    ctx.restore();\n  }\n\n  if (width - a > 0.5) {\n    ctx.save();\n    ctx.beginPath();\n    ctx.rect(a, 0, width - a, height);\n    ctx.clip();\n    // A faint lift on the exposed field, so the two sides read apart in the margins\n    // where the drawing itself has nothing to show.\n    ctx.fillStyle = 'rgba(255, 248, 234, 0.035)';\n    ctx.fillRect(a, 0, width - a, height);\n    exposed(ctx, s);\n    ctx.restore();\n  }\n\n  // The slit: the band that is open right now, brightest at the edge that opened it.\n  if (b - a > 0.75) {\n    const lit = ctx.createLinearGradient(a, 0, b, 0);\n    lit.addColorStop(0, `rgba(${ACCENT}, 0.2)`);\n    lit.addColorStop(1, `rgba(${ACCENT}, 0.04)`);\n    ctx.fillStyle = lit;\n    ctx.fillRect(a, 0, b - a, height);\n  }\n\n  divider(ctx, height, a);\n}\n\n/** `compact` is the 298x240 catalogue card: the same two curtains and the same speed\n *  buttons, with the tags and the hint dropped. The frame is measured off the canvas box,\n *  so the traverse and the slit are the same solve — see `shutter-compare.css`. */\nexport type ShutterCompareProps = { compact?: boolean };\n\nexport function ShutterCompare({ compact = false }: ShutterCompareProps) {\n  const reduced = useReducedMotion();\n  // The pointer, the keys and the solver all read the divider, so the ref is the\n  // single source of truth and the React state is only the copy the label and the\n  // grip's position render from.\n  const valueRef = useRef(START_VALUE);\n  const [value, setValue] = useState(START_VALUE);\n  const [speed, setSpeed] = useState(START_SPEED);\n  const handleRef = useRef<HTMLDivElement>(null);\n\n  const commit = (next: number) => {\n    const clamped = Math.round(Math.min(100, Math.max(0, next)));\n    if (clamped === valueRef.current) return;\n    valueRef.current = clamped;\n    setValue(clamped);\n  };\n\n  const setup = ({ width, height }: SceneSetupContext): State => {\n    // Every number below is read here and only here: `setup` re-runs on resize and\n    // on a DPR change, so nothing downstream can hold geometry from the old size.\n    const padX = Math.max(16, Math.min(30, width * 0.055));\n    const top = Math.min(80, Math.max(44, height * 0.2));\n    const bottom = Math.min(92, Math.max(52, height * 0.24));\n    const dim = 15;\n    const boxW = Math.max(40, width - padX * 2 - dim);\n    const boxH = Math.max(40, height - top - bottom - dim);\n    const scale = Math.min(boxW / GRID_W, boxH / GRID_H);\n\n    const state: State = {\n      clock: 0,\n      carry: 0,\n      snap: reduced,\n      ox: padX + dim + (boxW - GRID_W * scale) / 2,\n      oy: top + dim + (boxH - GRID_H * scale) / 2,\n      scale,\n      wall: Math.max(2.5, scale * 0.17),\n      dim,\n      first: { x: 0, v: 0, rest: 0 },\n      second: { x: 0, v: 0, rest: 0 },\n      hold: 0,\n      cmd: (valueRef.current / 100) * width,\n      lastCmd: 0,\n      delay: TRAVEL * (SYNC / speed),\n      slit: Math.min(width, (width * SYNC) / speed),\n      drive: B * (width / TRAVEL),\n      buffer: Math.max(24, width * BUFFER),\n    };\n\n    part(state, width);\n    return state;\n  };\n\n  const draw = ({ context, width, height, state, pointer }: SceneDrawContext<State>) => {\n    const now = performance.now() / 1000;\n    const dt = state.clock === 0 ? 0 : Math.min(0.05, now - state.clock);\n    state.clock = now;\n    state.snap = reduced;\n    // The two numbers the speed buttons own. Written every frame rather than read\n    // through a closure, so a press changes the mechanism and not the drawing.\n    state.delay = TRAVEL * (SYNC / speed);\n    state.slit = Math.min(width, (width * SYNC) / speed);\n\n    if (pointer.down && pointer.inside) {\n      commit((pointer.x / Math.max(1, width)) * 100);\n    }\n    state.cmd = (valueRef.current / 100) * width;\n\n    if (state.snap) {\n      part(state, width);\n    } else {\n      state.carry += dt;\n      let n = 0;\n      while (state.carry >= STEP && n < MAX_SUBSTEPS) {\n        step(state);\n        state.carry -= STEP;\n        n += 1;\n      }\n      if (n === MAX_SUBSTEPS) state.carry = 0;\n    }\n\n    paint(context, width, height, state);\n  };\n\n  const { stageRef, canvasRef, requestRender } = useCanvasScene<State>({ setup, draw });\n\n  // With the loop stopped, nothing else would repaint: the still would keep the old\n  // divider and the old slit width however many keys were pressed.\n  useEffect(() => requestRender(), [value, speed, reduced, requestRender]);\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    const at = valueRef.current;\n    let next = at;\n    if (event.key === 'ArrowRight' || event.key === 'ArrowUp') next = at + KEY_STEP;\n    else if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') next = at - KEY_STEP;\n    else if (event.key === 'PageUp') next = at + PAGE_STEP;\n    else if (event.key === 'PageDown') next = at - PAGE_STEP;\n    else if (event.key === 'Home') next = 0;\n    else if (event.key === 'End') next = 100;\n    else return;\n    event.preventDefault();\n    commit(next);\n    requestRender();\n  };\n\n  return (\n    // A press hands focus to the divider, since the control is transparent to the\n    // pointer and would otherwise only ever be reachable by Tab. In a card there is\n    // nothing to hand focus to — the whole frame is aria-hidden — so the press is left\n    // to the canvas alone.\n    <div\n      className=\"shutter-compare-stage\"\n      data-compact={compact ? 'true' : undefined}\n      onPointerDown={compact ? undefined : () => handleRef.current?.focus()}\n    >\n      <div ref={stageRef} className=\"shutter-compare-frame\" aria-hidden=\"true\">\n        <canvas ref={canvasRef} />\n      </div>\n\n      <div className=\"shutter-compare-ui\">\n        <div>\n          <p className=\"shutter-compare-label\">Unit 4b · plan</p>\n          <p className=\"shutter-compare-read\">\n            {value}\n            <span className=\"shutter-compare-unit\">% built</span>\n          </p>\n        </div>\n\n        <div className=\"shutter-compare-base\">\n          <div className=\"shutter-compare-foot\">\n            <p className=\"shutter-compare-tag\">\n              <b>setting out</b>\n            </p>\n            <p className=\"shutter-compare-tag shutter-compare-tag-end\">\n              <b>built</b>\n            </p>\n          </div>\n\n          <div className=\"shutter-compare-speeds\" role=\"group\" aria-label=\"Shutter speed\">\n            {SPEEDS.map((each) => (\n              <button\n                key={each}\n                type=\"button\"\n                className=\"shutter-compare-speed\"\n                aria-pressed={each === speed}\n                /* Still pressable in a card, but out of the tab order: the card frame is\n                   aria-hidden, and a focusable node inside one is a trap with no label. */\n                tabIndex={compact ? -1 : undefined}\n                onClick={() => setSpeed(each)}\n              >\n                1/{each}\n              </button>\n            ))}\n          </div>\n        </div>\n      </div>\n\n      {/* A sibling of the tracked element, never a child of it: the canvas host\n          takes pointer capture on pointerdown and would swallow this one's focus\n          and its keys. */}\n      <div\n        ref={handleRef}\n        className=\"shutter-compare-handle\"\n        style={{ left: `${value}%` }}\n        role=\"slider\"\n        tabIndex={compact ? -1 : 0}\n        aria-label=\"Reveal divider\"\n        aria-orientation=\"horizontal\"\n        aria-valuemin={0}\n        aria-valuemax={100}\n        aria-valuenow={value}\n        aria-valuetext={`${value} percent built, at 1/${speed} second`}\n        onKeyDown={onKeyDown}\n      />\n\n      <p className=\"shutter-compare-hint\">drag · arrows · home/end</p>\n    </div>\n  );\n}\n\nexport default ShutterCompare;\n","type":"registry:ui"},{"path":"components/ui/shutter-compare.css","target":"components/ui/shutter-compare.css","content":"/* shutter-compare — a before/after divider carried by the two curtains of a\n   focal-plane shutter, each a mass on a spring released by its own latch. */\n\n.shutter-compare-stage {\n  position: relative;\n  width: 100%;\n  min-height: 23rem;\n  padding: 1.5rem 1.375rem 1.25rem;\n  overflow: hidden;\n  border-radius: 0.875rem;\n  background: radial-gradient(130% 120% at 50% 0%, #0d1420 0%, #080c15 58%, #05070c 100%);\n  color: #ecf3ff;\n  /* Hairline as an inset shadow rather than a border: the canvas host is inset 0\n     against the padding box and the handle is positioned as a percentage of that\n     same box, so a 1px border would slide the drawn curtain edge off the grip\n     the user is holding. */\n  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.075);\n}\n\n.shutter-compare-frame {\n  position: absolute;\n  inset: 0;\n  touch-action: none;\n}\n\n.shutter-compare-frame canvas {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n\n/* Transparent to the pointer so a press anywhere in the frame commands the\n   divider. Only the shutter-speed buttons take events back. */\n.shutter-compare-ui {\n  position: relative;\n  display: flex;\n  min-height: 19.5rem;\n  flex-direction: column;\n  justify-content: space-between;\n  pointer-events: none;\n}\n\n.shutter-compare-label {\n  margin: 0;\n  font: 500 0.6875rem/1 ui-monospace, 'SFMono-Regular', Menlo, monospace;\n  letter-spacing: 0.14em;\n  text-transform: uppercase;\n  color: rgba(236, 243, 255, 0.5);\n}\n\n.shutter-compare-read {\n  margin: 0.5rem 0 0;\n  font-size: 2rem;\n  font-weight: 500;\n  line-height: 1;\n  letter-spacing: -0.03em;\n  font-variant-numeric: tabular-nums;\n  text-shadow: 0 1px 18px rgba(5, 8, 14, 0.65);\n}\n\n.shutter-compare-unit {\n  margin-left: 0.25em;\n  font-size: 0.875rem;\n  font-weight: 500;\n  letter-spacing: 0;\n  color: rgba(236, 243, 255, 0.5);\n}\n\n/* Both bottom rows in one child, so the flex `space-between` above pins them to\n   the floor together instead of stranding the labels over the drawing. */\n.shutter-compare-base {\n  display: grid;\n  gap: 0.75rem;\n}\n\n.shutter-compare-foot {\n  display: flex;\n  align-items: flex-end;\n  justify-content: space-between;\n  gap: 1rem;\n}\n\n.shutter-compare-tag {\n  margin: 0;\n  font: 500 0.625rem/1.45 ui-monospace, 'SFMono-Regular', Menlo, monospace;\n  letter-spacing: 0.1em;\n  text-transform: uppercase;\n  color: rgba(236, 243, 255, 0.32);\n  text-shadow: 0 1px 12px rgba(5, 8, 14, 0.75);\n}\n\n.shutter-compare-tag b {\n  font-weight: 500;\n  color: rgba(236, 243, 255, 0.6);\n}\n\n.shutter-compare-tag-end {\n  text-align: right;\n}\n\n.shutter-compare-speeds {\n  display: flex;\n  gap: 0.3125rem;\n}\n\n.shutter-compare-speed {\n  appearance: none;\n  margin: 0;\n  padding: 0.375rem 0.5625rem;\n  border: 1px solid rgba(255, 255, 255, 0.14);\n  border-radius: 999px;\n  background: rgba(6, 12, 20, 0.5);\n  font: inherit;\n  font-size: 0.6875rem;\n  font-weight: 500;\n  font-variant-numeric: tabular-nums;\n  color: rgba(236, 243, 255, 0.7);\n  cursor: pointer;\n  pointer-events: auto;\n  backdrop-filter: blur(6px);\n  transition:\n    border-color 160ms ease,\n    background-color 160ms ease,\n    color 160ms ease;\n}\n\n.shutter-compare-speed:hover {\n  border-color: rgba(255, 201, 120, 0.42);\n  color: #fff6e8;\n}\n\n.shutter-compare-speed[aria-pressed='true'] {\n  border-color: rgba(255, 201, 120, 0.62);\n  background: rgba(255, 201, 120, 0.16);\n  color: #fff3df;\n}\n\n.shutter-compare-speed:focus-visible {\n  outline: 2px solid rgba(255, 201, 120, 0.8);\n  outline-offset: 2px;\n}\n\n/* The grip the pointer and the arrow keys both move. It is drawn at the commanded\n   position while the canvas draws the curtains where the solver has them, so the\n   gap between this and the bright edge during a drag is the lag of a real mass on\n   a spring and not a rendering slip. `pointer-events` stays none so a press falls\n   through to the canvas host, which owns the hook's pointer capture. */\n.shutter-compare-handle {\n  position: absolute;\n  top: 0;\n  bottom: 0;\n  width: 2.75rem;\n  transform: translateX(-50%);\n  pointer-events: none;\n}\n\n.shutter-compare-handle::before {\n  content: '';\n  position: absolute;\n  top: 3.5rem;\n  bottom: 4.75rem;\n  left: 50%;\n  width: 1px;\n  background: repeating-linear-gradient(\n    180deg,\n    rgba(255, 201, 120, 0.5) 0 3px,\n    rgba(255, 201, 120, 0) 3px 7px\n  );\n}\n\n.shutter-compare-handle::after {\n  content: '';\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  width: 1.375rem;\n  height: 1.375rem;\n  border: 1px solid rgba(255, 201, 120, 0.55);\n  border-radius: 999px;\n  background: rgba(8, 13, 22, 0.85);\n  box-shadow:\n    0 0 0 4px rgba(8, 13, 22, 0.45),\n    0 0 16px rgba(255, 201, 120, 0.22);\n  transform: translate(-50%, -50%);\n}\n\n/* The ring belongs on the grip, not on the full-height strip the strip has to be\n   to stay easy to hit. */\n.shutter-compare-handle:focus-visible {\n  outline: none;\n}\n\n.shutter-compare-handle:focus-visible::after {\n  outline: 2px solid rgba(255, 201, 120, 0.85);\n  outline-offset: 3px;\n}\n\n.shutter-compare-hint {\n  position: absolute;\n  top: 1.5rem;\n  right: 1.375rem;\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  pointer-events: none;\n  color: rgba(236, 243, 255, 0.26);\n}\n\n/*\n * What is switched off is the travel, not the shutter. With the loop stopped the\n * two curtains are painted parted by exactly the gap the selected exposure implies\n * at full travel, so the still carries both treatments, the divider between them\n * and the real width of the slit — at 1/60 that slit reaches the frame edge, at\n * 1/4000 it is a band a sixteenth of the frame wide. Every arrow key and every\n * speed change repaints that still in its new place.\n */\n@media (prefers-reduced-motion: reduce) {\n  .shutter-compare-speed {\n    transition: none;\n  }\n}\n\n/*\n * The card variant: the 298x240 catalogue frame, at that real size and never scaled.\n * The curtains are laid out from the canvas box — the top and bottom margins are\n * `height * 0.2` and `height * 0.24` under clamps — so the frame follows the card down\n * and the slit stays the width the selected exposure makes it. All this does is drop the\n * two corner tags and the hint and bring the reading down a size.\n */\n.shutter-compare-stage[data-compact='true'] {\n  min-height: 0;\n  height: 100%;\n  padding: 0.75rem 0.875rem;\n  /* The card frame rounds and clips already. */\n  border-radius: 0;\n}\n\n/* A full-bleed drag surface that claims every touch traps the page inside a scrolling\n   grid. `pan-y` hands the vertical gesture back to the document; the horizontal drag is\n   the one this mechanism is about, and it still arrives. */\n.shutter-compare-stage[data-compact='true'] .shutter-compare-frame {\n  touch-action: pan-y;\n}\n\n/* The full stage reserves 19.5rem for this column. In a card it takes what is left. */\n.shutter-compare-stage[data-compact='true'] .shutter-compare-ui {\n  min-height: 0;\n  height: 100%;\n}\n\n.shutter-compare-stage[data-compact='true'] .shutter-compare-read {\n  margin-top: 0.25rem;\n  font-size: 1.625rem;\n}\n\n/* Both are the card's own title's job, and the tags are what the speed row needs the\n   room back from. */\n.shutter-compare-stage[data-compact='true'] .shutter-compare-foot,\n.shutter-compare-stage[data-compact='true'] .shutter-compare-hint {\n  display: none;\n}\n\n.shutter-compare-stage[data-compact='true'] .shutter-compare-speed {\n  padding: 0.25rem 0.4375rem;\n  font-size: 0.625rem;\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":["draggable","masks","micro"],"docs":"https://ui.artbloom.tech/artbloom/animations/shutter-compare"}}