{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"dock-magnify","type":"registry:ui","title":"Dock Magnify","description":"An app dock on a shelf that actually bends. The bump under the pointer is a solved deflection rather than a curve over distance, so the icons two slots out dip below the resting line and the ones at either end lift less than the ones in the middle — neither of which a falloff function can do.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/dock-magnify.tsx","target":"components/ui/dock-magnify.tsx","content":"'use client';\n\nimport './dock-magnify.css';\n\nimport { useEffect, useRef, useState } from 'react';\nimport type { KeyboardEvent, MouseEvent, PointerEvent } from 'react';\n\nimport {\n  useCanvasScene,\n  useReducedMotion,\n  type SceneDrawContext,\n  type SceneSetupContext,\n} from '@/hooks/use-canvas-scene';\n\n/*\n * A dock whose shelf is a BEAM ON A WINKLER ELASTIC FOUNDATION. The pointer is a\n * concentrated load P riding along it, and the shelf's shape is the solution of\n *\n *     EI * w'''' + k * w = p(x),        w(0) = w(W) = 0,  w'(0) = w'(W) = 0\n *\n * with the fourth derivative taken by the central five-point stencil\n * (w[i-2] - 4w[i-1] + 6w[i] - 4w[i+1] + w[i+2]) / h^4. That makes a symmetric\n * pentadiagonal positive-definite system in the 119 interior nodes, factorised\n * once per resize by banded Cholesky and back-substituted every frame — one\n * linear solve per painted frame, not a curve evaluated at eight points.\n *\n * It is NOT a Gaussian, NOT a cosine window, and NOT a distance-to-pointer\n * falloff with a radius. Every dock in every library reaches for one of those\n * three, and all three are positive everywhere. The elastic response is not:\n *\n *     w(x) = (P / 2kL) * e^(-|x|/L) * (cos(x/L) + sin(x/L)),   L = (4EI/k)^(1/4)\n *\n * crosses zero at 3*pi/4 * L and troughs at pi * L, where it sits at -e^(-pi),\n * i.e. 4.3% of the peak, BELOW the resting line. So the icon two places out from\n * the pointer does not merely fail to rise — it dips under the dock's rim, and\n * you can watch it pass behind the rim hairline. That undershoot is the whole\n * argument: it is unreachable by any positive kernel, and here it falls out of\n * the solve rather than being drawn in.\n *\n * Two more things come free with the equation and are visible on the shelf. The\n * bump's width is a material property, L, and not a radius: at L = 0.7 of the\n * icon pitch the zero crossing lands at 1.65 pitches and the trough at 2.20, so\n * which neighbour sinks is decided by EI/k. And the boundary is real — both ends\n * are built in, so an end icon lifts about 65% of what a mid-dock icon lifts,\n * the stiffening you feel in a real dock's corners and that no falloff produces.\n */\n\n/** Icons on the dock. Eight, so aiming the middle keeps both undershoot slots on the shelf. */\nconst SLOTS = 8;\n/** Interior nodes plus two ends. 121 puts h at 0.07 of L, where the stencil's error is under 0.1%. */\nconst NODES = 121;\n/** Foundation modulus k. The unit of the whole system: EI and P are expressed in it. */\nconst BED = 1;\n/** L in icon pitches. 0.7 puts the zero crossing at 1.65 pitches, so slot +-2 is the one that sinks. */\nconst L_CELLS = 0.7;\n/** Built-in ends, 0.3 pitch outside the end icons: close enough to stiffen a corner, not to pin it. */\nconst PAD_CELLS = 0.3;\n/** Peak rise at the pointer, in pitches. 1.05 lifts an icon clear of its neighbours' tops. */\nconst LIFT_CELLS = 1.05;\n/** Icon side at rest, in pitches. 0.58 leaves the gap a dock needs to read as separate tiles. */\nconst TILE_CELLS = 0.58;\n/** Scale at full lift. 1.7 is the macOS dock's magnification, and it fits inside a 0.7-pitch bump. */\nconst MAG = 1.7;\n/** Load sign under a press. -0.26 dimples the aimed icon 14px into the shelf and inverts the lobes. */\nconst PRESS = -0.26;\n/** Shelf thickness in px. 18 is deeper than the -2.1px trough, so a sunk icon stays inside the rim. */\nconst PLATE_H = 18;\n/** Shelf inset from the card's edges in px, so the dock reads as a shelf with air at either end. */\nconst PLATE_INSET = 16;\n/** Room below the resting line in px: 18 for the shelf, 16 so a press cannot clip its underside. */\nconst FOOT = 34;\n/** Least room above the resting line in px, so the icons keep their headroom on a short card. */\nconst HEAD = 66;\n/** Grab slack below the shelf in px, so a pointer just under the rim still counts as on the dock. */\nconst SLACK = 24;\n/** Seconds per solver step. The load lag is first order with tau >= 45ms; dt/tau = 0.19 here, stable. */\nconst STEP = 1 / 120;\n/** Steps per frame ceiling. 12 covers a 100ms hitch and refuses to chase a backgrounded tab. */\nconst MAX_STEPS = 12;\n/** Time constant of the load's travel, in seconds. 0.045 trails a fast flick by about 3px. */\nconst MOVE_TAU = 0.045;\n/** Time constant of the load's magnitude. 0.085 is slower than the travel, so the bump grows in place. */\nconst FADE_TAU = 0.085;\n/** Which icon the load starts under. Slot 3 is off centre, so the response is visibly asymmetric. */\nconst START = 3;\n/** Apps open at rest, one bit per slot: Finder, Terminal and Messages. */\nconst OPEN = 0b01001001;\n\nconst INK = '234, 243, 255';\nconst ACCENT = '158, 205, 255';\nconst TAU = Math.PI * 2;\n\ntype AppKind = 'files' | 'mail' | 'calendar' | 'terminal' | 'music' | 'photos' | 'chat' | 'gear';\n\ninterface DockApp {\n  readonly name: string;\n  readonly kind: AppKind;\n}\n\nconst APPS: readonly DockApp[] = [\n  { name: 'Finder', kind: 'files' },\n  { name: 'Mail', kind: 'mail' },\n  { name: 'Calendar', kind: 'calendar' },\n  { name: 'Terminal', kind: 'terminal' },\n  { name: 'Music', kind: 'music' },\n  { name: 'Photos', kind: 'photos' },\n  { name: 'Messages', kind: 'chat' },\n  { name: 'Settings', kind: 'gear' },\n];\n\n/** The banded Cholesky factor of the stiffness matrix: diagonal plus two sub-diagonals. */\ninterface Band {\n  readonly size: number;\n  readonly d: Float64Array;\n  readonly l1: Float64Array;\n  readonly l2: Float64Array;\n}\n\n/** What the DOM shows about the frame the solver just produced. */\ninterface Readout {\n  aim: number;\n}\n\ninterface DockState {\n  /** Geometry, all of it derived from the stage at setup and never mutated after. */\n  readonly plateX: number;\n  readonly plateW: number;\n  readonly restY: number;\n  readonly cell: number;\n  readonly pad: number;\n  readonly tile: number;\n  readonly lift: number;\n  /** P, scaled so the infinite-beam peak P/(2kL) equals `lift`. */\n  readonly load: number;\n  readonly h: number;\n  /** Vertical reach above the resting line that still counts as being on the dock. */\n  readonly band: number;\n  readonly centres: Float64Array;\n  readonly system: Band;\n  readonly rhs: Float64Array;\n  readonly inner: Float64Array;\n  readonly w: Float64Array;\n  readonly wAt: Float64Array;\n  readonly tileFill: CanvasGradient;\n\n  /** Load position along the shelf, in px from its left end, and where it is headed. */\n  lx: number;\n  lxTarget: number;\n  /** Load magnitude as a fraction of P, and its target. Negative under a press. */\n  p: number;\n  pTarget: number;\n  clock: number;\n  carry: number;\n  snap: boolean;\n  magnify: boolean;\n  /** One bit per slot, set when that app is open. Read from React each frame. */\n  running: number;\n  /** Slot holding keyboard focus, or -1. The load follows it when nothing is hovering. */\n  keyed: number;\n  aim: number;\n  engaged: boolean;\n  /** Last transform written per slot: x, y, scale. NaN until the first placement. */\n  posted: Float64Array;\n}\n\n/*\n * Banded Cholesky of the clamped stiffness matrix. Row i of the interior system is\n * beta * (w[i-2] - 4w[i-1] + 6w[i] - 4w[i+1] + w[i+2]) + k * w[i], with beta = EI/h^4.\n * The clamp enters as a ghost node, w[-1] = w[1]: reflecting it onto the first and last\n * rows turns their 6*beta into 7*beta, and that single changed entry is the entire\n * difference between a built-in end and a pinned one. Factorising here rather than per\n * frame is what makes a solve-per-frame cheap: 119 unknowns cost about 1.8 microseconds.\n */\nfunction factorise(size: number, beta: number): Band {\n  const d = new Float64Array(size);\n  const l1 = new Float64Array(size);\n  const l2 = new Float64Array(size);\n  const at = (row: number, column: number): number => {\n    const gap = Math.abs(row - column);\n    if (gap === 2) return beta;\n    if (gap === 1) return -4 * beta;\n    if (gap > 2) return 0;\n    const edge = row === 0 || row === size - 1;\n    return (edge ? 7 : 6) * beta + BED;\n  };\n\n  for (let i = 0; i < size; i += 1) {\n    d[i] = Math.sqrt(at(i, i) - l1[i] * l1[i] - l2[i] * l2[i]);\n    if (i + 1 < size) l1[i + 1] = (at(i + 1, i) - l2[i + 1] * l1[i]) / d[i];\n    if (i + 2 < size) l2[i + 2] = at(i + 2, i) / d[i];\n  }\n\n  return { size, d, l1, l2 };\n}\n\n/** Forward then back substitution through the factor. O(n), and the whole per-frame cost. */\nfunction bandSolve(band: Band, rhs: Float64Array, out: Float64Array): void {\n  const { size, d, l1, l2 } = band;\n  for (let i = 0; i < size; i += 1) {\n    const b1 = i >= 1 ? l1[i] * out[i - 1] : 0;\n    const b2 = i >= 2 ? l2[i] * out[i - 2] : 0;\n    out[i] = (rhs[i] - b1 - b2) / d[i];\n  }\n  for (let i = size - 1; i >= 0; i -= 1) {\n    const u1 = i + 1 < size ? l1[i + 1] * out[i + 1] : 0;\n    const u2 = i + 2 < size ? l2[i + 2] * out[i + 2] : 0;\n    out[i] = (out[i] - u1 - u2) / d[i];\n  }\n}\n\n/**\n * Build the right-hand side for the load where it currently is, solve, and read the shelf\n * height at each icon. The load lands between two nodes and is split between them by\n * distance — consistent lumping. Rounding it to the nearest node instead would make the\n * bump jump the 3.7px node spacing, which is plainly visible on a slow drag.\n */\nfunction deflect(state: DockState): void {\n  const { rhs, inner, w, h, system } = state;\n  rhs.fill(0);\n  const g = Math.max(0, Math.min(state.plateW, state.lx)) / h;\n  const node = Math.floor(g);\n  const frac = g - node;\n  const scale = (state.p * state.load) / h;\n  for (let k = 0; k < 2; k += 1) {\n    const i = node + k - 1;\n    if (i >= 0 && i < rhs.length) rhs[i] += scale * (k === 0 ? 1 - frac : frac);\n  }\n  bandSolve(system, rhs, inner);\n  for (let i = 0; i < inner.length; i += 1) w[i + 1] = inner[i];\n  for (let i = 0; i < SLOTS; i += 1) state.wAt[i] = sampleW(state, state.centres[i]);\n}\n\n/** Shelf height between nodes, linearly. The stencil's own error is larger than this one's. */\nfunction sampleW(state: DockState, x: number): number {\n  const g = Math.max(0, Math.min(state.plateW, x)) / state.h;\n  const i = Math.min(NODES - 2, Math.floor(g));\n  return state.w[i] + (state.w[i + 1] - state.w[i]) * (g - i);\n}\n\nconst slotAt = (state: DockState, x: number): number =>\n  Math.max(0, Math.min(SLOTS - 1, Math.round((x - state.pad) / state.cell - 0.5)));\n\n/** Icon side at a given shelf height. Linear in w, so the scale is the solve, not a curve of its own. */\nconst sizeOf = (state: DockState, w: number): number => state.tile * (1 + (MAG - 1) * (w / state.lift));\n\n/**\n * Size the shelf to the stage and factorise its stiffness matrix. Everything geometric is\n * derived from one number, the icon pitch, so a 390px card and a 1340px one are the same\n * dock at two scales; and EI is not a free parameter — the characteristic length is chosen\n * in pitches and EI = k * L^4 / 4 follows, which is why the bump keeps its shape.\n */\nfunction build({ context, width, height }: SceneSetupContext, snap: boolean, magnify: boolean): DockState {\n  const plateX = PLATE_INSET;\n  const plateW = Math.max(SLOTS * 12, width - PLATE_INSET * 2);\n  const restY = Math.max(HEAD, height - FOOT);\n  const cell = plateW / (SLOTS + 2 * PAD_CELLS);\n  const pad = PAD_CELLS * cell;\n  const charLen = L_CELLS * cell;\n  const lift = LIFT_CELLS * cell;\n  const tile = TILE_CELLS * cell;\n  const h = plateW / (NODES - 1);\n  const size = NODES - 2;\n\n  const centres = new Float64Array(SLOTS);\n  for (let i = 0; i < SLOTS; i += 1) centres[i] = pad + (i + 0.5) * cell;\n\n  // Made once and drawn under a unit-space transform, so one gradient serves eight icons\n  // at eight different scales.\n  const tileFill = context.createLinearGradient(0, -0.5, 0, 0.5);\n  tileFill.addColorStop(0, `rgba(${ACCENT}, 0.22)`);\n  tileFill.addColorStop(1, 'rgba(8, 14, 23, 0.92)');\n\n  const start = pad + (START + 0.5) * cell;\n\n  return {\n    plateX,\n    plateW,\n    restY,\n    cell,\n    pad,\n    tile,\n    lift,\n    load: 2 * BED * charLen * lift,\n    h,\n    band: lift + tile * MAG,\n    centres,\n    system: factorise(size, (BED * charLen ** 4) / 4 / h ** 4),\n    rhs: new Float64Array(size),\n    inner: new Float64Array(size),\n    w: new Float64Array(NODES),\n    wAt: new Float64Array(SLOTS),\n    tileFill,\n    lx: start,\n    lxTarget: start,\n    p: 0,\n    pTarget: 0,\n    clock: 0,\n    carry: 0,\n    snap,\n    magnify,\n    running: OPEN,\n    keyed: -1,\n    aim: START,\n    engaged: false,\n    posted: new Float64Array(SLOTS * 3).fill(Number.NaN),\n  };\n}\n\n/** The deflected surface, node by node. `lineTo` opens the subpath, so no separate `moveTo`. */\nfunction surfacePath(context: CanvasRenderingContext2D, state: DockState): void {\n  context.beginPath();\n  for (let i = 0; i < NODES; i += 1) {\n    context.lineTo(state.plateX + i * state.h, state.restY - state.w[i]);\n  }\n}\n\n/** The shelf as a solid of constant thickness: the surface out, the underside back. */\nfunction ribbonPath(context: CanvasRenderingContext2D, state: DockState): void {\n  context.beginPath();\n  for (let i = 0; i < NODES; i += 1) {\n    context.lineTo(state.plateX + i * state.h, state.restY - state.w[i]);\n  }\n  for (let i = NODES - 1; i >= 0; i -= 1) {\n    context.lineTo(state.plateX + i * state.h, state.restY - state.w[i] + PLATE_H);\n  }\n  context.closePath();\n}\n\n/** A rounded square on the unit box, so one path serves every icon at every scale. */\nfunction roundedUnit(context: CanvasRenderingContext2D, radius: number): void {\n  context.beginPath();\n  context.moveTo(-0.5 + radius, -0.5);\n  context.arcTo(0.5, -0.5, 0.5, 0.5, radius);\n  context.arcTo(0.5, 0.5, -0.5, 0.5, radius);\n  context.arcTo(-0.5, 0.5, -0.5, -0.5, radius);\n  context.arcTo(-0.5, -0.5, 0.5, -0.5, radius);\n  context.closePath();\n}\n\n/**\n * The mark on an icon, in unit space, stroked at whatever width the caller set. Painted on\n * the canvas rather than left to the DOM because it rides a surface the DOM cannot bend;\n * the button over it carries the name, so nothing here is load-bearing for a screen reader.\n */\nfunction glyph(context: CanvasRenderingContext2D, kind: AppKind): void {\n  context.beginPath();\n  switch (kind) {\n    case 'files':\n      context.moveTo(-0.19, -0.06);\n      context.lineTo(-0.19, -0.14);\n      context.lineTo(-0.02, -0.14);\n      context.lineTo(0.03, -0.06);\n      context.lineTo(0.19, -0.06);\n      context.lineTo(0.19, 0.15);\n      context.lineTo(-0.19, 0.15);\n      context.closePath();\n      break;\n    case 'mail':\n      context.rect(-0.2, -0.13, 0.4, 0.26);\n      context.moveTo(-0.2, -0.13);\n      context.lineTo(0, 0.02);\n      context.lineTo(0.2, -0.13);\n      break;\n    case 'calendar':\n      context.rect(-0.19, -0.12, 0.38, 0.27);\n      context.moveTo(-0.19, -0.03);\n      context.lineTo(0.19, -0.03);\n      context.moveTo(-0.09, -0.19);\n      context.lineTo(-0.09, -0.07);\n      context.moveTo(0.09, -0.19);\n      context.lineTo(0.09, -0.07);\n      break;\n    case 'terminal':\n      context.moveTo(-0.16, -0.09);\n      context.lineTo(-0.05, 0.01);\n      context.lineTo(-0.16, 0.11);\n      context.moveTo(0.01, 0.13);\n      context.lineTo(0.17, 0.13);\n      break;\n    case 'music':\n      context.moveTo(0.035, 0.09);\n      context.arc(-0.04, 0.09, 0.075, 0, TAU);\n      context.moveTo(0.035, 0.09);\n      context.lineTo(0.035, -0.15);\n      context.lineTo(0.15, -0.09);\n      break;\n    case 'photos':\n      context.rect(-0.2, -0.13, 0.4, 0.26);\n      context.moveTo(-0.035, -0.045);\n      context.arc(-0.08, -0.045, 0.045, 0, TAU);\n      context.moveTo(-0.2, 0.13);\n      context.lineTo(-0.02, 0);\n      context.lineTo(0.07, 0.07);\n      context.lineTo(0.13, 0.02);\n      context.lineTo(0.2, 0.13);\n      break;\n    case 'chat':\n      context.rect(-0.19, -0.14, 0.38, 0.23);\n      context.moveTo(-0.12, 0.09);\n      context.lineTo(-0.12, 0.19);\n      context.lineTo(-0.02, 0.09);\n      break;\n    case 'gear':\n      context.moveTo(0.085, 0);\n      context.arc(0, 0, 0.085, 0, TAU);\n      for (let i = 0; i < 8; i += 1) {\n        const angle = (i / 8) * TAU;\n        context.moveTo(Math.cos(angle) * 0.125, Math.sin(angle) * 0.125);\n        context.lineTo(Math.cos(angle) * 0.195, Math.sin(angle) * 0.195);\n      }\n      break;\n  }\n  context.stroke();\n}\n\n/*\n * One frame: aim the load, walk the lag to it at a fixed step, solve the beam, draw.\n *\n * The beam itself is solved statically — for a shelf this stiff the elastic transient is\n * over inside a frame, so pretending otherwise would be fiction. What has a time constant\n * is the load: a hand does not teleport, and neither does the pressure it puts on the\n * shelf. Those two lags are integrated at a fixed 1/120s because they are explicit and\n * first order: at a 60ms frame, dt/tau would be 1.33 and the bump would ring on arrival.\n */\nfunction paint(scene: SceneDrawContext<DockState>): void {\n  const { context, width, height, state, pointer } = scene;\n  const now = performance.now();\n  const elapsed = state.clock ? Math.min(0.05, (now - state.clock) / 1000) : STEP;\n  state.clock = now;\n\n  // On the dock means anywhere the dock reaches: a magnified icon stands a full pitch\n  // above the shelf, so the band has to include the space it occupies or the bump would\n  // collapse the moment the pointer followed the icon it just raised.\n  const localX = pointer.x - state.plateX;\n  const onDock =\n    pointer.inside &&\n    pointer.y > state.restY - state.band &&\n    pointer.y < state.restY + PLATE_H + SLACK &&\n    localX > -state.cell &&\n    localX < state.plateW + state.cell;\n\n  if (onDock) {\n    state.lxTarget = Math.max(0, Math.min(state.plateW, localX));\n    state.pTarget = state.magnify ? (pointer.down ? PRESS : 1) : 0;\n  } else if (state.keyed >= 0) {\n    // Nothing is hovering but a dock button holds focus, so the arrows drive the same\n    // load the pointer does and the bump travels to whatever Tab reached.\n    state.lxTarget = state.centres[state.keyed];\n    state.pTarget = state.magnify ? 1 : 0;\n  } else {\n    state.pTarget = 0;\n  }\n  state.engaged = onDock;\n\n  if (state.snap) {\n    // With the loop stopped nothing can be walked anywhere, so the load is placed at its\n    // target and the beam is solved there in one step. The whole response is on screen,\n    // undershoot and end stiffening included; what is gone is the travel to it.\n    state.lx = state.lxTarget;\n    state.p = state.pTarget;\n    state.carry = 0;\n  } else {\n    state.carry += elapsed;\n    const steps = Math.min(MAX_STEPS, Math.floor(state.carry / STEP));\n    for (let i = 0; i < steps; i += 1) {\n      state.lx += (state.lxTarget - state.lx) * (STEP / MOVE_TAU);\n      state.p += (state.pTarget - state.p) * (STEP / FADE_TAU);\n    }\n    state.carry -= steps * STEP;\n    // A tab that was backgrounded owes nothing on return.\n    if (state.carry > STEP * MAX_STEPS) state.carry = 0;\n  }\n\n  state.aim = slotAt(state, state.lx);\n  deflect(state);\n\n  const { plateX, plateW, restY } = state;\n  context.clearRect(0, 0, width, height);\n  context.lineJoin = 'round';\n  context.lineCap = 'round';\n\n  ribbonPath(context, state);\n  context.fillStyle = `rgba(${INK}, 0.055)`;\n  context.fill();\n  context.lineWidth = 1;\n  context.strokeStyle = `rgba(${INK}, 0.13)`;\n  context.stroke();\n\n  for (let i = 0; i < SLOTS; i += 1) {\n    const w = state.wAt[i];\n    const size = sizeOf(state, w);\n    const cx = plateX + state.centres[i];\n    const lit = Math.max(0, Math.min(1, w / state.lift));\n    context.save();\n    // Icons ride the surface: the foot of the tile is the shelf's height at its own x, so\n    // the lift and the magnification are two readings of one solve.\n    context.translate(cx, restY - w - size / 2);\n    context.scale(size, size);\n    roundedUnit(context, 0.22);\n    context.fillStyle = state.tileFill;\n    context.fill();\n    context.lineWidth = 1.1 / size;\n    context.strokeStyle = `rgba(${ACCENT}, ${(0.18 + 0.5 * lit).toFixed(3)})`;\n    context.stroke();\n    context.lineWidth = 1.6 / size;\n    context.strokeStyle = `rgba(${INK}, ${(0.42 + 0.42 * lit).toFixed(3)})`;\n    glyph(context, APPS[i].kind);\n    context.restore();\n\n    if ((state.running & (1 << i)) !== 0) {\n      context.beginPath();\n      context.arc(cx, restY - w + 7, 1.6, 0, TAU);\n      context.fillStyle = `rgba(${ACCENT}, 0.8)`;\n      context.fill();\n    }\n  }\n\n  surfacePath(context, state);\n  context.lineWidth = 1.4;\n  context.strokeStyle = `rgba(${ACCENT}, 0.55)`;\n  context.stroke();\n\n  // The rim, at the undeflected line, drawn in front of the icons so a sunk one visibly\n  // passes behind it. This is the reference the eye compares against.\n  context.fillStyle = `rgba(${INK}, 0.26)`;\n  context.fillRect(plateX, restY - 0.5, plateW, 1);\n}\n\n/*\n * Move the real buttons onto the tiles the solver just drew, so the focus ring rides the\n * bump instead of sitting where the icon used to be. The transform is written from the same\n * two numbers the canvas used; CSS owns only the centring translate, never a hand-matched\n * `left`. Writes are skipped when nothing moved, which is most frames once the load settles.\n */\nfunction place(state: DockState, buttons: (HTMLButtonElement | null)[], tip: HTMLElement | null): void {\n  for (let i = 0; i < SLOTS; i += 1) {\n    const node = buttons[i];\n    if (!node) continue;\n    const w = state.wAt[i];\n    const size = sizeOf(state, w);\n    const x = state.plateX + state.centres[i];\n    const y = state.restY - w - size / 2;\n    const scale = size / state.tile;\n    const slot = i * 3;\n    const first = Number.isNaN(state.posted[slot]);\n    if (\n      !first &&\n      Math.abs(state.posted[slot + 1] - y) < 0.3 &&\n      Math.abs(state.posted[slot + 2] - scale) < 0.004\n    ) {\n      continue;\n    }\n    if (first) {\n      // The rest size is set once per rebuild; every frame after that is a scale, which is\n      // one composited property rather than a relayout eight times a frame.\n      node.style.width = `${state.tile.toFixed(1)}px`;\n      node.style.height = `${state.tile.toFixed(1)}px`;\n      node.style.opacity = '1';\n    }\n    state.posted[slot] = x;\n    state.posted[slot + 1] = y;\n    state.posted[slot + 2] = scale;\n    node.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) translate(-50%, -50%) scale(${scale.toFixed(3)})`;\n  }\n\n  if (!tip) return;\n  const w = state.wAt[state.aim];\n  const x = state.plateX + state.centres[state.aim];\n  const y = state.restY - w - sizeOf(state, w) - 10;\n  // The label is faded by the load itself, so it arrives with the bump rather than on a\n  // timer of its own, and it leaves when the hand does.\n  tip.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) translate(-50%, -100%)`;\n  tip.style.opacity = Math.max(0, Math.min(1, Math.abs(state.p) / 0.5)).toFixed(2);\n}\n\n/** `compact` is the 298x240 catalogue card: the same shelf, the same eight icons and the\n *  same solve, with the hint below the card dropped and the head tightened. The shelf is\n *  sized from the canvas box — `restY` is `height − FOOT` under a clamp and every other\n *  length is in icon pitches — so a shorter card is the same dock at a smaller scale. See\n *  `dock-magnify.css`. */\nexport type DockMagnifyProps = { compact?: boolean };\n\nexport function DockMagnify({ compact = false }: DockMagnifyProps) {\n  const reduced = useReducedMotion();\n  const [magnify, setMagnify] = useState(true);\n  const [running, setRunning] = useState(OPEN);\n  const [roving, setRoving] = useState(START);\n  const [readout, setReadout] = useState<Readout>({ aim: START });\n\n  const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]);\n  const tipRef = useRef<HTMLParagraphElement>(null);\n  /** Which slot holds focus, or -1. Kept out of state: the solver reads it every frame. */\n  const keyedRef = useRef(-1);\n  /** Written by the solver so a press on the shelf knows which icon it landed under. */\n  const hitRef = useRef({ slot: START, engaged: false });\n  /** Last published readout, so an unchanged frame does not re-render the DOM. */\n  const postedRef = useRef<Readout>({ aim: START });\n\n  const { stageRef, canvasRef, requestRender } = useCanvasScene<DockState>({\n    setup: (scene: SceneSetupContext) => build(scene, reduced, magnify),\n    draw: (scene: SceneDrawContext<DockState>) => {\n      const { state } = scene;\n      state.snap = reduced;\n      state.magnify = magnify;\n      state.running = running;\n      state.keyed = keyedRef.current;\n\n      paint(scene);\n      place(state, buttonsRef.current, tipRef.current);\n      hitRef.current.slot = state.aim;\n      hitRef.current.engaged = state.engaged;\n\n      const posted = postedRef.current;\n      if (posted.aim !== state.aim) {\n        postedRef.current = { aim: state.aim };\n        setReadout(postedRef.current);\n      }\n    },\n  });\n\n  // Every React value the scene reads needs one of these, or a stopped loop keeps showing\n  // the old frame: under reduced motion nothing repaints unless something asks it to. The\n  // readout is deliberately absent — it is published *by* the scene, and feeding it back\n  // would schedule a second paint for every frame of a drag.\n  useEffect(() => {\n    requestRender();\n  }, [magnify, running, reduced, requestRender]);\n\n  const toggleApp = (index: number) => {\n    setRunning((mask) => mask ^ (1 << index));\n  };\n\n  const onSlotFocus = (index: number) => {\n    keyedRef.current = index;\n    setRoving(index);\n    requestRender();\n  };\n\n  const onSlotBlur = () => {\n    keyedRef.current = -1;\n    requestRender();\n  };\n\n  const onSlotKey = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {\n    let next = index;\n    if (event.key === 'ArrowRight' || event.key === 'ArrowDown') next = index + 1;\n    else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') next = index - 1;\n    else if (event.key === 'Home') next = 0;\n    else if (event.key === 'End') next = SLOTS - 1;\n    else return;\n    event.preventDefault();\n    buttonsRef.current[Math.max(0, Math.min(SLOTS - 1, next))]?.focus({ preventScroll: true });\n  };\n\n  /*\n   * The shelf owns the pointer capture, which is why the eight icon buttons are laid over it\n   * with `pointer-events: none` and their clicks are routed from here instead. Edge-detecting\n   * `pointer.down` inside the solver would have done it too, but it drops a click that opens\n   * and closes between two frames; the DOM's own click never misses one.\n   */\n  const onStagePointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (!hitRef.current.engaged) return;\n    if ((event.target as HTMLElement).closest('button')) return;\n    // In a card there is nothing to hand focus to — the frame is aria-hidden and its buttons\n    // are out of the tab order — so the press is left to the shelf, and the click below still\n    // opens the app under it.\n    if (compact) return;\n    buttonsRef.current[hitRef.current.slot]?.focus({ preventScroll: true });\n    // The focus came from the pointer, so the pointer keeps the load: clicking an icon must\n    // not leave the dock magnified after the hand has gone.\n    keyedRef.current = -1;\n  };\n\n  const onStageClick = (event: MouseEvent<HTMLDivElement>) => {\n    if ((event.target as HTMLElement).closest('button')) return;\n    if (hitRef.current.engaged) toggleApp(hitRef.current.slot);\n  };\n\n  const app = APPS[readout.aim];\n  const open = (running & (1 << readout.aim)) !== 0;\n\n  return (\n    <div\n      className=\"dock-magnify-stage\"\n      data-compact={compact ? 'true' : undefined}\n      onPointerDown={onStagePointerDown}\n      onClick={onStageClick}\n    >\n      <div className=\"dock-magnify-card\">\n        <div ref={stageRef} className=\"dock-magnify-well\" aria-hidden=\"true\">\n          <canvas ref={canvasRef} />\n        </div>\n\n        <div className=\"dock-magnify-head\">\n          <p className=\"dock-magnify-label\">Dock</p>\n          <p className=\"dock-magnify-read\">\n            {app.name}\n            <span className=\"dock-magnify-unit\">{open ? 'open' : 'not running'}</span>\n          </p>\n        </div>\n\n        <button\n          type=\"button\"\n          className=\"dock-magnify-toggle\"\n          aria-pressed={magnify}\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={() => setMagnify((on) => !on)}\n        >\n          Magnification\n        </button>\n\n        <div className=\"dock-magnify-apps\" role=\"toolbar\" aria-label=\"Dock\" aria-orientation=\"horizontal\">\n          {APPS.map((each, index) => (\n            <button\n              key={each.name}\n              ref={(node) => {\n                buttonsRef.current[index] = node;\n              }}\n              type=\"button\"\n              className=\"dock-magnify-app\"\n              aria-pressed={(running & (1 << index)) !== 0}\n              /* The roving index is the toolbar's whole keyboard contract, so it stays —\n                 except in a card, where the entire frame is out of the tab order. */\n              tabIndex={compact ? -1 : index === roving ? 0 : -1}\n              onFocus={() => onSlotFocus(index)}\n              onBlur={onSlotBlur}\n              onKeyDown={(event) => onSlotKey(event, index)}\n              onClick={() => toggleApp(index)}\n            >\n              <span className=\"dock-magnify-app-name\">{each.name}</span>\n            </button>\n          ))}\n        </div>\n\n        <p ref={tipRef} className=\"dock-magnify-tip\" aria-hidden=\"true\">\n          {app.name}\n        </p>\n      </div>\n\n      <p className=\"dock-magnify-hint\">Hover the dock, or Tab into it</p>\n    </div>\n  );\n}\n\nexport default DockMagnify;\n","type":"registry:ui"},{"path":"components/ui/dock-magnify.css","target":"components/ui/dock-magnify.css","content":".dock-magnify-stage {\n  position: relative;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  width: 100%;\n  min-height: 23.5rem;\n  padding: 2.25rem 1.5rem;\n  overflow: hidden;\n  border-radius: 0.75rem;\n  background: radial-gradient(120% 110% at 50% 0%, #0c1620 0%, #070b12 60%, #05070c 100%);\n  color: #eaf3ff;\n}\n\n.dock-magnify-card {\n  position: relative;\n  width: min(30rem, 100%);\n  height: 15.5rem;\n  overflow: hidden;\n  border: 1px solid rgba(255, 255, 255, 0.09);\n  border-radius: 1rem;\n  background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.015));\n  isolation: isolate;\n}\n\n/* The shelf, behind everything, and the layer that owns the pointer capture — so every\n   other layer in the card is a sibling of it rather than a child, which would swallow the\n   press. Its box is the card's padding box, which is why a transform written from a canvas\n   pixel lands where the canvas drew. */\n.dock-magnify-well {\n  position: absolute;\n  inset: 0;\n  touch-action: none;\n}\n\n.dock-magnify-well canvas {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n\n.dock-magnify-head {\n  position: absolute;\n  top: 1rem;\n  left: 1.25rem;\n  pointer-events: none;\n}\n\n.dock-magnify-label {\n  margin: 0 0 0.375rem;\n  font: 500 0.6875rem/1 ui-monospace, \"SFMono-Regular\", Menlo, monospace;\n  letter-spacing: 0.14em;\n  text-transform: uppercase;\n  color: rgba(234, 243, 255, 0.5);\n}\n\n.dock-magnify-read {\n  margin: 0;\n  font-size: 1.3125rem;\n  font-weight: 500;\n  line-height: 1;\n  letter-spacing: -0.02em;\n  text-shadow: 0 1px 18px rgba(5, 12, 20, 0.55);\n}\n\n/* Its own line, not a suffix: at 390px a long app name plus its state would have run under\n   the Magnification switch in the opposite corner. */\n.dock-magnify-unit {\n  display: block;\n  margin-top: 0.3125rem;\n  font-size: 0.75rem;\n  font-weight: 500;\n  letter-spacing: 0;\n  color: rgba(234, 243, 255, 0.55);\n}\n\n/* The one control that needs its own click, so it takes events back. Moving onto it leaves\n   the shelf, which drops the load — correct: the pointer is no longer on the dock. */\n.dock-magnify-toggle {\n  position: absolute;\n  top: 1rem;\n  right: 1.125rem;\n  display: inline-flex;\n  align-items: center;\n  gap: 0.4375rem;\n  padding: 0.3125rem 0.625rem;\n  border: 1px solid rgba(234, 243, 255, 0.16);\n  border-radius: 999px;\n  background: rgba(6, 12, 20, 0.5);\n  font: 500 0.625rem/1 ui-monospace, \"SFMono-Regular\", Menlo, monospace;\n  letter-spacing: 0.1em;\n  text-transform: uppercase;\n  color: rgba(234, 243, 255, 0.62);\n  cursor: pointer;\n  pointer-events: auto;\n  transition: border-color 160ms ease, color 160ms ease;\n}\n\n.dock-magnify-toggle::before {\n  content: \"\";\n  width: 0.3125rem;\n  height: 0.3125rem;\n  border-radius: 50%;\n  background: rgba(234, 243, 255, 0.25);\n}\n\n.dock-magnify-toggle[aria-pressed=\"true\"] {\n  border-color: rgba(158, 205, 255, 0.45);\n  color: rgba(234, 243, 255, 0.88);\n}\n\n.dock-magnify-toggle[aria-pressed=\"true\"]::before {\n  background: #9ecdff;\n  box-shadow: 0 0 8px rgba(158, 205, 255, 0.85);\n}\n\n.dock-magnify-toggle:focus-visible {\n  outline: 2px solid rgba(158, 205, 255, 0.8);\n  outline-offset: 3px;\n}\n\n/*\n * The real dock. One button per icon, laid over the tiles the canvas draws and moved by the\n * solver, so the focus ring rides the bump instead of sitting where the icon used to be.\n * `pointer-events: none` keeps the press on the shelf, which owns the capture; focus still\n * lands here, so Tab and the arrows reach the same eight buttons the pointer does, and a\n * click on the shelf is routed to whichever of them is under it.\n */\n.dock-magnify-apps {\n  position: absolute;\n  inset: 0;\n  pointer-events: none;\n}\n\n.dock-magnify-app {\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 2rem;\n  height: 2rem;\n  padding: 0;\n  border: 0;\n  border-radius: 0.5rem;\n  background: none;\n  pointer-events: none;\n  opacity: 0;\n  will-change: transform;\n}\n\n.dock-magnify-app:focus-visible {\n  outline: 2px solid rgba(158, 205, 255, 0.85);\n  outline-offset: 3px;\n}\n\n/* The name is the button's whole accessible content — the mark itself is painted on the\n   canvas, because it rides a surface the DOM has no way to bend. */\n.dock-magnify-app-name {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  margin: -1px;\n  overflow: hidden;\n  clip-path: inset(50%);\n  white-space: nowrap;\n}\n\n/* The dock label, placed above the magnified icon by the solver and faded by the load\n   itself, so it arrives with the bump rather than on a timer of its own. */\n.dock-magnify-tip {\n  position: absolute;\n  top: 0;\n  left: 0;\n  margin: 0;\n  padding: 0.25rem 0.5rem;\n  border: 1px solid rgba(255, 255, 255, 0.1);\n  border-radius: 0.375rem;\n  background: rgba(7, 13, 21, 0.85);\n  font-size: 0.75rem;\n  font-weight: 500;\n  line-height: 1;\n  white-space: nowrap;\n  pointer-events: none;\n  opacity: 0;\n  will-change: transform, opacity;\n}\n\n.dock-magnify-hint {\n  margin: 0.9375rem 0 0;\n  font: 500 0.6875rem/1.5 ui-monospace, \"SFMono-Regular\", Menlo, monospace;\n  letter-spacing: 0.07em;\n  text-transform: uppercase;\n  text-align: center;\n  color: rgba(234, 243, 255, 0.3);\n  pointer-events: none;\n}\n\n/* With the loop stopped the load is placed at its target and the beam is solved there in one\n   step, so the shape is the same shape — what is gone is the travel to it. Hovering, the\n   arrow keys and Enter all still work, and the undershoot is still on screen. */\n@media (prefers-reduced-motion: reduce) {\n  .dock-magnify-toggle {\n    transition: none;\n  }\n}\n\n/*\n * The card variant: the 298x240 catalogue frame, at that real size and never scaled.\n * Nothing about the shelf is restated here. `restY` is `height − FOOT` under a clamp and\n * every other length — pitch, characteristic length, lift, tile — is a multiple of the icon\n * pitch, which is `plateW / 8.6`. So a 278x220 card is the same beam with the same L/pitch\n * ratio, the same zero crossing at 1.65 pitches and the same trough at 2.20. What changes\n * here is the room around it.\n */\n.dock-magnify-stage[data-compact='true'] {\n  min-height: 0;\n  height: 100%;\n  padding: 0.625rem;\n  /* The card frame rounds and clips already. */\n  border-radius: 0;\n}\n\n.dock-magnify-stage[data-compact='true'] .dock-magnify-card {\n  width: 100%;\n  height: 100%;\n}\n\n/* A full-bleed surface that claims every touch traps the page inside a scrolling grid.\n   `pan-y` hands the vertical gesture back to the document; the load follows a hover, which\n   arrives either way. */\n.dock-magnify-stage[data-compact='true'] .dock-magnify-well {\n  touch-action: pan-y;\n}\n\n/* In from the corners, so the head and the switch clear the shelf's headroom on a card\n   that is 60px shorter. */\n.dock-magnify-stage[data-compact='true'] .dock-magnify-head {\n  top: 0.75rem;\n  left: 0.875rem;\n}\n\n.dock-magnify-stage[data-compact='true'] .dock-magnify-read {\n  font-size: 1.125rem;\n}\n\n.dock-magnify-stage[data-compact='true'] .dock-magnify-toggle {\n  top: 0.75rem;\n  right: 0.75rem;\n  gap: 0.375rem;\n  padding: 0.25rem 0.5rem;\n}\n\n/* It sits below the card, and the card's own title says the same thing. */\n.dock-magnify-stage[data-compact='true'] .dock-magnify-hint {\n  display: none;\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":["micro","springs","stagger"],"docs":"https://ui.artbloom.tech/artbloom/animations/dock-magnify"}}