{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"snap-toggle","type":"registry:ui","title":"Snap Toggle","description":"A settings toggle whose off and on are two real resting positions of a buckled arch. Drag the knob to the middle and let go: it hangs for a third of a second and then commits on its own, and which way it goes is settled by a fraction of a pixel rather than by a threshold.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/snap-toggle.tsx","target":"components/ui/snap-toggle.tsx","content":"'use client';\n\nimport './snap-toggle.css';\n\nimport { useCallback, useEffect, useId, useRef, useState } from 'react';\n\nimport {\n  useCanvasScene,\n  useReducedMotion,\n  type SceneDrawContext,\n  type SceneSetupContext,\n} from '@/hooks/use-canvas-scene';\n\n/**\n * A toggle switch whose two states are the two wells of a von Mises truss.\n *\n * Two pin-jointed bars of unstretched length L₀ meet at the knob, and the knob is\n * pinned to a slide running between supports 2b apart. Because L₀ > b the pair can\n * never lie straight — it is held buckled — so the strain energy\n *\n *   U(y) = k·(√(b² + y²) − L₀)²\n *\n * is zero at each of y = ±√(L₀² − b²), where the bars are the length they want to\n * be, and k·(L₀ − b)² in the middle, where both are squeezed. Off and on are those\n * two minima. What runs is m ÿ = −dU/dy − c ẏ + F_finger, semi-implicit Euler at a\n * fixed 1/240 s, and the barrier between the wells is the only reason this control\n * has two states instead of a number between them.\n *\n * It is not an eased transition, and it is not a spring with a stiffness bump. A\n * spring has one equilibrium: let it go anywhere and it returns to the same place.\n * A keyframe has as many equilibria as its state variable has values, and it is\n * that variable — set the instant you let go — which decides where the pixels are\n * heading. Nothing here decides. No line in this file tests which way the knob\n * should go: it goes downhill, and which hill it is on is a matter of a fraction of\n * a pixel either side of the crest.\n *\n * That is watchable, and it is the whole claim. Let the knob go a hair past the\n * crest and it hangs for a third of a second before it moves, because the crest is\n * an equilibrium and the force at an equilibrium is zero; let it go six pixels\n * further along and the same trip takes 130 ms. Both arrive at the far side at\n * 240 px/s, within a percent of each other, because the arrival speed is √(2ΔU/m)\n * — read off the barrier, and not off a duration anybody chose. So the throw is the\n * release rather than a transition: a drag that stops short of the crest is pulled\n * back by the strut instead of animated back, a drag that stops on the crest stays\n * there balanced for as long as you hold it, and a press spends an impulse, so\n * Space has to clear the same barrier your finger does. Nothing eases anywhere.\n */\n\n/** Seconds a sub-step advances. The barrier is stiff: at 1/120 the snap changes shape. */\nconst STEP = 1 / 240;\n/** Half the distance between the two supports, in mechanism units. */\nconst SPAN = 48;\n/** Unstretched bar length. Longer than the half-span is the whole trick: it is why\n *  the pair cannot lie flat, and so why there are two states and not a slider. */\nconst BAR = 60;\n/** The wells, ±√(BAR² − SPAN²). 36-48-60 is a 3-4-5 triangle, so this is exactly 36. */\nconst WELL = Math.sqrt(BAR * BAR - SPAN * SPAN);\n/** The housing wall. A fifth past the well: room for the ring, none for a hurl. */\nconst LIMIT = 44;\n/** Axial stiffness of the pair. Puts the well at 4.7 Hz — slow enough that the\n *  crossing is something you watch rather than something that happened. */\nconst K = 1200;\n/** Viscous damping. ζ = 0.28 at the well, so the knob passes its detent by six units\n *  and is still inside 600 ms; twice this and it crawls off the crest instead of\n *  snapping off it. */\nconst C = 16.5;\n/** Mass at the apex. One, so K and C read per unit mass and the loop below can be\n *  the equation the comment above names, with nothing scaled out of sight. */\nconst MASS = 1;\n/** The impulse a press spends, in units per second. Clearing the barrier from rest\n *  takes 1011, so this is 29% over: enough to beat a ring already heading the other\n *  way, and short of banging the housing. */\nconst KICK = 1300;\n/** Stiffness of the hand's hold on the knob. The strut's peak resistance up the\n *  barrier is 7400, so the knob trails the pointer by 2.5 units on the climb and\n *  catches up on the way down — the load is visible in the lag. */\nconst GRAB = 3000;\n/** Damping in that hold: a fingertip is a pad, not a hook. Without it, taking hold\n *  of a moving knob rings it about the pointer at 8 Hz. */\nconst GRAB_DAMP = 55;\n/** How near the knob a press has to land to take hold of it, in pixels. */\nconst REACH = 26;\n/** Pointer travel that still counts as a press rather than a drag, in pixels. */\nconst TAP = 5;\n/** Units off centre before a crossing is published. Nearer than this the sign of y\n *  is noise, and `aria-checked` would chatter every time the strut sat on the crest. */\nconst REPORT = 6;\n/** Gap between knob and track wall, in pixels. Sets the knob radius and with it how\n *  many pixels one mechanism unit is worth. */\nconst RAIL = 4;\n/** The switch's CSS box, mirrored here only as the first frame's fallback: the real\n *  one is measured as soon as the layout effect has run. */\nconst BOX = { w: 104, h: 40 };\n/** The two rows under the live one. Furniture — the argument is that this is a\n *  settings row and not an exhibit, and one row alone does not make a settings list. */\nconst ROWS = [\n  { id: 'reports', name: 'Crash reports', note: 'Set by your organisation.', on: true },\n  { id: 'beta', name: 'Beta channel', note: 'Locked to the stable channel.', on: false },\n];\n/** 0…1. A tint outside that is a paint bug, not a state. */\nconst clamp01 = (value: number) => Math.min(1, Math.max(0, value));\n\ninterface Box {\n  x: number;\n  y: number;\n  w: number;\n  h: number;\n}\n\n/**\n * What React writes and the scene reads. The scene state is built inside the hook,\n * so a click handler has no way to reach it; this ref is the whole channel between\n * the two, and it carries exactly two things.\n */\ninterface Bridge {\n  /** The switch's box, in canvas pixels. Measured, never assumed. */\n  track: Box;\n  /** An impulse waiting to be spent. Set by a press, cleared by the next frame. */\n  press: number;\n}\n\ninterface TrussState {\n  readonly bridge: { readonly current: Bridge };\n  /** Apex position along the slide, in mechanism units. With v, the entire state. */\n  y: number;\n  v: number;\n  /** Which well the component has been told about, −1 or 1. */\n  side: number;\n  /** Set when y crosses the crest. The component reads it and clears it. */\n  flipped: boolean;\n  held: boolean;\n  /** Where the hand is, in mechanism units. Only read while held. */\n  hold: number;\n  /** Pointer travel since the press, in pixels, so a tap can be told from a drag. */\n  travel: number;\n  carry: number;\n  clock: number;\n  /** The switch's React value, for the reduced-motion seat. */\n  on: boolean;\n  /** Seat the strut in its well and skip the solver. Set under reduced motion, where\n   *  the loop never starts and a strut integrating one step per repaint would hang\n   *  somewhere between the two states for as long as the page stayed still. */\n  snap: boolean;\n}\n\n/** −dU/dy: the axial force in the two bars, resolved along the slide. */\nfunction force(y: number) {\n  const length = Math.hypot(SPAN, y);\n  return (-2 * K * (length - BAR) * y) / length;\n}\n\n/** The strut at rest in the well `on` names — the answer, not a step toward it. */\nfunction seat(state: TrussState, on: boolean) {\n  state.y = on ? WELL : -WELL;\n  state.v = 0;\n  state.side = on ? 1 : -1;\n  state.held = false;\n}\n\nfunction advance(state: TrussState) {\n  let load = force(state.y);\n  let damp = C;\n  if (state.held) {\n    load += GRAB * (state.hold - state.y);\n    damp += GRAB_DAMP;\n  }\n  /*\n   * Semi-implicit: the force is read at the old position, but the damping is solved\n   * for the new velocity instead of added to it. Explicit damping at GRAB_DAMP needs\n   * a step under 2/55 s to stay bounded and buzzes long before that, so this form is\n   * what lets the hand hold the knob as hard as it likes.\n   */\n  state.v = (state.v + (load / MASS) * STEP) / (1 + (damp / MASS) * STEP);\n  state.y += state.v * STEP;\n\n  // The pill is moulded plastic: a wall, not a bumper. Only a hurled flick gets\n  // here, and what a wall does is stop it.\n  if (Math.abs(state.y) > LIMIT) {\n    state.y = state.y > 0 ? LIMIT : -LIMIT;\n    state.v = 0;\n  }\n\n  // The state is read off the sign of y, so a drag, a press and the keyboard all\n  // publish through the same line: none of them can set it without moving the strut.\n  const side = state.y > 0 ? 1 : -1;\n  if (side !== state.side && Math.abs(state.y) > REPORT) {\n    state.side = side;\n    state.flipped = true;\n  }\n}\n\nfunction build(\n  { width, height }: SceneSetupContext,\n  bridge: { current: Bridge },\n  on: boolean,\n): TrussState {\n  // The first frame is painted before the layout effect has measured anything, so\n  // the fallback box lives here: a resize re-runs setup and keeps the real one.\n  if (bridge.current.track.w === 0) {\n    bridge.current.track = {\n      x: (width - BOX.w) / 2,\n      y: height * 0.22,\n      w: BOX.w,\n      h: BOX.h,\n    };\n  }\n  const state: TrussState = {\n    bridge,\n    y: 0,\n    v: 0,\n    side: 1,\n    flipped: false,\n    held: false,\n    hold: 0,\n    travel: 0,\n    carry: 0,\n    clock: 0,\n    on,\n    snap: false,\n  };\n  // Starting in a well is the honest initial condition: the switch was already in\n  // this state before the component mounted, and a strut at rest sits at a minimum.\n  seat(state, on);\n  return state;\n}\n\ninterface Frame {\n  cx: number;\n  cy: number;\n  knobR: number;\n  /** Pixels per mechanism unit. */\n  scale: number;\n}\n\n/**\n * The drawing frame, derived from the box the layout gave the switch. The knob has to\n * reach the housing wall exactly at the inside of the pill, so the scale follows the\n * measured box and not the other way round — the mechanism has no opinion about rem.\n */\nfunction frameOf(track: Box): Frame {\n  const half = track.w / 2;\n  const knobR = Math.max(4, track.h / 2 - RAIL);\n  return {\n    cx: track.x + half,\n    cy: track.y + track.h / 2,\n    knobR,\n    scale: (half - RAIL - knobR) / LIMIT,\n  };\n}\n\n/** A pill path by hand, because `context.roundRect` moves between DOM library\n *  versions and this file is meant to compile in whichever one the consumer has. */\nfunction pill(context: CanvasRenderingContext2D, box: Box) {\n  const r = box.h / 2;\n  context.beginPath();\n  context.moveTo(box.x + r, box.y);\n  context.lineTo(box.x + box.w - r, box.y);\n  context.arc(box.x + box.w - r, box.y + r, r, -Math.PI / 2, Math.PI / 2);\n  context.lineTo(box.x + r, box.y + box.h);\n  context.arc(box.x + r, box.y + r, r, Math.PI / 2, (Math.PI * 3) / 2);\n  context.closePath();\n}\n\nfunction drawSwitch(\n  context: CanvasRenderingContext2D,\n  state: TrussState,\n  track: Box,\n  geom: Frame,\n) {\n  const { cx, cy, knobR, scale } = geom;\n  const apex = cx + state.y * scale;\n  // 0 off, 1 on, continuous — the tint follows the knob's position and nothing else, so\n  // the track is half lit when the knob is halfway across, and waits there with it.\n  const lit = clamp01(state.y / WELL / 2 + 0.5);\n\n  pill(context, track);\n  context.fillStyle = 'rgba(6,10,18,0.72)';\n  context.fill();\n  context.fillStyle = `rgba(143,198,255,${0.03 + lit * 0.13})`;\n  context.fill();\n  context.lineWidth = 1;\n  context.strokeStyle = 'rgba(233,241,251,0.1)';\n  context.stroke();\n  context.strokeStyle = `rgba(143,198,255,${lit * 0.4})`;\n  context.stroke();\n\n  // The knob, shaded down its own height so it reads as a cap sitting in the track.\n  const glass = context.createLinearGradient(0, cy - knobR, 0, cy + knobR);\n  glass.addColorStop(0, 'rgba(233,241,251,0.26)');\n  glass.addColorStop(1, 'rgba(233,241,251,0.08)');\n  context.beginPath();\n  context.arc(apex, cy, knobR, 0, Math.PI * 2);\n  context.fillStyle = glass;\n  context.fill();\n  context.lineWidth = 1.6;\n  context.strokeStyle = `rgba(233,241,251,${0.46 + lit * 0.24})`;\n  context.stroke();\n}\n\nfunction paint({ context, width, height, state, pointer }: SceneDrawContext<TrussState>) {\n  const now = performance.now();\n  // Clamped at 50 ms: a backgrounded tab comes back owing seconds, and the strut owes\n  // nothing at all for the time nobody was watching it.\n  const elapsed = state.clock ? Math.min(0.05, (now - state.clock) / 1000) : STEP;\n  state.clock = now;\n\n  const bridge = state.bridge.current;\n  const geom = frameOf(bridge.track);\n\n  if (state.snap) {\n    seat(state, state.on);\n  } else {\n    /*\n     * Take hold on a press that lands on the knob; let go when the press ends. A press\n     * that ends without travelling is a tap, and a tap spends an impulse — which is how\n     * a click still works on a control whose pointer events belong to the drag.\n     */\n    if (!pointer.down) {\n      if (state.held) {\n        state.held = false;\n        if (state.travel < TAP) bridge.press = 1;\n      }\n    } else if (state.held) {\n      state.travel +=\n        Math.abs(pointer.x - pointer.lastX) + Math.abs(pointer.y - pointer.lastY);\n    } else if (\n      geom.scale > 0 &&\n      pointer.inside &&\n      Math.hypot(pointer.x - (geom.cx + state.y * geom.scale), pointer.y - geom.cy) < REACH\n    ) {\n      state.held = true;\n      state.travel = 0;\n    }\n    if (state.held && geom.scale > 0) state.hold = (pointer.x - geom.cx) / geom.scale;\n\n    if (bridge.press) {\n      bridge.press = 0;\n      // Away from the well it is in, and that is all a press does. Whether it arrives\n      // is a matter between the impulse and the barrier.\n      state.v += state.y >= 0 ? -KICK : KICK;\n    }\n\n    state.carry += elapsed;\n    let steps = 0;\n    // 240 Hz is four sub-steps a frame at 60 fps, so the cap has to clear eight or a\n    // display running at 30 would integrate the strut at half speed.\n    while (state.carry >= STEP && steps < 16) {\n      advance(state);\n      state.carry -= STEP;\n      steps += 1;\n    }\n    // Hitting the cap means the frame was late. The arrears are dropped rather than\n    // paid back, because paying them back is how a stiff solver detonates.\n    if (state.carry > STEP * 16) state.carry = 0;\n  }\n\n  context.clearRect(0, 0, width, height);\n  if (bridge.track.w > 0) drawSwitch(context, state, bridge.track, geom);\n}\n\n/** `compact` is the 298x240 catalogue card: the same truss and the same live row, with\n *  the eyebrow, the two locked rows and the hint dropped. Presentation only — the\n *  geometry is measured off the switch's own box either way, so the mechanism is\n *  unchanged. See `snap-toggle.css`. */\nexport type SnapToggleProps = { compact?: boolean };\n\nexport function SnapToggle({ compact = false }: SnapToggleProps) {\n  const [on, setOn] = useState(false);\n  const reduced = useReducedMotion();\n  // Scoped ids, so two of these on one page do not both claim the same label.\n  const uid = useId();\n  const card = useRef<HTMLDivElement | null>(null);\n  const knob = useRef<HTMLButtonElement | null>(null);\n  const bridge = useRef<Bridge>({\n    track: { x: 0, y: 0, w: 0, h: 0 },\n    press: 0,\n  });\n\n  const { stageRef, canvasRef, requestRender } = useCanvasScene<TrussState>({\n    setup: (scene) => build(scene, bridge, on),\n    draw: (scene) => {\n      scene.state.on = on;\n      scene.state.snap = reduced;\n      paint(scene);\n      // The crossing is published by the solver, so `aria-checked` follows the strut\n      // and never leads it. Nothing else in this component writes the value.\n      if (scene.state.flipped) {\n        scene.state.flipped = false;\n        setOn(scene.state.side > 0);\n      }\n    },\n  });\n\n  // Under reduced motion the loop never starts, so without this the knob would still\n  // be sitting in the well it was told to leave.\n  useEffect(() => {\n    requestRender();\n  }, [on, requestRender]);\n\n  /*\n   * The canvas draws inside the box the layout hands it, so the pill lands under its\n   * own focus ring at any font size or zoom. Measured against the card because the\n   * canvas is `inset: 0` inside it, and the card carries no border for the same reason.\n   */\n  const measure = useCallback(() => {\n    const host = card.current;\n    const box = knob.current;\n    if (!host || !box) return;\n    const origin = host.getBoundingClientRect();\n    const rect = box.getBoundingClientRect();\n    bridge.current.track = {\n      x: rect.left - origin.left,\n      y: rect.top - origin.top,\n      w: rect.width,\n      h: rect.height,\n    };\n    requestRender();\n  }, [requestRender]);\n\n  useEffect(() => {\n    const host = card.current;\n    const box = knob.current;\n    if (!host || !box) return;\n    measure();\n    // The rows reflow before the card does at a narrow width, so the switch is watched\n    // as well: its box is the one the mechanism is scaled from.\n    const sizes = new ResizeObserver(measure);\n    sizes.observe(host);\n    sizes.observe(box);\n    return () => sizes.disconnect();\n  }, [measure]);\n\n  /*\n   * A press is an impulse, not an assignment. It goes into the bridge and the solver\n   * spends it against the same barrier a finger has to climb, so Space cannot put the\n   * switch anywhere the strut has not been. Under reduced motion there is no solver\n   * running to spend it, so the value moves and the strut is re-seated on the answer.\n   */\n  const press = useCallback(() => {\n    if (reduced) {\n      setOn((was) => !was);\n      return;\n    }\n    bridge.current.press = 1;\n  }, [reduced]);\n\n  return (\n    <div className=\"snap-toggle-stage\" data-compact={compact ? 'true' : undefined}>\n      <div className=\"snap-toggle-card\" ref={card}>\n        <div className=\"snap-toggle-mech\" ref={stageRef} aria-hidden=\"true\">\n          <canvas ref={canvasRef} />\n        </div>\n        <div className=\"snap-toggle-face\">\n          <p className=\"snap-toggle-eyebrow\">Editor preferences</p>\n          <div className=\"snap-toggle-row snap-toggle-live\">\n            <span className=\"snap-toggle-text\">\n              <span className=\"snap-toggle-name\" id={`${uid}-autosave`}>\n                Autosave\n              </span>\n              <span className=\"snap-toggle-note\">\n                {on ? 'Written to disk as you type.' : 'Changes stay in memory.'}\n              </span>\n            </span>\n            <button\n              type=\"button\"\n              role=\"switch\"\n              className=\"snap-toggle-switch\"\n              ref={knob}\n              aria-checked={on}\n              aria-labelledby={`${uid}-autosave`}\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={press}\n            />\n          </div>\n          {ROWS.map((row) => (\n            <div className=\"snap-toggle-row\" key={row.id}>\n              <span className=\"snap-toggle-text\">\n                <span className=\"snap-toggle-name\" id={`${uid}-${row.id}`}>\n                  {row.name}\n                </span>\n                <span className=\"snap-toggle-note\">{row.note}</span>\n              </span>\n              <button\n                type=\"button\"\n                role=\"switch\"\n                className=\"snap-toggle-pill\"\n                aria-checked={row.on}\n                aria-labelledby={`${uid}-${row.id}`}\n                data-on={row.on}\n                disabled\n              />\n            </div>\n          ))}\n        </div>\n      </div>\n      <p className=\"snap-toggle-hint\">Drag the knob, or press it</p>\n    </div>\n  );\n}\n\nexport default SnapToggle;\n","type":"registry:ui"},{"path":"components/ui/snap-toggle.css","target":"components/ui/snap-toggle.css","content":"/*\n * No border on the card, deliberately: the canvas is `inset: 0`, which positions it\n * against the padding box, so a border would offset every drawn pixel from the box the\n * switch was measured in — the pill would sit a pixel out of its own focus ring. The\n * hairline is an inset shadow instead, which takes no space.\n */\n.snap-toggle-stage {\n  position: relative;\n  display: grid;\n  place-items: center;\n  width: 100%;\n  min-height: 20rem;\n  padding: 2.5rem 1.5rem;\n  overflow: hidden;\n  border-radius: 0.75rem;\n  background: radial-gradient(120% 110% at 50% 0%, #0d1421 0%, #080b12 58%, #05070c 100%);\n  color: #e9f1fb;\n}\n\n.snap-toggle-card {\n  position: relative;\n  width: min(23rem, 100%);\n  border-radius: 1rem;\n  background: linear-gradient(\n    180deg,\n    rgba(255, 255, 255, 0.045),\n    rgba(255, 255, 255, 0.012)\n  );\n  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.075);\n}\n\n/*\n * The mechanism, behind the card's copy and owning the pointer, because the knob is\n * dragged here rather than on the button that names it. The hook takes pointer capture,\n * so a drag survives leaving the card — which is what lets a finger hold the knob\n * balanced between the two states without having to keep aiming at it.\n */\n.snap-toggle-mech {\n  position: absolute;\n  inset: 0;\n  cursor: grab;\n  touch-action: none;\n}\n\n.snap-toggle-mech:active {\n  cursor: grabbing;\n}\n\n.snap-toggle-mech canvas {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n\n/*\n * Transparent to the pointer all the way down, the switch included: a press on the knob\n * belongs to the drag. The switch stays in the tab order and still takes Space and\n * Enter, which is how the keyboard reaches the mechanism.\n */\n.snap-toggle-face {\n  position: relative;\n  padding: 1.125rem 1.25rem 1rem;\n  pointer-events: none;\n}\n\n.snap-toggle-eyebrow {\n  margin: 0 0 0.875rem;\n  font: 500 0.6875rem/1 ui-monospace, \"SFMono-Regular\", Menlo, monospace;\n  letter-spacing: 0.16em;\n  text-transform: uppercase;\n  color: rgba(143, 198, 255, 0.72);\n}\n\n.snap-toggle-row {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 1rem;\n  min-height: 3.5rem;\n  border-top: 1px solid rgba(255, 255, 255, 0.055);\n}\n\n/* The list's first row: the eyebrow above it is the divider, so it carries no rule. */\n.snap-toggle-live {\n  border-top: 0;\n}\n\n.snap-toggle-text {\n  display: flex;\n  flex-direction: column;\n  gap: 0.25rem;\n  min-width: 0;\n}\n\n.snap-toggle-name {\n  font-size: 0.875rem;\n  font-weight: 500;\n  letter-spacing: -0.01em;\n  color: rgba(233, 241, 251, 0.92);\n}\n\n.snap-toggle-note {\n  font-size: 0.75rem;\n  line-height: 1.45;\n  color: rgba(233, 241, 251, 0.44);\n}\n\n/*\n * The live switch is an empty box. Everything inside it is drawn on the canvas, and the\n * box exists so that the layout decides where the control is and how big — 6.5rem by\n * 2.5rem, mirrored in BOX in the .tsx as the first frame's fallback and measured for\n * real after that.\n */\n.snap-toggle-switch {\n  flex: none;\n  width: 6.5rem;\n  height: 2.5rem;\n  margin: 0;\n  padding: 0;\n  border: 0;\n  border-radius: 999px;\n  background: none;\n  appearance: none;\n}\n\n.snap-toggle-switch:focus-visible {\n  outline: 2px solid rgba(143, 198, 255, 0.85);\n  outline-offset: 3px;\n}\n\n/*\n * The two rows below are furniture, so their switches are plain CSS: same box, same pill,\n * knob parked at 26px — which is where the solver's own detent, 36 mechanism units, lands\n * once the measured box has been scaled. Nothing here is solved and nothing here is meant\n * to be. The claim of the row above is that it sits in a list of ordinary ones.\n */\n.snap-toggle-pill {\n  position: relative;\n  flex: none;\n  width: 6.5rem;\n  height: 2.5rem;\n  margin: 0;\n  padding: 0;\n  border: 0;\n  border-radius: 999px;\n  background: rgba(6, 10, 18, 0.66);\n  box-shadow: inset 0 0 0 1px rgba(233, 241, 251, 0.1);\n  opacity: 0.5;\n  appearance: none;\n}\n\n.snap-toggle-pill::after {\n  content: \"\";\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  width: 2rem;\n  height: 2rem;\n  margin: -1rem 0 0 -1rem;\n  border-radius: 999px;\n  background: rgba(233, 241, 251, 0.14);\n  box-shadow: inset 0 0 0 1.6px rgba(233, 241, 251, 0.5);\n  transform: translateX(-26px);\n}\n\n.snap-toggle-pill[data-on=\"true\"] {\n  background: rgba(143, 198, 255, 0.13);\n}\n\n.snap-toggle-pill[data-on=\"true\"]::after {\n  transform: translateX(26px);\n}\n\n.snap-toggle-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, 251, 0.26);\n  pointer-events: none;\n}\n\n/*\n * Nothing to switch off. There is no transition and no keyframe anywhere in this file,\n * because the doc comment claims nothing eases and a stylesheet is a poor place to be\n * caught easing. With the loop stopped the strut is seated in its well by `paint` instead\n * of integrated toward it, and a press moves the value and re-seats it: the switch still\n * switches, it simply arrives. Dragging goes with the loop, because a knob that follows\n * the pointer is motion arriving through another door.\n */\n@media (prefers-reduced-motion: reduce) {\n  .snap-toggle-mech {\n    cursor: default;\n  }\n}\n\n/*\n * The card variant: the 298x240 catalogue frame, at that real size and never scaled.\n * The truss needs nothing done to it — it is scaled from the switch's measured box, so\n * it lands under its own focus ring at whatever size the row ends up. All this does is\n * get the settings list out of the way and leave the one live row in it.\n */\n.snap-toggle-stage[data-compact='true'] {\n  min-height: 0;\n  height: 100%;\n  /* `place-items: center` leaves the column auto-sized, so the card's `width: 100%` below\n     would resolve against its own max-content instead of the frame. Stretched horizontally\n     it resolves against the frame; the row stays centred, which is where one live switch\n     wants to be in a 240px card. */\n  justify-items: stretch;\n  padding: 0.75rem;\n  /* The card frame rounds and clips already. */\n  border-radius: 0;\n}\n\n.snap-toggle-stage[data-compact='true'] .snap-toggle-card {\n  width: 100%;\n}\n\n/* A full-bleed drag surface that claims every touch traps the page inside a scrolling\n   grid. `pan-y` hands the vertical gesture back to the document; a horizontal drag is\n   the one this mechanism is about, and it still arrives. */\n.snap-toggle-stage[data-compact='true'] .snap-toggle-mech {\n  touch-action: pan-y;\n}\n\n.snap-toggle-stage[data-compact='true'] .snap-toggle-face {\n  padding: 0.75rem 0.875rem;\n}\n\n/*\n * The eyebrow and the hint are the card's own title's job, and the two locked rows are\n * furniture — they are there in the full stage to argue that this is a settings list and\n * not an exhibit, which a card three lines tall cannot claim either way.\n */\n.snap-toggle-stage[data-compact='true'] .snap-toggle-eyebrow,\n.snap-toggle-stage[data-compact='true'] .snap-toggle-hint,\n.snap-toggle-stage[data-compact='true'] .snap-toggle-row:not(.snap-toggle-live) {\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":["draggable","springs","micro"],"docs":"https://ui.artbloom.tech/artbloom/animations/snap-toggle"}}