{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"kelvin-ramp","type":"registry:ui","title":"Kelvin Ramp","description":"A theme-warmth control whose strip is a real black-body locus — Planck's law through the CIE 1931 observer, then the sRGB transfer curve, solved a column at a time. A thousand kelvin at the warm end is five times the colour change it is at the cool end, so the same drag does different work at either end of the same strip, which a gradient between two stops cannot do at any number of stops.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/kelvin-ramp.tsx","target":"components/ui/kelvin-ramp.tsx","content":"'use client';\n\nimport './kelvin-ramp.css';\n\nimport { useEffect, useId, useRef, useState, type KeyboardEvent as ReactKeyboard, type PointerEvent as ReactPointer } from 'react';\n\nimport { useCanvasScene, useReducedMotion, type SceneDrawContext, type SceneSetupContext } from '@/hooks/use-canvas-scene';\n\n/**\n * A colour-temperature control for a theme picker, whose ramp is Planck's law integrated\n * through the CIE 1931 observer, one column of the strip at a time.\n *\n * THE SPECTRUM. A blackbody at temperature T radiates, per unit wavelength,\n *\n *   B(lambda, T) = 2*h*c^2 / lambda^5 * 1 / (exp(h*c / (lambda*k*T)) - 1)\n *\n * and the only part of that which survives to the screen is the shape. The leading 2*h*c^2 is\n * a constant factor on every wavelength and the normalisation two paragraphs down divides it\n * straight back out, so the code carries exactly one physical constant: the second radiation\n * constant c2 = h*c/k = 1.4387768775e4 um*K, with lambda in micrometres so lambda^-5 lands\n * near unity in the visible instead of near 1e-14.\n *\n * THE OBSERVER. Colour is that spectrum's projection onto three fixed curves,\n *\n *   X = integral B(lambda,T) * xbar(lambda) dlambda\n *   Y = integral B(lambda,T) * ybar(lambda) dlambda\n *   Z = integral B(lambda,T) * zbar(lambda) dlambda\n *\n * taken over 380..780nm by the trapezium rule on 21 samples 20nm apart: half weight on the two\n * end rows, full weight on the nineteen between them. dlambda is a common factor on all three\n * sums and the Y normalisation removes it, so it never appears in the code.\n *\n * EQUAL LUMINANCE. X and Z are divided by Y and Y is set to 1, so every temperature is\n * compared at the luminance of white. That is what makes this a chromaticity ramp: what\n * changes along the strip is the colour, and 1600K is not dark merely because a candle is dim.\n *\n * TO sRGB. The standard D65 matrix, unrounded:\n *\n *   r =  3.2404542*X - 1.5371385*Y - 0.4985314*Z\n *   g = -0.9692660*X + 1.8760108*Y + 0.0415560*Z\n *   b =  0.0556434*X - 0.2040259*Y + 1.0572252*Z\n *\n * GAMUT. Below about 1900K the locus leaves the sRGB triangle and b comes out negative. The\n * answer is not to clamp that channel: a clamp moves the chromaticity in whatever direction\n * the clamp happens to point, and changes the luminance while it does it. Instead the triple is\n * mixed with the white point, along the line joining them, by exactly the fraction that lifts\n * the low channel to zero. With m = min(r,g,b) < 0 that fraction is\n *\n *   t = -m / (1 - m)      giving   c' = (c - m) / (1 - m)   for each channel\n *\n * and because the white point at Y = 1 is linear (1,1,1) — whose luminance is 1 by construction\n * of the matrix above — while the triple being mixed also has luminance 1, the mix is\n * luminance-preserving for free. The branch is live, not defensive: it fires from 1600K up to\n * about 1900K, t peaks at 0.049, and the Candle preset sits inside that band.\n *\n * EXPOSURE, then the transfer function. A triple at Y = 1 has its largest channel above 1\n * everywhere except at the white point, so the triple is divided by that largest channel — one\n * factor on all three, which cannot move the chromaticity — and then multiplied by EXPOSURE.\n * The encode is the piecewise sRGB curve, 12.92*c below c = 0.0031308 and 1.055*c^(1/2.4) -\n * 0.055 above it, and not a bare 2.2 power: those two disagree by several levels in the\n * shadows, which is exactly where the warm end of this ramp keeps its blue channel.\n *\n * IT IS NOT a CSS linear-gradient between two or three hand-picked stops, and it is not the\n * mired polynomial that every \"kelvin to RGB\" snippet copies. Both are fitted to the locus\n * rather than derived from it, and both fail the same two ways. They cut the corner between an\n * orange stop and a blue stop straight through the middle of the gradient, which puts a pink or\n * a flat grey where the Planckian locus has neither — no hot body emits a pink, and the locus\n * bends around that region instead of through it. And they are parameterised so that equal\n * steps along the gradient look like equal steps of colour, which the real locus is not.\n *\n * THE ONE VISIBLE CONSEQUENCE, under your hand: the strip is not evenly paced. Drag a thousand\n * kelvin at the bottom and a thousand at the top and the second barely moves. 2000K -> 3000K\n * travels rgb(239,129,20) -> rgb(239,171,101); 6000K -> 7000K travels rgb(239,228,224) ->\n * rgb(229,227,239). Ninety-one levels of RGB against eighteen — five times as far for the same\n * thousand kelvin, because the locus crowds together as T rises, its coordinates converging on\n * a limit point as 1/T goes to zero. So the control is loose down low and stiff up high, and a\n * 100K arrow-key step that is an obvious change at the candle end is invisible at the blue one.\n *\n * THE TABLE. 21 rows at 20nm, not 81 at 5nm, and here the coarse table is not a compromise.\n * Checked against the full 5nm table across 1600..12000K, the 20nm trapezium moves no channel\n * by more than one level in 255 and the chromaticity by at most 0.0008 in xy — under a\n * just-noticeable difference on a swatch. There is nothing a finer table could change about the\n * pacing or the shape of the locus, because those come from Planck's law and not from the\n * quadrature; what it would change is a spiky spectrum, where a fluorescent tube's mercury\n * lines fall between 20nm samples and are simply missed. A blackbody has no lines to miss.\n *\n * WHAT THE FIXED STEP IS FOR. Nothing here integrates a force, and a whole strip is about eight\n * thousand calls to expm1 — a fraction of one frame. The step is not buying stability and it is\n * not spreading a cost: it paces the refinement, so the strip resolves at the same speed on a\n * 60Hz and a 144Hz panel. Columns are solved in stride order, every 16th and then the 8s, 4s,\n * 2s and 1s, so the strip is complete after a sixteenth of the work and visibly sharpens from\n * there, and no column is ever solved twice.\n */\n\n/**\n * The CIE 1931 2-degree observer, tabulated at 20nm from 380nm to 780nm: wavelength in\n * nanometres, then xbar, ybar, zbar. Twenty-one rows, the standard values, no interpolation\n * and nothing invented. ybar is the photopic luminosity function, which is why Y is luminance\n * and why dividing by it is what puts two temperatures at the same brightness.\n *\n * Why this is enough is argued above, with the measurement.\n */\nconst CMF: readonly (readonly [number, number, number, number])[] = [\n  [380, 0.001368, 0.000039, 0.006450],\n  [400, 0.014310, 0.000396, 0.067850],\n  [420, 0.134380, 0.004000, 0.645600],\n  [440, 0.348280, 0.023000, 1.747060],\n  [460, 0.290800, 0.060000, 1.669200],\n  [480, 0.095640, 0.139020, 0.812950],\n  [500, 0.004900, 0.323000, 0.272000],\n  [520, 0.063270, 0.710000, 0.078250],\n  [540, 0.290400, 0.954000, 0.020300],\n  [560, 0.594500, 0.995000, 0.003900],\n  [580, 0.916300, 0.870000, 0.001650],\n  [600, 1.062200, 0.631000, 0.000800],\n  [620, 0.854450, 0.381000, 0.000190],\n  [640, 0.447900, 0.175000, 0.000020],\n  [660, 0.164900, 0.061000, 0.000000],\n  [680, 0.046770, 0.017000, 0.000000],\n  [700, 0.011359, 0.004102, 0.000000],\n  [720, 0.002899, 0.001047, 0.000000],\n  [740, 0.000690, 0.000249, 0.000000],\n  [760, 0.000166, 0.000060, 0.000000],\n  [780, 0.000042, 0.000015, 0.000000],\n];\n\n/**\n * Ends of the range. 1600K is about as low as this is worth going: below it the locus is far\n * enough outside sRGB that the mix toward white eats the last of the hue and the swatches stop\n * separating. 12000K is where the far end has flattened out — another thousand kelvin past it\n * moves a channel by one level, which is the limit point the locus is converging on.\n */\nconst MIN_K = 1600;\nconst MAX_K = 12000;\n/**\n * Where the control starts: a warm studio white. Off-neutral on purpose, so the first painted\n * frame shows a ramp with a colour in it rather than a grey strip with a white chip on it.\n */\nconst DEFAULT_K = 3200;\n/**\n * Kelvin the value is quantised to. 50K is under two pixels of a 370px strip, so a drag stays\n * continuous to the eye, and both key steps below are multiples of it, so the keyboard and the\n * pointer land on the same set of values instead of two interleaved ones.\n */\nconst GRID = 50;\n/** Arrow and page steps: 100K crosses the range in 104 presses, 1000K in ten. */\nconst KEY_FINE = 100;\nconst KEY_PAGE = 1000;\n\n/** Second radiation constant h*c/k in um*K, so lambda can be micrometres. */\nconst C2 = 1.4387768775e4;\n/**\n * One multiplier on all three linear channels after the gamut fit, which is why it cannot move\n * a hue. The fitted triple has its brightest channel at exactly 1, so without this the neutral\n * end of the ramp is paper white. 0.86 is the largest value that still reads as a surface\n * rather than as the page, and it leaves the darkest swatch in the range — 1600K, at a\n * luminance near 0.29 — bright enough for near-black ink on the preview to clear 4.5:1, so the\n * ink never has to flip sides part-way through a drag.\n */\nconst EXPOSURE = 0.86;\n\n/**\n * Seconds per substep. Nothing here integrates a force, so 1/240 buys no stability; 1/120 is\n * the rate at which a fixed budget of columns is solved, so the strip sharpens at the same\n * speed whatever the display refresh is.\n */\nconst STEP = 1 / 120;\n/** Substeps one frame may consume, so a tab restored after a minute cannot aggregate a minute. */\nconst MAX_SUB = 4;\n/**\n * Columns solved per substep: 720 a second. The coarse pass of a 370px strip is on screen\n * inside two frames at 60Hz, so the ramp is never blank under the handle, and the last\n * refinement lands about half a second later — long enough to read as the strip resolving,\n * short enough that a reader who drags immediately is never fighting it.\n */\nconst COLS_PER_STEP = 6;\n/**\n * Stride of the first pass. 16 puts the whole strip on screen for a sixteenth of the work; 32\n * would be faster to first paint but the blocks are then wide enough to read as banding, which\n * is the one thing this ramp must not look like.\n */\nconst COARSE_STRIDE = 16;\n/** 16, 8, 4, 2, 1. Derived rather than written out, so the two cannot drift apart. */\nconst PASS_COUNT = Math.log2(COARSE_STRIDE) + 1;\n\n/**\n * Handle width in CSS pixels, and the width of the gap the strip is cut with. Both live here\n * because the canvas cuts the socket and the component sizes the chip: 18 against 14 leaves two\n * pixels of dark either side, which is what makes the handle read as lifted out of the strip\n * rather than laid on top of it.\n */\nconst THUMB = 14;\nconst THUMB_HALF = THUMB / 2;\nconst NOTCH_HALF = 9;\n\n/** What the light at this temperature is, in words. Upper bound of each band, in kelvin. */\nconst BANDS: readonly (readonly [number, string])[] = [\n  [2100, 'Candle flame'],\n  [2900, 'Tungsten lamp'],\n  [3700, 'Halogen studio'],\n  [4600, 'Cool fluorescent'],\n  [5600, 'Direct sunlight'],\n  [6800, 'Noon daylight'],\n  [8200, 'Overcast sky'],\n];\n/** Above the last band. */\nconst BAND_TOP = 'Open shade';\n\n/** Presets, as real buttons. Candle sits at 1800K, inside the out-of-gamut band, on purpose. */\nconst PRESETS: readonly (readonly [string, number])[] = [\n  ['Candle', 1800],\n  ['Tungsten', 2800],\n  ['Daylight', 5600],\n  ['Shade', 9000],\n];\n\n/**\n * Planck's law, up to the constant factor the Y normalisation removes. `expm1` is exp(x) - 1\n * without the cancellation; inside this range x never drops below 1.5, where the two forms agree\n * to the last bit, but this one stays right if the range is ever widened.\n */\nfunction planck(nm: number, kelvin: number): number {\n  const um = nm / 1000;\n  return 1 / (um ** 5 * Math.expm1(C2 / (um * kelvin)));\n}\n\n/**\n * The sRGB transfer function, to a level in 0..255. The clamp is not decoration: the mix toward\n * white lands the low channel on zero, and in floating point that arrives as a value a hair\n * either side of it.\n */\nfunction encode(linear: number): number {\n  const c = Math.min(1, Math.max(0, linear));\n  const signal = c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055;\n  return Math.round(255 * signal);\n}\n\n/**\n * One temperature, all the way to a colour the DOM and the canvas can both take. This is the\n * whole solver, and its domain is MIN_K..MAX_K.\n */\nfunction solveColour(kelvin: number): string {\n  let sumX = 0;\n  let sumY = 0;\n  let sumZ = 0;\n  for (let i = 0; i < CMF.length; i += 1) {\n    const [nm, xbar, ybar, zbar] = CMF[i];\n    // Trapezium rule: the two end rows count half, the nineteen between them count once.\n    const weight = i === 0 || i === CMF.length - 1 ? 0.5 : 1;\n    const power = planck(nm, kelvin) * weight;\n    sumX += power * xbar;\n    sumY += power * ybar;\n    sumZ += power * zbar;\n  }\n\n  // Equal luminance. Y becomes exactly 1, which is why the middle column of the matrix below is\n  // a bare constant rather than a product, and why dlambda never had to be carried: it is a\n  // common factor on all three sums and it divides out here.\n  const x = sumX / sumY;\n  const z = sumZ / sumY;\n  let r = 3.2404542 * x - 1.5371385 - 0.4985314 * z;\n  let g = -0.969266 * x + 1.8760108 + 0.041556 * z;\n  let b = 0.0556434 * x - 0.2040259 + 1.0572252 * z;\n\n  // Out of gamut: mix with the white point by the fraction that lifts the low channel to zero.\n  // Linear (1,1,1) has luminance 1 and so does this triple, so the mix costs no luminance.\n  const low = Math.min(r, g, b);\n  if (low < 0) {\n    const span = 1 - low;\n    r = (r - low) / span;\n    g = (g - low) / span;\n    b = (b - low) / span;\n  }\n\n  // The luminance weights sum to 1, so a triple at Y = 1 has its largest channel at 1 or above\n  // and this divisor is never small: no guard is needed, and none is a lie.\n  const gain = EXPOSURE / Math.max(r, g, b);\n  return `rgb(${encode(r * gain)}, ${encode(g * gain)}, ${encode(b * gain)})`;\n}\n\n/** The value, on the grid and inside the ends. Every path that sets it goes through here. */\nfunction clampKelvin(kelvin: number): number {\n  return Math.min(MAX_K, Math.max(MIN_K, Math.round(kelvin / GRID) * GRID));\n}\n\n/** What the light at this temperature is called. The caption under the value, in words. */\nfunction describe(kelvin: number): string {\n  for (const [upper, name] of BANDS) {\n    if (kelvin < upper) return name;\n  }\n  return BAND_TOP;\n}\n\ninterface RampState {\n  /** Columns of strip, one per CSS pixel of width. */\n  readonly cols: number;\n  /** Pixels the value maps across: the width less the handle, so both ends line up. */\n  readonly travel: number;\n  /** The solved colour of every column. This array is the ramp, and nothing else is. */\n  readonly colour: string[];\n  /** Refinement pass in flight. At PASS_COUNT every column has been solved exactly once. */\n  pass: number;\n  /** Next column of the current pass. */\n  cursor: number;\n  /** Centre of the gap the handle sits in, in CSS pixels, written from the value each frame. */\n  notch: number;\n  /** Nothing is repainted unless a column landed or the gap moved. */\n  dirty: boolean;\n  carry: number;\n  clock: number;\n}\n\n/**\n * The three numbers that define a pass. Pass 0 solves every 16th column and paints 16 wide;\n * pass 1 solves the 8s that fell between them and paints 8 wide; then the 4s, the 2s and the\n * odd columns. Every index in the strip belongs to exactly one pass, so the total work is the\n * column count and not a multiple of it.\n */\nfunction passStride(pass: number): number {\n  return pass === 0 ? COARSE_STRIDE : COARSE_STRIDE >> pass;\n}\n\nfunction passStart(pass: number): number {\n  return pass === 0 ? 0 : COARSE_STRIDE >> pass;\n}\n\n/**\n * The temperature one column of the strip shows. Offset and clamped by half the handle at each\n * end, so the colour under the chip is the colour the chip is at every position — including the\n * two ends, where the outermost seven pixels all hold the end temperature rather than running\n * past it. Linear in kelvin, which is the whole point: the ramp is allowed to be unevenly paced\n * because the locus is, and re-spacing the axis to even it out would be the lie.\n */\nfunction temperatureAt(state: RampState, column: number): number {\n  const along = state.travel > 0 ? (column + 0.5 - THUMB_HALF) / state.travel : 0;\n  return MIN_K + (MAX_K - MIN_K) * Math.min(1, Math.max(0, along));\n}\n\n/**\n * Solve at most `budget` columns, in stride order, picking up wherever the last call stopped.\n * A pass boundary costs no budget, so a call that lands on one still does its work.\n */\nfunction resolve(state: RampState, budget: number): void {\n  let solved = 0;\n  while (solved < budget && state.pass < PASS_COUNT) {\n    if (state.cursor >= state.cols) {\n      state.pass += 1;\n      state.cursor = state.pass < PASS_COUNT ? passStart(state.pass) : 0;\n      continue;\n    }\n    const stride = passStride(state.pass);\n    const css = solveColour(temperatureAt(state, state.cursor));\n    // The block this column stands for until a finer pass overwrites its interior.\n    const end = Math.min(state.cols, state.cursor + stride);\n    for (let i = state.cursor; i < end; i += 1) state.colour[i] = css;\n    state.cursor += state.pass === 0 ? stride : stride * 2;\n    state.dirty = true;\n    solved += 1;\n  }\n}\n\n/** Where the gap goes, from the value. The arithmetic the handle's own `left` repeats in CSS. */\nfunction notchAt(state: RampState, kelvin: number): number {\n  return THUMB_HALF + ((kelvin - MIN_K) / (MAX_K - MIN_K)) * state.travel;\n}\n\n/**\n * A strip sized to the box, with nothing solved yet. `offsetWidth` is an integer, so one column\n * per CSS pixel is exact and the handle's percentage travel and the gap's pixel travel are the\n * same distance. The floor on `travel` is for the one-pixel box a stage can be measured at\n * mid-layout, where the division would otherwise be by zero.\n */\nfunction build({ width }: SceneSetupContext, kelvin: number): RampState {\n  const cols = Math.max(1, Math.round(width));\n  const state: RampState = {\n    cols,\n    travel: Math.max(1, cols - THUMB),\n    colour: new Array<string>(cols).fill(''),\n    pass: 0,\n    cursor: 0,\n    notch: 0,\n    dirty: true,\n    carry: 0,\n    clock: 0,\n  };\n  state.notch = notchAt(state, kelvin);\n  return state;\n}\n\n/**\n * The strip, straight off the solved array: one filled column per CSS pixel, and a gap where the\n * handle sits. Columns not yet solved are left empty and the layer's own dark background stands\n * in for them, which is what the coarse first pass fills in.\n *\n * The early return is the steady state, not an optimisation for a rare case. Once every column\n * is solved and the value is still, this scene draws nothing at all — a ramp is not an animation\n * and the loop should not be paying for one.\n */\nfunction paint({ context, width, height, dpr, state }: SceneDrawContext<RampState>): void {\n  if (!state.dirty) return;\n  state.dirty = false;\n  context.clearRect(0, 0, width, height);\n\n  for (let x = 0; x < state.cols; x += 1) {\n    const css = state.colour[x];\n    if (!css) continue;\n    if (Math.abs(x + 0.5 - state.notch) < NOTCH_HALF) continue;\n    // Snapped to whole device pixels. At a fractional device ratio a one-pixel column starts on\n    // a half pixel, and the antialiasing that follows reads as dark seams ruled down the ramp.\n    const left = Math.round(x * dpr) / dpr;\n    const right = Math.round((x + 1) * dpr) / dpr;\n    context.fillStyle = css;\n    context.fillRect(left, 0, right - left, height);\n  }\n}\n\n/**\n * The control. The strip's canvas sits in a layer that holds nothing else, and the slider is a\n * sibling above it — a real focusable element with a real role, so the value is announced as a\n * slider and reachable from the keyboard, and its own pointer events are not swallowed by the\n * capture the canvas layer takes. One function solves the colour in both places: once per column\n * inside the scene, and once per render for the handle and the preview, so the chip can never be\n * a different colour from the strip it is sitting in.\n *\n * `compact` is the 298x240 catalogue card: the same strip, re-solved at that width, with the prose\n * dropped and the slider left pointer-live but out of the tab order — the card's own title link is\n * the accessible path to the item, so a control inside the frame must not be a second stop.\n */\nexport type KelvinRampProps = { compact?: boolean };\n\nexport function KelvinRamp({ compact = false }: KelvinRampProps) {\n  const reduced = useReducedMotion();\n  const [kelvin, setKelvin] = useState(DEFAULT_K);\n  const uid = useId();\n  const helpId = `${uid}-keys`;\n\n  // Mirrors, because the scene runs inside a loop React does not drive.\n  const kelvinRef = useRef(kelvin);\n  kelvinRef.current = kelvin;\n  const snapRef = useRef(reduced);\n  snapRef.current = reduced;\n  const dragRef = useRef(false);\n\n  // Twenty-one rows of expm1 per render. That is cheaper than the bookkeeping a memo of it would\n  // need, and it is the same call the canvas makes for each of its own columns.\n  const surface = solveColour(kelvin);\n  const fraction = (kelvin - MIN_K) / (MAX_K - MIN_K);\n\n  const draw = (scene: SceneDrawContext<RampState>) => {\n    const { state } = scene;\n    const notch = notchAt(state, kelvinRef.current);\n    if (notch !== state.notch) {\n      state.notch = notch;\n      state.dirty = true;\n    }\n\n    if (snapRef.current) {\n      // The settled strip, in the single frame a stopped loop gives us. The refinement takes\n      // exactly `cols` solves in total, so this budget cannot fall short of finishing it.\n      resolve(state, state.cols);\n    } else {\n      const now = performance.now() / 1000;\n      const elapsed = state.clock === 0 ? 0 : Math.min(0.25, now - state.clock);\n      state.clock = now;\n      state.carry += elapsed;\n      let taken = 0;\n      while (state.carry >= STEP && taken < MAX_SUB) {\n        resolve(state, COLS_PER_STEP);\n        state.carry -= STEP;\n        taken += 1;\n      }\n      if (taken === MAX_SUB) state.carry = 0;\n    }\n\n    paint(scene);\n  };\n\n  const { stageRef, canvasRef, requestRender } = useCanvasScene<RampState>({\n    setup: (scene) => build(scene, kelvinRef.current),\n    draw,\n  });\n\n  // The gap has to repaint on the value's account: with the loop stopped under reduced motion\n  // nothing else would, and the strip would keep its gap where the last value left it.\n  useEffect(() => requestRender(), [kelvin, reduced, requestRender]);\n\n  /**\n   * A pointer position, straight to a temperature. The travel is inset by half the handle at\n   * each end so grabbing the chip does not jump it, and so the ends of the strip are reachable\n   * without dragging past the box.\n   */\n  const setFromClientX = (element: HTMLDivElement, clientX: number) => {\n    const rect = element.getBoundingClientRect();\n    const travel = rect.width - THUMB;\n    const along = travel > 0 ? (clientX - rect.left - THUMB_HALF) / travel : 0;\n    setKelvin(clampKelvin(MIN_K + (MAX_K - MIN_K) * Math.min(1, Math.max(0, along))));\n  };\n\n  // Capture on the slider itself, so a drag that leaves the 44px strip keeps setting the value\n  // instead of stopping the moment the hand strays vertically.\n  const onPointerDown = (event: ReactPointer<HTMLDivElement>) => {\n    dragRef.current = true;\n    event.currentTarget.focus();\n    event.currentTarget.setPointerCapture(event.pointerId);\n    setFromClientX(event.currentTarget, event.clientX);\n  };\n\n  const onPointerMove = (event: ReactPointer<HTMLDivElement>) => {\n    if (dragRef.current) setFromClientX(event.currentTarget, event.clientX);\n  };\n\n  const onPointerUp = (event: ReactPointer<HTMLDivElement>) => {\n    dragRef.current = false;\n    if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n      event.currentTarget.releasePointerCapture(event.pointerId);\n    }\n  };\n\n  const onKeyDown = (event: ReactKeyboard<HTMLDivElement>) => {\n    let next = kelvin;\n    switch (event.key) {\n      case 'ArrowRight':\n      case 'ArrowUp':\n        next += KEY_FINE;\n        break;\n      case 'ArrowLeft':\n      case 'ArrowDown':\n        next -= KEY_FINE;\n        break;\n      case 'PageUp':\n        next += KEY_PAGE;\n        break;\n      case 'PageDown':\n        next -= KEY_PAGE;\n        break;\n      case 'Home':\n        next = MIN_K;\n        break;\n      case 'End':\n        next = MAX_K;\n        break;\n      default:\n        return;\n    }\n    // Only for the keys handled above, so nothing else a reader presses is swallowed here — and\n    // the page cannot scroll out from under a Page key that has just moved the value.\n    event.preventDefault();\n    setKelvin(clampKelvin(next));\n  };\n\n  return (\n    <div className=\"kelvin-ramp-stage\" data-compact={compact ? 'true' : undefined}>\n      <div className=\"kelvin-ramp-card\">\n        <p className=\"kelvin-ramp-kicker\">Appearance</p>\n        <h3 className=\"kelvin-ramp-title\">Display warmth</h3>\n        <p className=\"kelvin-ramp-lead\">\n          Every panel, border and hover state in the theme is mixed from one colour temperature.\n          Drag the strip to choose it, and the card above takes the result.\n        </p>\n\n        <div className=\"kelvin-ramp-preview\" style={{ backgroundColor: surface }}>\n          <p className=\"kelvin-ramp-preview-kicker\">Panel surface</p>\n          <p className=\"kelvin-ramp-preview-title\">Draft saved at 14:06</p>\n          <p className=\"kelvin-ramp-preview-body\">\n            Sidebars, cards and hover states all take this temperature. Type stays near-black at\n            every setting, so no screen has to be checked for contrast twice.\n          </p>\n        </div>\n\n        <div className=\"kelvin-ramp-ramp\">\n          <div ref={stageRef} className=\"kelvin-ramp-band\" aria-hidden=\"true\">\n            <canvas ref={canvasRef} />\n          </div>\n\n          <div\n            className=\"kelvin-ramp-control\"\n            role=\"slider\"\n            tabIndex={compact ? -1 : 0}\n            aria-label=\"Colour temperature\"\n            aria-valuemin={MIN_K}\n            aria-valuemax={MAX_K}\n            aria-valuenow={kelvin}\n            aria-valuetext={`${kelvin} K`}\n            aria-describedby={helpId}\n            onKeyDown={onKeyDown}\n            onPointerDown={onPointerDown}\n            onPointerMove={onPointerMove}\n            onPointerUp={onPointerUp}\n            onPointerCancel={onPointerUp}\n          >\n            {/* Width and travel inline: the canvas cuts the gap this chip sits in from the same\n                two numbers, and a second copy of them in the stylesheet is how they drift. */}\n            <div\n              className=\"kelvin-ramp-thumb\"\n              style={{\n                width: `${THUMB}px`,\n                left: `calc(${fraction.toFixed(5)} * (100% - ${THUMB}px) + ${THUMB_HALF}px)`,\n                backgroundColor: surface,\n              }}\n            />\n          </div>\n        </div>\n\n        <div className=\"kelvin-ramp-row\">\n          <p className=\"kelvin-ramp-value\">\n            {kelvin}\n            <span className=\"kelvin-ramp-unit\">K</span>\n          </p>\n          <p className=\"kelvin-ramp-name\">{describe(kelvin)}</p>\n        </div>\n\n        <div className=\"kelvin-ramp-presets\" role=\"group\" aria-label=\"Preset temperatures\">\n          {PRESETS.map(([name, preset]) => (\n            <button\n              key={preset}\n              type=\"button\"\n              className=\"kelvin-ramp-preset\"\n              aria-pressed={preset === kelvin}\n              onClick={() => setKelvin(clampKelvin(preset))}\n            >\n              <span className=\"kelvin-ramp-preset-name\">{name}</span>\n              <span className=\"kelvin-ramp-preset-k\">{preset} K</span>\n            </button>\n          ))}\n        </div>\n\n        <p className=\"kelvin-ramp-help\" id={helpId}>\n          Arrow keys move 100 K, Page Up and Page Down move 1000 K, Home and End take the ends.\n        </p>\n      </div>\n\n      <p className=\"kelvin-ramp-hint\">drag the strip</p>\n    </div>\n  );\n}\n\nexport default KelvinRamp;\n","type":"registry:ui"},{"path":"components/ui/kelvin-ramp.css","target":"components/ui/kelvin-ramp.css","content":"/*\n * The room the control sits in. Near-black, one white, and the only colour on the surface is\n * the colour the solver returns: the strip and the preview slab. Nothing here declares a hue,\n * so the card cannot drift out of step with the ramp — there is no second source of truth.\n */\n.kelvin-ramp-stage {\n  position: relative;\n  display: grid;\n  place-content: center;\n  width: 100%;\n  min-height: 33rem;\n  padding: 2.25rem 1.5rem;\n  overflow: hidden;\n  border-radius: 0.75rem;\n  background: radial-gradient(125% 115% at 50% 0%, #0b1219 0%, #070b11 58%, #04060a 100%);\n  color: #e9f1f9;\n}\n\n/* `min()` and not a media query: at 390px the card is the viewport less the stage padding,\n   so no width in this file can push the page sideways. */\n.kelvin-ramp-card {\n  width: min(24rem, 100%);\n  padding: 1.375rem;\n  border: 1px solid rgba(255, 255, 255, 0.09);\n  border-radius: 1rem;\n  background: linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012));\n}\n\n.kelvin-ramp-kicker {\n  margin: 0 0 0.4375rem;\n  font: 500 0.6875rem/1 ui-monospace, 'SFMono-Regular', Menlo, monospace;\n  letter-spacing: 0.15em;\n  text-transform: uppercase;\n  color: rgba(233, 241, 249, 0.44);\n}\n\n.kelvin-ramp-title {\n  margin: 0;\n  font-size: 1.3125rem;\n  font-weight: 500;\n  line-height: 1.15;\n  letter-spacing: -0.025em;\n  color: #f4f9ff;\n}\n\n.kelvin-ramp-lead {\n  margin: 0.5rem 0 0;\n  font-size: 0.8125rem;\n  line-height: 1.5;\n  color: rgba(233, 241, 249, 0.6);\n}\n\n/*\n * The preview. Its background is written from React as the solver's answer for the current\n * value — the one place in this file where a colour arrives from outside it. Everything on the\n * slab is ink rather than white, because the swatch is the brighter surface: the strip runs\n * from a luminance of about 0.29 at 1600K to 0.83 at the neutral end, so near-black type\n * clears 4.5:1 at every setting and never has to swap sides mid-drag.\n */\n.kelvin-ramp-preview {\n  min-height: 6.5rem;\n  margin: 1.125rem 0 0;\n  padding: 0.875rem 1rem 1rem;\n  border-radius: 0.625rem;\n  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.16);\n}\n\n.kelvin-ramp-preview-kicker {\n  margin: 0;\n  font: 500 0.6875rem/1 ui-monospace, 'SFMono-Regular', Menlo, monospace;\n  letter-spacing: 0.13em;\n  text-transform: uppercase;\n  color: #1f252c;\n}\n\n.kelvin-ramp-preview-title {\n  margin: 0.5rem 0 0;\n  font-size: 1.0625rem;\n  font-weight: 500;\n  line-height: 1.2;\n  letter-spacing: -0.02em;\n  color: #0d1117;\n}\n\n.kelvin-ramp-preview-body {\n  margin: 0.4375rem 0 0;\n  font-size: 0.75rem;\n  line-height: 1.5;\n  color: #1c2128;\n}\n\n/*\n * The strip and its handle, stacked in one box. The wrapper does not clip, so the handle may\n * stand a little proud of the strip and the focus ring is not cut off; the clipping is one\n * level down, on the layer that holds the canvas.\n */\n.kelvin-ramp-ramp {\n  position: relative;\n  height: 2.75rem;\n  margin: 1rem 0 0;\n}\n\n/*\n * The canvas layer, and the only thing inside it is the canvas. It is `aria-hidden`, and the\n * pointer never reaches it: the slider above covers it completely and takes every event, which\n * is the sibling rule doing its job rather than being worked around. `overflow: hidden` is what\n * rounds the strip's corners — the solver knows nothing about the border radius. The background\n * is what shows through the gap the strip is cut with, and through the columns not solved yet.\n */\n.kelvin-ramp-band {\n  position: absolute;\n  inset: 0;\n  overflow: hidden;\n  border-radius: 0.5rem;\n  background: #0a0f15;\n  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);\n}\n\n.kelvin-ramp-band canvas {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n\n/*\n * The slider itself: a real focusable element with a real role, sitting on top of the canvas\n * rather than drawn into it. `pan-y` rather than `none` — a 44px band that swallows vertical\n * touches traps the page in a scrolling list, and the value is read from x alone, so a sideways\n * drag still arrives intact.\n */\n.kelvin-ramp-control {\n  position: absolute;\n  inset: 0;\n  border-radius: 0.5rem;\n  cursor: ew-resize;\n  touch-action: pan-y;\n}\n\n.kelvin-ramp-control:focus-visible {\n  outline: 2px solid rgba(233, 241, 249, 0.8);\n  outline-offset: 3px;\n}\n\n/*\n * The handle. Its width and its travel are set inline from the component, because the canvas\n * cuts the gap it sits in from the same constant — a copy of that number here is how the chip\n * and its socket drift apart at some width nobody tested. Its fill is the solver's answer, so\n * the handle is a swatch of the value and not a marker pointing at one.\n */\n.kelvin-ramp-thumb {\n  position: absolute;\n  top: -0.25rem;\n  bottom: -0.25rem;\n  border-radius: 0.3125rem;\n  box-shadow:\n    0 0 0 1px rgba(255, 255, 255, 0.82),\n    0 2px 9px rgba(0, 0, 0, 0.6);\n  transform: translateX(-50%);\n  pointer-events: none;\n}\n\n.kelvin-ramp-row {\n  display: flex;\n  align-items: baseline;\n  justify-content: space-between;\n  gap: 0.75rem;\n  margin: 0.9375rem 0 0;\n}\n\n.kelvin-ramp-value {\n  margin: 0;\n  font-size: 1.75rem;\n  font-weight: 500;\n  line-height: 1;\n  letter-spacing: -0.03em;\n  font-variant-numeric: tabular-nums;\n  color: #f4f9ff;\n}\n\n.kelvin-ramp-unit {\n  margin-left: 0.2em;\n  font-size: 0.875rem;\n  font-weight: 500;\n  letter-spacing: 0;\n  color: rgba(233, 241, 249, 0.5);\n}\n\n.kelvin-ramp-name {\n  margin: 0;\n  font: 500 0.6875rem/1 ui-monospace, 'SFMono-Regular', Menlo, monospace;\n  letter-spacing: 0.1em;\n  text-transform: uppercase;\n  color: rgba(233, 241, 249, 0.48);\n}\n\n/* Four real buttons, not four painted dots. `minmax(0, 1fr)` so a long label shrinks its own\n   column instead of widening the grid past the card. */\n.kelvin-ramp-presets {\n  display: grid;\n  grid-template-columns: repeat(4, minmax(0, 1fr));\n  gap: 0.3125rem;\n  margin: 0.875rem 0 0;\n}\n\n.kelvin-ramp-preset {\n  display: grid;\n  gap: 0.1875rem;\n  appearance: none;\n  margin: 0;\n  padding: 0.375rem 0.25rem 0.4375rem;\n  border: 1px solid rgba(255, 255, 255, 0.14);\n  border-radius: 0.5rem;\n  background: rgba(6, 11, 17, 0.5);\n  font: inherit;\n  color: rgba(233, 241, 249, 0.74);\n  cursor: pointer;\n  transition:\n    border-color 160ms ease,\n    background-color 160ms ease,\n    color 160ms ease;\n}\n\n.kelvin-ramp-preset:hover {\n  border-color: rgba(233, 241, 249, 0.34);\n  color: #f4f9ff;\n}\n\n/* The pressed state is white, not the swatch colour: the strip and the slab are already\n   carrying the one colour, and a third surface taking it would stop reading as the output. */\n.kelvin-ramp-preset[aria-pressed='true'] {\n  border-color: rgba(233, 241, 249, 0.46);\n  background: rgba(233, 241, 249, 0.12);\n  color: #f8fbff;\n}\n\n.kelvin-ramp-preset:focus-visible {\n  outline: 2px solid rgba(233, 241, 249, 0.8);\n  outline-offset: 2px;\n}\n\n.kelvin-ramp-preset-name {\n  font-size: 0.6875rem;\n  font-weight: 500;\n  line-height: 1.1;\n}\n\n.kelvin-ramp-preset-k {\n  font: 500 0.625rem/1 ui-monospace, 'SFMono-Regular', Menlo, monospace;\n  letter-spacing: 0.04em;\n  font-variant-numeric: tabular-nums;\n  color: rgba(233, 241, 249, 0.44);\n}\n\n/* Named by the slider's `aria-describedby`, so the key steps are announced with the control\n   and are also just readable on the card. */\n.kelvin-ramp-help {\n  margin: 0.75rem 0 0;\n  font-size: 0.6875rem;\n  line-height: 1.45;\n  color: rgba(233, 241, 249, 0.38);\n}\n\n.kelvin-ramp-hint {\n  position: absolute;\n  right: 0.875rem;\n  bottom: 0.75rem;\n  margin: 0;\n  font: 500 0.6875rem/1 ui-monospace, 'SFMono-Regular', Menlo, monospace;\n  letter-spacing: 0.08em;\n  text-transform: uppercase;\n  color: rgba(233, 241, 249, 0.26);\n  pointer-events: none;\n}\n\n/*\n * On a phone the lead and the slab body both take another line, so the stage is given the room\n * rather than the card being squeezed, and the presets go to two rows. The strip is rebuilt at\n * the new width — every column is re-solved from the spectrum, so the ramp is resolved for the\n * box it is in and never scaled into it.\n */\n@media (max-width: 26rem) {\n  .kelvin-ramp-stage {\n    min-height: 38rem;\n    padding: 1.75rem 1rem;\n  }\n\n  .kelvin-ramp-card {\n    padding: 1.125rem;\n  }\n\n  .kelvin-ramp-title {\n    font-size: 1.1875rem;\n  }\n\n  .kelvin-ramp-presets {\n    grid-template-columns: repeat(2, minmax(0, 1fr));\n  }\n\n  .kelvin-ramp-hint {\n    display: none;\n  }\n}\n\n/*\n * Reduced motion takes away the refinement, not the ramp. The loop never starts, so the first\n * painted frame runs every pass at once and the strip is fully resolved the moment it appears —\n * which is a real state of the solver, the same one the paced path arrives at half a second\n * later, and not a frozen frame of an animation. Dragging and the keys still work, because the\n * component asks for a repaint whenever the value changes. All that is left here is the\n * buttons' own colour fade.\n */\n@media (prefers-reduced-motion: reduce) {\n  .kelvin-ramp-preset {\n    transition: none;\n  }\n}\n\n/*\n * The card variant: the 298x240 catalogue frame, at that real size and never scaled. The strip\n * is rebuilt for whatever width it is given — every column is re-solved from the spectrum — so\n * a card ramp is the same locus at a coarser sampling, not a shrunk copy of the stage's.\n *\n * What goes is the prose. What stays is the pair of surfaces that carry the solver's answer: the\n * slab, whose background *is* the value, and the strip the value is picked from. The one line of\n * type left on the slab is load-bearing rather than decorative — near-black ink clears 4.5:1 at\n * every temperature, and a card with no words on the swatch could not show that.\n *\n * These win over the `max-width: 26rem` block above on specificity, not on order: a media query\n * adds none, so `[data-compact='true']` outranks the phone rules and a 390px catalogue does not\n * hand this card a 38rem stage.\n */\n.kelvin-ramp-stage[data-compact='true'] {\n  min-height: 0;\n  height: 100%;\n  /* `place-content: center` leaves the single track sized from the card's own content, so the\n     ramp would be as wide as the value row's type happens to set. Stretched, the track is the\n     card frame and the strip is solved across its full width. */\n  place-content: stretch;\n  padding: 0.8125rem;\n  /* The card frame rounds and clips already. */\n  border-radius: 0;\n}\n\n.kelvin-ramp-stage[data-compact='true'] .kelvin-ramp-card {\n  width: 100%;\n  padding: 0.75rem;\n  border-radius: 0.75rem;\n}\n\n.kelvin-ramp-stage[data-compact='true'] .kelvin-ramp-kicker,\n.kelvin-ramp-stage[data-compact='true'] .kelvin-ramp-title,\n.kelvin-ramp-stage[data-compact='true'] .kelvin-ramp-lead,\n.kelvin-ramp-stage[data-compact='true'] .kelvin-ramp-preview-kicker,\n.kelvin-ramp-stage[data-compact='true'] .kelvin-ramp-preview-body,\n.kelvin-ramp-stage[data-compact='true'] .kelvin-ramp-presets,\n.kelvin-ramp-stage[data-compact='true'] .kelvin-ramp-help,\n.kelvin-ramp-stage[data-compact='true'] .kelvin-ramp-hint {\n  display: none;\n}\n\n.kelvin-ramp-stage[data-compact='true'] .kelvin-ramp-preview {\n  min-height: 4rem;\n  margin: 0;\n  padding: 0.625rem 0.75rem 0.6875rem;\n}\n\n.kelvin-ramp-stage[data-compact='true'] .kelvin-ramp-preview-title {\n  margin: 0;\n  font-size: 0.9375rem;\n}\n\n.kelvin-ramp-stage[data-compact='true'] .kelvin-ramp-ramp {\n  height: 2.25rem;\n  margin: 0.625rem 0 0;\n}\n\n.kelvin-ramp-stage[data-compact='true'] .kelvin-ramp-row {\n  margin: 0.5625rem 0 0;\n}\n\n.kelvin-ramp-stage[data-compact='true'] .kelvin-ramp-value {\n  font-size: 1.375rem;\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":["gradients","micro"],"docs":"https://ui.artbloom.tech/artbloom/animations/kelvin-ramp"}}