{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"heat-grid","type":"registry:ui","title":"Heat Grid","description":"A contribution calendar where activity is heat and the heat obeys the diffusion equation, so a busy week bleeds into the days beside it and cools back to ambient on its own.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/heat-grid.tsx","target":"components/ui/heat-grid.tsx","content":"'use client';\n\nimport './heat-grid.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 contribution calendar whose cells are a live temperature field.\n *\n * The glow under the cursor is the 2D heat equation dT/dt = alpha * lap(T) - k * T, integrated on the\n * cell lattice by explicit finite difference at a fixed step. Explicit FD is the honest cheap solver\n * for 182 cells, but it is only conditionally stable: with r = alpha * dt / dx^2 the discrete Fourier\n * factor is 1 + r * (2 cos kx + 2 cos ky - 4), which at the checkerboard mode kx = ky = pi is 1 - 8r.\n * The familiar 1D number r = 1/2 makes that -3, so every step multiplies alternating cells by minus\n * three and the grid tears into a flickering chequerboard inside half a second. R below is pinned to\n * 0.2, under the 2D ceiling of 1/4. Tweening each cell's opacity cannot stand in for this: a tween\n * never hands heat to the neighbour, and cell-to-cell transport is the entire picture.\n *\n * Boundaries are insulated (Neumann) by mirroring: the stencil reads a ghost cell equal to the edge\n * cell, so wall flux is exactly zero and interior fluxes cancel in pairs. Heat is conserved apart\n * from the one explicit sink, -k * T, radiating each cell back to its ambient — and ambient is the\n * recorded activity, which is why the grid reads as a plain, usable heatmap when left alone.\n */\n\nconst COLS = 26;\nconst ROWS = 7;\nconst CELLS = COLS * ROWS;\n\nconst STEP = 1 / 120;\nconst R = 0.2; // alpha * dt / dx^2, dx = 1 cell; 1/4 is the 2D explicit stability ceiling\nconst COOL = 3.4; // 1/s radiative pull back to ambient: a ~0.3 s decay, so the trail dies fast\nconst BRUSH = 6.2; // K/s injected under a moving pointer\nconst HOLD = 2.4; // multiplier while pressed, which is what makes a held source read as held\nconst SPREAD = 1.9; // squared brush radius, in cells\nconst KICK = 0.9; // impulse dropped by a keyboard step\nconst GAIN = 0.8; // temperature to ramp units\nconst WARM = 26; // setup pulse length in steps: 0.22 s, hot enough to see, short of saturation\n\nconst PAD = 18;\nconst HEAD = 78; // matches .heat-grid-content min-height; the calendar starts under the header\nconst MONTH_H = 15;\nconst LEGEND_H = 30;\nconst FOOT = 26;\nconst LABEL_W = 28;\n\n// Six stops, flat so the lookup is a typed-array read. Ambient slate, then ember, then filament.\nconst STOPS = new Float64Array([\n  22, 25, 32, 74, 44, 24, 138, 70, 22, 206, 112, 26, 244, 164, 74, 255, 226, 168,\n]);\nconst SEGS = 5;\n\nconst ramp = (t: number): string => {\n  const x = t > 0 ? (t < 1 ? t : 1) : 0; // the ternary order also turns a stray NaN into 0\n  const f = x * SEGS;\n  const s = Math.min(SEGS - 1, Math.floor(f));\n  const k = f - s;\n  const a = s * 3;\n  const b = a + 3;\n  const r = Math.round(STOPS[a] + (STOPS[b] - STOPS[a]) * k);\n  const g = Math.round(STOPS[a + 1] + (STOPS[b + 1] - STOPS[a + 1]) * k);\n  const l = Math.round(STOPS[a + 2] + (STOPS[b + 2] - STOPS[a + 2]) * k);\n  return `rgb(${r}, ${g}, ${l})`;\n};\n\nconst MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];\nconst WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];\nconst DAY_MS = 86400000;\nconst START = Date.UTC(2026, 2, 9); // a Monday, so row 0 is Monday for all 26 columns\n\n// Sim index is row-major (r * COLS + c); the date runs down each week column, so day = c * 7 + r.\nconst dateOf = (i: number): Date =>\n  new Date(START + ((i % COLS) * 7 + Math.floor(i / COLS)) * DAY_MS);\n\nconst mix = (n: number): number => {\n  let x = Math.imul(n ^ 0x9e3779b9, 0x85ebca6b);\n  x = Math.imul(x ^ (x >>> 13), 0xc2b2ae35);\n  return ((x ^ (x >>> 16)) >>> 0) / 4294967296;\n};\n\nconst COUNTS = new Int16Array(CELLS);\nconst AMBIENT = new Float64Array(CELLS);\nlet total = 0;\nfor (let i = 0; i < CELLS; i += 1) {\n  const row = Math.floor(i / COLS);\n  const q = mix((i % COLS) * 7 + row);\n  const n = Math.floor(q * q * (row > 4 ? 6 : 19)) + (q > 0.87 ? 11 : 0);\n  COUNTS[i] = n;\n  AMBIENT[i] = Math.min(1, n / 14) * 0.62; // the data owns the lower ramp; heat owns the top\n  total += n;\n}\nconst TOTAL = total.toLocaleString('en-US');\nconst TODAY = 4 * COLS + (COLS - 1); // Friday of the newest week\n\ninterface State {\n  clock: number;\n  carry: number;\n  u: Float64Array; // excess temperature over ambient, in ramp units\n  next: Float64Array;\n  src: Float64Array; // injection rate per cell, rebuilt every frame\n  x0: number;\n  y0: number;\n  pitch: number;\n  size: number;\n  wasDown: boolean;\n  snap: boolean;\n}\n\nconst advance = (s: State): void => {\n  const u = s.u;\n  const n = s.next;\n  for (let r = 0; r < ROWS; r += 1) {\n    // The mirrored ghost cell: at a wall the offset collapses to 0, so the stencil reads this cell\n    // in place of the missing neighbour and the wall flux is identically zero. Without the mirror the\n    // four edges leak into nothing and the top row would read colder than the middle for no reason.\n    const up = r > 0 ? -COLS : 0;\n    const dn = r < ROWS - 1 ? COLS : 0;\n    for (let c = 0; c < COLS; c += 1) {\n      const i = r * COLS + c;\n      const lf = c > 0 ? -1 : 0;\n      const rt = c < COLS - 1 ? 1 : 0;\n      const lap = u[i + lf] + u[i + rt] + u[i + up] + u[i + dn] - 4 * u[i];\n      const v = u[i] + R * lap - STEP * COOL * u[i] + STEP * s.src[i];\n      // Every source is positive, so u >= 0 holds analytically; the clamp is only here so a single\n      // bad float can never take up permanent residence in the field.\n      n[i] = v > 0 ? Math.min(v, 4) : 0;\n    }\n  }\n  s.u = n;\n  s.next = u;\n};\n\nconst paint = (s: State, cx: number, cy: number, rate: number): void => {\n  const c1 = Math.max(0, Math.ceil(cx - 2));\n  const c2 = Math.min(COLS - 1, Math.floor(cx + 2));\n  const r1 = Math.max(0, Math.ceil(cy - 2));\n  const r2 = Math.min(ROWS - 1, Math.floor(cy + 2));\n  for (let r = r1; r <= r2; r += 1) {\n    const dy = r - cy;\n    for (let c = c1; c <= c2; c += 1) {\n      const dx = c - cx;\n      s.src[r * COLS + c] += rate * Math.exp(-(dx * dx + dy * dy) / SPREAD);\n    }\n  }\n};\n\n/** A held source at one cell for `steps` steps, then released. Shared by setup and reduced motion. */\nconst pulse = (s: State, i: number, steps: number): void => {\n  paint(s, i % COLS, Math.floor(i / COLS), BRUSH * HOLD);\n  for (let k = 0; k < steps; k += 1) {\n    advance(s);\n  }\n  s.src.fill(0);\n};\n\nconst MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace';\nconst MUTED = '#6d7382';\n\nconst render = (\n  ctx: CanvasRenderingContext2D,\n  width: number,\n  height: number,\n  s: State,\n  sel: number,\n): void => {\n  ctx.fillStyle = '#0b0d12';\n  ctx.fillRect(0, 0, width, height);\n\n  const { x0, y0, pitch, size } = s;\n\n  // Squares, never ctx.roundRect: its typing moves between DOM library versions.\n  for (let i = 0; i < CELLS; i += 1) {\n    ctx.fillStyle = ramp(AMBIENT[i] + s.u[i] * GAIN);\n    ctx.fillRect(x0 + (i % COLS) * pitch, y0 + Math.floor(i / COLS) * pitch, size, size);\n  }\n\n  ctx.lineWidth = 1.5;\n  ctx.strokeStyle = 'rgba(236, 238, 243, 0.86)';\n  const sx = x0 + (sel % COLS) * pitch;\n  const sy = y0 + Math.floor(sel / COLS) * pitch;\n  ctx.strokeRect(sx - 1.5, sy - 1.5, size + 3, size + 3);\n\n  ctx.font = `500 10px ${MONO}`;\n  ctx.fillStyle = MUTED;\n  ctx.textBaseline = 'middle';\n  ctx.textAlign = 'right';\n  for (let r = 0; r < ROWS; r += 2) {\n    ctx.fillText(WEEKDAYS[r], x0 - 8, y0 + r * pitch + size / 2);\n  }\n\n  ctx.textAlign = 'left';\n  let seen = dateOf(0).getUTCMonth();\n  let mark = -9;\n  for (let c = 1; c < COLS; c += 1) {\n    const m = dateOf(c).getUTCMonth();\n    if (m !== seen) {\n      seen = m;\n      if (c - mark >= 3) {\n        ctx.fillText(MONTHS[m], x0 + c * pitch, y0 - MONTH_H / 2);\n        mark = c;\n      }\n    }\n  }\n\n  // The legend is the cells' own ramp function sampled across its whole range, so a hot cell can be\n  // read off it instead of being an unexplained bright square.\n  const ly = y0 + ROWS * pitch + LEGEND_H / 2;\n  const sw = Math.max(7, Math.min(12, size * 0.55));\n  ctx.font = `500 9px ${MONO}`;\n  ctx.fillText('AMBIENT', x0, ly);\n  let lx = x0 + ctx.measureText('AMBIENT').width + 9;\n  for (let k = 0; k < 6; k += 1) {\n    ctx.fillStyle = ramp(k / SEGS);\n    ctx.fillRect(lx, ly - sw / 2, sw, sw);\n    lx += sw + 3;\n  }\n  ctx.fillStyle = MUTED;\n  ctx.fillText('HOT', lx + 5, ly);\n};\n\n/** `compact` is the 298x240 catalogue card: the same field, solved by the same explicit\n *  step, with the header cut to one line along the bottom so the calendar is the whole\n *  subject. Presentation only — see `heat-grid.css`. */\nexport type HeatGridProps = { compact?: boolean };\n\nexport function HeatGrid({ compact = false }: HeatGridProps) {\n  const reduced = useReducedMotion();\n  const [sel, setSel] = useState(TODAY);\n  const kick = useRef<number | null>(null);\n\n  const setup = ({ width, height }: SceneSetupContext): State => {\n    const gw = Math.max(1, width - PAD * 2 - LABEL_W);\n    const gh = Math.max(1, height - HEAD - MONTH_H - LEGEND_H - FOOT);\n    const pitch = Math.max(4, Math.min(gw / COLS, gh / ROWS)); // square cells, never a zero divisor\n    const s: State = {\n      clock: 0,\n      carry: 0,\n      u: new Float64Array(CELLS),\n      next: new Float64Array(CELLS),\n      src: new Float64Array(CELLS),\n      x0: PAD + LABEL_W + Math.max(0, (gw - pitch * COLS) / 2),\n      y0: HEAD + MONTH_H + Math.max(0, (gh - pitch * ROWS) / 2),\n      pitch,\n      size: pitch - Math.max(1, Math.min(4, pitch * 0.17)),\n      wasDown: false,\n      snap: reduced,\n    };\n    // Warm the real solver before the first paint. A reader scrolling a gallery gets a pulse already\n    // spreading and dying on the newest week, rather than a still grid waiting to be touched.\n    pulse(s, sel, WARM);\n    return s;\n  };\n\n  const draw = ({ context, width, height, state, pointer }: SceneDrawContext<State>) => {\n    // Refreshed every frame, not just in setup: the preference can flip without a resize.\n    state.snap = reduced;\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\n    const cx = (pointer.x - state.x0 - state.size / 2) / state.pitch;\n    const cy = (pointer.y - state.y0 - state.size / 2) / state.pitch;\n    const over = pointer.inside && cx > -1.5 && cx < COLS + 0.5 && cy > -1.5 && cy < ROWS + 0.5;\n    const hit = kick.current;\n    kick.current = null;\n\n    if (state.snap) {\n      // No loop to spread anything over time, so a keyboard step resolves at once: clear the field\n      // and run the same held source for the same number of steps setup used.\n      if (hit !== null) {\n        state.u.fill(0);\n        pulse(state, hit, WARM);\n      }\n    } else {\n      state.src.fill(0);\n      if (over) {\n        paint(state, cx, cy, pointer.down ? BRUSH * HOLD : BRUSH);\n      }\n      if (hit !== null) {\n        state.u[hit] += KICK; // a keypress is a discrete impulse, not a rate held over a frame\n      }\n      state.carry += dt;\n      let n = 0;\n      while (state.carry >= STEP && n < 8) {\n        advance(state);\n        state.carry -= STEP;\n        n += 1;\n      }\n      if (n === 8) {\n        state.carry = 0; // a backgrounded tab must not come back owing 400 steps\n      }\n    }\n\n    if (pointer.down && !state.wasDown && over) {\n      const c = Math.min(COLS - 1, Math.max(0, Math.round(cx)));\n      const r = Math.min(ROWS - 1, Math.max(0, Math.round(cy)));\n      if (r * COLS + c !== sel) {\n        setSel(r * COLS + c);\n      }\n    }\n    state.wasDown = pointer.down;\n\n    render(context, width, height, state, sel);\n  };\n\n  const { stageRef, canvasRef, requestRender } = useCanvasScene<State>({ setup, draw });\n  useEffect(() => requestRender(), [reduced, requestRender]);\n\n  const step = (dc: number, dr: number): void => {\n    const c = Math.min(COLS - 1, Math.max(0, (sel % COLS) + dc));\n    const r = Math.min(ROWS - 1, Math.max(0, Math.floor(sel / COLS) + dr));\n    kick.current = r * COLS + c;\n    setSel(r * COLS + c);\n    requestRender();\n  };\n\n  const onKey = (event: KeyboardEvent<HTMLButtonElement>): void => {\n    const dc = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;\n    const dr = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;\n    if (dc === 0 && dr === 0) {\n      return;\n    }\n    event.preventDefault();\n    step(dc, dr);\n  };\n\n  const day = dateOf(sel);\n  const count = COUNTS[sel];\n  const stamp = `${WEEKDAYS[Math.floor(sel / COLS)]} ${day.getUTCDate()} ${MONTHS[day.getUTCMonth()]}`;\n\n  return (\n    <div className=\"heat-grid-stage\" data-compact={compact ? 'true' : undefined}>\n      <div ref={stageRef} className=\"heat-grid-surface\">\n        <canvas ref={canvasRef} aria-hidden=\"true\" />\n      </div>\n      <div className=\"heat-grid-content\">\n        <div className=\"heat-grid-titles\">\n          <h3 className=\"heat-grid-title\">Deploy activity</h3>\n          <p className=\"heat-grid-meta\">{TOTAL} deploys in 26 weeks, diffusing at 24 cells²/s</p>\n        </div>\n        {/* Still clickable in a card, and clicking it still drops heat on the probed\n            day, but out of the tab order: the card frame is aria-hidden, and a focusable\n            node inside one is a trap with no label. */}\n        <button\n          type=\"button\"\n          className=\"heat-grid-probe\"\n          tabIndex={compact ? -1 : undefined}\n          aria-keyshortcuts=\"ArrowUp ArrowDown ArrowLeft ArrowRight\"\n          aria-label={`Heat ${stamp}, ${count} deploys. Arrow keys move the probe.`}\n          onClick={() => step(0, 0)}\n          onKeyDown={onKey}\n        >\n          <span className=\"heat-grid-probe-day\">{stamp}</span>\n          <span className=\"heat-grid-probe-count\">\n            {count} {count === 1 ? 'deploy' : 'deploys'}\n          </span>\n        </button>\n      </div>\n      <p className=\"heat-grid-hint\">drag to warm, hold to pin</p>\n    </div>\n  );\n}\n\nexport default HeatGrid;\n","type":"registry:ui"},{"path":"components/ui/heat-grid.css","target":"components/ui/heat-grid.css","content":".heat-grid-stage {\n  position: relative;\n  width: 100%;\n  min-height: 20rem;\n  overflow: hidden;\n  border-radius: 0.875rem;\n  background: #0b0d12;\n  color: #eceef3;\n  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;\n}\n\n/* No border on the measured host: `inset: 0` resolves against the padding box, so a border would\n   slide the canvas origin a pixel off the grid that the pointer-to-cell maths assumes. An inset\n   shadow draws the same hairline without moving anything. */\n.heat-grid-surface {\n  position: absolute;\n  inset: 0;\n  border-radius: inherit;\n  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.055);\n  touch-action: none;\n}\n\n.heat-grid-surface canvas {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n\n/* Transparent to the pointer so a drag started anywhere over the header still warms cells; the\n   probe button takes events back. Height is in px, not rem, because the canvas reserves exactly\n   78 device-independent pixels for this band before it lays out the calendar. */\n.heat-grid-content {\n  position: relative;\n  display: flex;\n  min-height: 78px;\n  align-items: flex-start;\n  justify-content: space-between;\n  gap: 1rem;\n  padding: 1.0625rem 1.125rem 0;\n  pointer-events: none;\n}\n\n.heat-grid-titles {\n  min-width: 0;\n}\n\n.heat-grid-title {\n  margin: 0;\n  font-size: 0.9375rem;\n  font-weight: 600;\n  letter-spacing: -0.01em;\n}\n\n.heat-grid-meta {\n  margin: 0.3125rem 0 0;\n  overflow: hidden;\n  color: #99a0ad;\n  font-size: 0.75rem;\n  font-variant-numeric: tabular-nums;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n}\n\n.heat-grid-probe {\n  display: grid;\n  flex: none;\n  gap: 0.125rem;\n  margin: 0;\n  padding: 0.4375rem 0.6875rem;\n  appearance: none;\n  border: 0;\n  border-radius: 0.5rem;\n  background: rgba(255, 255, 255, 0.05);\n  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.09);\n  color: #eceef3;\n  font: inherit;\n  text-align: right;\n  cursor: pointer;\n  pointer-events: auto;\n  transition:\n    background-color 150ms ease,\n    box-shadow 150ms ease;\n}\n\n.heat-grid-probe:hover {\n  background: rgba(244, 164, 74, 0.14);\n  box-shadow: inset 0 0 0 1px rgba(244, 164, 74, 0.42);\n}\n\n.heat-grid-probe:focus-visible {\n  outline: 2px solid rgba(244, 164, 74, 0.8);\n  outline-offset: 2px;\n}\n\n.heat-grid-probe-day {\n  font-size: 0.75rem;\n  font-weight: 600;\n  letter-spacing: -0.005em;\n}\n\n.heat-grid-probe-count {\n  color: #f4a44a;\n  font-size: 0.6875rem;\n  font-variant-numeric: tabular-nums;\n}\n\n.heat-grid-hint {\n  position: absolute;\n  right: 0.875rem;\n  bottom: 0.75rem;\n  margin: 0;\n  font: 500 0.6875rem/1 ui-monospace, 'SFMono-Regular', Menlo, monospace;\n  letter-spacing: 0.08em;\n  text-transform: uppercase;\n  pointer-events: none;\n  color: rgba(236, 238, 243, 0.3);\n}\n\n/*\n * The animation frame loop never starts, so what is switched off is the diffusion in time: no warm\n * trail following the cursor, no glow decaying after you leave. The canvas still paints the recorded\n * grid and one already-spread pulse on the selected day, produced by running the same explicit\n * solver to that point inside setup — a still of the physics rather than a different picture.\n */\n@media (prefers-reduced-motion: reduce) {\n  .heat-grid-probe {\n    transition: none;\n  }\n}\n\n/*\n * The card variant: the 298x240 catalogue frame, at that real size and never scaled. The\n * calendar already lays itself out against the live canvas box, so nothing here resizes it\n * — all this does is crop the band the header used to occupy and cut the header itself down\n * to one strip along the bottom, which is the only part of the box the canvas keeps empty.\n */\n.heat-grid-stage[data-compact='true'] {\n  min-height: 0;\n  height: 100%;\n  /* The card frame rounds and clips already. */\n  border-radius: 0;\n}\n\n/*\n * `HEAD` in the tsx reserves the top 78px of the canvas for the header that used to sit\n * there, and in a card the header no longer does. Lifting the measured host by exactly that\n * band crops it out of view, so the calendar centres in what is left instead of floating in\n * the lower half under 78px of nothing: at 298 wide the pitch is capped by the width, which\n * pins the grid at 234x63 however tall the host is, so the lift is the whole of the fix.\n * In pixels, because that constant is in pixels.\n *\n * `pan-y` because a full-bleed drag surface that claims every touch traps the page inside a\n * scrolling grid. The vertical gesture goes back to the document; a horizontal drag still\n * warms cells, which is the gesture this mechanism is about.\n */\n.heat-grid-stage[data-compact='true'] .heat-grid-surface {\n  inset: -78px 0 0 0;\n  touch-action: pan-y;\n}\n\n/*\n * The strip lands in the gap under the legend: `FOOT` holds the legend 26px clear of the\n * canvas floor and the lift above leaves it around 150px down, so the two never meet.\n * `pointer-events: none` is inherited from the rule above and left alone, so a drag started\n * over the strip still warms cells; the probe goes on taking its own clicks back.\n */\n.heat-grid-stage[data-compact='true'] .heat-grid-content {\n  position: absolute;\n  inset: auto 0 0 0;\n  min-height: 0;\n  align-items: center;\n  gap: 0.5rem;\n  padding: 0.75rem;\n}\n\n/* The one line of text that stays, at a fixed rem — never `vw`, which would read the\n   viewport rather than the 298px card. Ellipsised rather than wrapped, so it cannot\n   quietly become two lines next to a long date. */\n.heat-grid-stage[data-compact='true'] .heat-grid-title {\n  overflow: hidden;\n  font-size: 0.8125rem;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n}\n\n/* The paragraph would be the second line and the hint the third; the card's own title\n   already says what this is, and the legend on the canvas explains the colour. */\n.heat-grid-stage[data-compact='true'] .heat-grid-meta,\n.heat-grid-stage[data-compact='true'] .heat-grid-hint {\n  display: none;\n}\n\n/* Day and count side by side instead of stacked, so the probe is one line ~20px tall and\n   reads as part of the strip rather than a block sitting on it. It stays because a click\n   on it drops heat on the probed day, which is worth having in a card. */\n.heat-grid-stage[data-compact='true'] .heat-grid-probe {\n  grid-auto-flow: column;\n  align-items: baseline;\n  gap: 0.375rem;\n  padding: 0.1875rem 0.5rem;\n  border-radius: 0.375rem;\n}\n\n.heat-grid-stage[data-compact='true'] .heat-grid-probe-day {\n  font-size: 0.6875rem;\n}\n\n.heat-grid-stage[data-compact='true'] .heat-grid-probe-count {\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":["stagger","micro"],"docs":"https://ui.artbloom.tech/artbloom/animations/heat-grid"}}