{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scatter-rewind",
  "title": "Scatter rewind",
  "description": "Scatter rewind is a production-ready text component from BJORK UI, packaged for shadcn registries.",
  "dependencies": [
    "clsx",
    "framer-motion",
    "next-themes",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/bjork-ui/text/scatter-rewind.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState, useSyncExternalStore, type RefObject } from \"react\";\nimport { useReducedMotion } from \"framer-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { BJORK_PALETTE, type BjorkTone } from \"@/components/bjork-ui/_core/palette\";\nimport { useBjorkTone } from \"@/components/bjork-ui/_core/tone\";\nimport { useVisibleLoop, isCoarsePointer } from \"@/components/bjork-ui/_core/loop\";\nimport { defaultMaxDpr, sizeCanvas } from \"@/components/bjork-ui/_core/canvas\";\nimport { hashString, mulberry32 } from \"@/components/bjork-ui/_core/random\";\n\nexport type ScatterRewindPhase = \"intact\" | \"dissolving\" | \"scattered\" | \"rewinding\";\nexport type ScatterRewindTrigger = \"inView\" | \"hover\" | \"click\" | \"manual\";\n\nexport interface ScatterRewindProps {\n  text: string;\n  as?: \"h1\" | \"h2\" | \"h3\" | \"p\";\n  trigger?: ScatterRewindTrigger;\n  /** Manual mode only. 0 is intact, 1 is fully scattered. */\n  progress?: number;\n  /** Dissolve time in ms. */\n  duration?: number;\n  /** Rewind time in ms. */\n  rewindDuration?: number;\n  /** Hold time in ms at the scattered frame before the rewind starts (inView only). */\n  holdMs?: number;\n  /** Repeat the inView cycle. Forced on by `attract`. */\n  loop?: boolean;\n  /** Wind direction in degrees. Negative is up and to the right. */\n  windAngle?: number;\n  /** How far, in px, the dust may travel along the wind. */\n  spill?: number;\n  accentRatio?: number;\n  /** Particle cap. Defaults to 1400, or 700 on coarse pointers. */\n  maxParticles?: number;\n  seed?: string;\n  onPhaseChange?: (phase: ScatterRewindPhase) => void;\n  tone?: BjorkTone;\n  /** Plays the inView cycle on loop. Pauses on pointer or key input for 4s. Static under reduced motion. */\n  attract?: boolean;\n  className?: string;\n  /**\n   * Test only. Shows the particle canvas at T = 0 (the heading is hidden) so its\n   * ink overlap with the heading can be measured.\n   */\n  debugShowCanvasAt0?: boolean;\n}\n\n// Sampling grid in CSS px. The plan's `step = 3 x DPR` in device px is 3 CSS px.\nconst STEP_CSS = 3;\n// Canvas margin beyond the spill, on every side, for the wobble and the drift spread.\nconst CANVAS_PAD = 40;\nconst FLIGHT = 0.25;\nconst PERP_WOBBLE = 8;\nconst DUST_ALPHA_FLOOR = 0.2;\nconst FIRST_DELAY = 1.2;\nconst LOOP_DELAY = 2;\nconst RESUME_DELAY = 1;\nconst REDUCED_FADE = 0.3;\nconst REDUCED_OPACITY = 0.15;\nconst ATTRACT_IDLE_MS = 4000;\n// Release time r = RELEASE_SPAN * order + RELEASE_JITTER * rand (plan 3.18).\nconst RELEASE_SPAN = 0.6;\nconst RELEASE_JITTER = 0.15;\n\nconst subscribeNoop = () => () => {};\nconst getMounted = () => true;\nconst getServerMounted = () => false;\n\ninterface Particles {\n  n: number;\n  hx: Float32Array;\n  hy: Float32Array;\n  dx: Float32Array;\n  dy: Float32Array;\n  rel: Float32Array;\n  ph: Float32Array;\n  acc: Uint8Array;\n  ord: Float32Array;\n}\n\ninterface Scene {\n  particles: Particles;\n  left: number;\n  top: number;\n  width: number;\n  height: number;\n  wx: number;\n  wy: number;\n  /** Release-order mapping, in content-box px: order = (x*wx + y*wy - minP) / span. */\n  minP: number;\n  span: number;\n  /** Content-box origin inside the heading's border box, and the border-box size (the mask's space). */\n  offX: number;\n  offY: number;\n  boxW: number;\n  boxH: number;\n  /** Heading ink colour, read once per build instead of every frame. */\n  ink: string;\n}\n\ninterface Sim {\n  /** Global time, 0 intact to 1 scattered. */\n  t: number;\n  target: number;\n  dir: number;\n  timer: number | null;\n  next: \"dissolve\" | \"rewind\" | null;\n  started: boolean;\n  paused: boolean;\n}\n\ninterface Live {\n  root: HTMLDivElement | null;\n  heading: HTMLElement | null;\n  canvas: HTMLCanvasElement | null;\n  scene: Scene | null;\n  dpr: number;\n  phase: ScatterRewindPhase;\n}\n\ninterface Snapshot {\n  text: string;\n  reduce: boolean;\n  attract: boolean;\n  trigger: ScatterRewindTrigger;\n  loop: boolean;\n  duration: number;\n  rewindDuration: number;\n  holdMs: number;\n  tone: BjorkTone;\n  debug: boolean;\n  onPhaseChange?: (phase: ScatterRewindPhase) => void;\n}\n\nfunction clamp01(n: number) {\n  return n < 0 ? 0 : n > 1 ? 1 : n;\n}\n\nfunction newSim(): Sim {\n  return { t: 0, target: 0, dir: 1, timer: null, next: null, started: false, paused: false };\n}\n\n// Advances the global time T and the automatic inView timeline. Returns true while the loop should keep running.\nfunction stepSim(dt: number, p: Snapshot, sim: Sim): boolean {\n  if (p.trigger === \"manual\") return false;\n  const auto = p.attract || p.trigger === \"inView\";\n\n  if (p.attract && p.reduce) {\n    sim.t = 0;\n    sim.target = 0;\n    sim.timer = null;\n    sim.next = null;\n    return false;\n  }\n  if (auto && sim.paused) return false;\n\n  if (auto && !sim.started) {\n    sim.started = true;\n    sim.timer = FIRST_DELAY;\n    sim.next = \"dissolve\";\n  }\n  if (auto && sim.timer !== null) {\n    sim.timer -= dt;\n    if (sim.timer <= 0) {\n      sim.target = sim.next === \"rewind\" ? 0 : 1;\n      sim.timer = null;\n      sim.next = null;\n    }\n  }\n\n  const prev = sim.t;\n  const dur = p.reduce ? REDUCED_FADE : p.duration / 1000;\n  const rw = p.reduce ? REDUCED_FADE : p.rewindDuration / 1000;\n  const dir = sim.target > sim.t ? 1 : sim.target < sim.t ? -1 : 0;\n  if (dir !== 0) {\n    sim.dir = dir;\n    sim.t += dir * (dir > 0 ? dt / dur : dt / rw);\n    if ((dir > 0 && sim.t > sim.target) || (dir < 0 && sim.t < sim.target)) sim.t = sim.target;\n  }\n\n  if (auto) {\n    if (prev < 1 && sim.t >= 1) {\n      sim.next = \"rewind\";\n      sim.timer = p.holdMs / 1000;\n    }\n    if (prev > 0 && sim.t <= 0) {\n      if (p.loop || p.attract) {\n        sim.next = \"dissolve\";\n        sim.timer = LOOP_DELAY;\n      } else {\n        sim.next = null;\n        sim.timer = null;\n      }\n    }\n  }\n\n  return sim.timer !== null || sim.t !== sim.target;\n}\n\n// Writes the frame. Stateless for the particles, so any T can be drawn exactly.\nfunction paint(live: Live, sim: Sim, p: Snapshot) {\n  const { root, heading, canvas, scene } = live;\n  if (!root || !heading || !canvas) return;\n\n  const T = sim.t;\n  const phase: ScatterRewindPhase =\n    T <= 0 ? \"intact\" : T >= 1 ? \"scattered\" : sim.dir > 0 ? \"dissolving\" : \"rewinding\";\n  if (phase !== live.phase) {\n    live.phase = phase;\n    root.dataset.phase = phase;\n    p.onPhaseChange?.(phase);\n  } else if (root.dataset.phase !== phase) {\n    root.dataset.phase = phase;\n  }\n\n  if (p.reduce) {\n    heading.style.opacity = String(1 - (1 - REDUCED_OPACITY) * T);\n    heading.style.maskImage = \"\";\n    heading.style.webkitMaskImage = \"\";\n    canvas.style.display = \"none\";\n    return;\n  }\n\n  const showCanvas = !!scene && (T > 0 || p.debug);\n  canvas.style.display = showCanvas ? \"block\" : \"none\";\n  if (!showCanvas || !scene) {\n    heading.style.opacity = \"1\";\n    heading.style.maskImage = \"\";\n    heading.style.webkitMaskImage = \"\";\n    return;\n  }\n  // Release front: no particle with order above `edge` has left home yet, so the real glyphs stay visible there.\n  const edge = p.debug ? Infinity : T / RELEASE_SPAN;\n  if (p.debug) {\n    heading.style.opacity = \"0\";\n  } else {\n    heading.style.opacity = \"1\";\n    const { wx: gx, wy: gy, boxW, boxH } = scene;\n    const angle = (Math.atan2(gx, -gy) * 180) / Math.PI;\n    const len = Math.abs(boxW * gx) + Math.abs(boxH * gy);\n    const start = (boxW / 2) * gx + (boxH / 2) * gy - len / 2;\n    const front = scene.minP + edge * scene.span + scene.offX * gx + scene.offY * gy;\n    const pct = ((front - start) / len) * 100;\n    const mask = `linear-gradient(${angle.toFixed(2)}deg, transparent ${pct.toFixed(2)}%, #000 ${(pct + 1.5).toFixed(2)}%)`;\n    heading.style.maskImage = mask;\n    heading.style.webkitMaskImage = mask;\n  }\n\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) return;\n  const cssW = scene.width;\n  const cssH = scene.height;\n  const P = scene.particles;\n  const { wx, wy } = scene;\n  const px = -wy;\n  const py = wx;\n\n  const colText = scene.ink || BJORK_PALETTE[p.tone].text;\n  const colAccent = BJORK_PALETTE[p.tone].accent;\n\n  ctx.setTransform(live.dpr, 0, 0, live.dpr, 0, 0);\n  ctx.clearRect(0, 0, cssW, cssH);\n  // Two passes: one fillStyle switch for heading ink, one for accent dust.\n  for (let pass = 0; pass < 2; pass++) {\n    ctx.fillStyle = pass === 0 ? colText : colAccent;\n    for (let i = 0; i < P.n; i++) {\n      if (P.acc[i] !== pass) continue;\n      let u = (T - P.rel[i]) / FLIGHT;\n      u = u < 0 ? 0 : u > 1 ? 1 : u;\n      if (u === 0 && P.ord[i] > edge) continue; // still home, under the visible glyphs\n      const e = 1 - (1 - u) * (1 - u) * (1 - u);\n      const wob = Math.sin(u * Math.PI * 2 + P.ph[i]) * PERP_WOBBLE * u;\n      const x = P.hx[i] + (P.dx[i] - P.hx[i]) * e + px * wob;\n      const y = P.hy[i] + (P.dy[i] - P.hy[i]) * e + py * wob;\n      const alpha = 1 - (1 - DUST_ALPHA_FLOOR) * Math.pow(u, 1.5);\n      const s = 2 * (1 - 0.5 * u);\n      ctx.globalAlpha = alpha;\n      ctx.fillRect(x - s / 2, y - s / 2, s, s);\n    }\n  }\n  ctx.globalAlpha = 1;\n}\n\n// Canvas-side ink mask of the heading, laid out word by word so wrapped headings sample correctly.\ninterface Raster {\n  data: Uint8ClampedArray;\n  w: number;\n  h: number;\n  dpr: number;\n  cw: number;\n  ch: number;\n}\n\nasync function rasterize(heading: HTMLElement, cs: CSSStyleDeclaration, cw: number, ch: number): Promise<Raster | null> {\n  const font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;\n  try {\n    await document.fonts.load(font);\n  } catch {\n    // Use whatever face is loaded.\n  }\n  const dpr = Math.min(2, window.devicePixelRatio || 1);\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = Math.max(1, Math.round(cw * dpr));\n  canvas.height = Math.max(1, Math.round(ch * dpr));\n  const ctx = canvas.getContext(\"2d\", { willReadFrequently: true });\n  if (!ctx) return null;\n\n  ctx.scale(dpr, dpr);\n  ctx.font = font;\n  ctx.fillStyle = \"#000\";\n  ctx.textAlign = \"left\";\n  ctx.textBaseline = \"alphabetic\";\n  const tracking = cs.letterSpacing && cs.letterSpacing !== \"normal\" ? cs.letterSpacing : \"0px\";\n  (ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = tracking;\n\n  const elRect = heading.getBoundingClientRect();\n  const sx = heading.offsetWidth ? elRect.width / heading.offsetWidth : 1;\n  const sy = heading.offsetHeight ? elRect.height / heading.offsetHeight : 1;\n  const originX = elRect.left + (parseFloat(cs.borderLeftWidth) + parseFloat(cs.paddingLeft)) * sx;\n  const originY = elRect.top + (parseFloat(cs.borderTopWidth) + parseFloat(cs.paddingTop)) * sy;\n  const transform = cs.textTransform;\n\n  const walker = document.createTreeWalker(heading, NodeFilter.SHOW_TEXT);\n  let node = walker.nextNode();\n  let drawn = 0;\n  while (node) {\n    const data = node.nodeValue ?? \"\";\n    for (const m of data.matchAll(/\\S+/g)) {\n      const start = m.index ?? 0;\n      const range = document.createRange();\n      range.setStart(node, start);\n      range.setEnd(node, start + m[0].length);\n      const r = range.getBoundingClientRect();\n      if (r.width === 0 && r.height === 0) continue;\n      const word = transform === \"uppercase\" ? m[0].toUpperCase() : transform === \"lowercase\" ? m[0].toLowerCase() : m[0];\n      const x = (r.left - originX) / sx;\n      const top = (r.top - originY) / sy;\n      // The inline box is the font's content area, so its top plus the ascent is the baseline.\n      const ascent = ctx.measureText(word).fontBoundingBoxAscent ?? parseFloat(cs.fontSize) * 0.8;\n      ctx.fillText(word, x, top + ascent);\n      drawn++;\n    }\n    node = walker.nextNode();\n  }\n  if (!drawn) return null;\n\n  const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;\n  return { data, w: canvas.width, h: canvas.height, dpr, cw, ch };\n}\n\n// Grid points where the ink alpha is above 128, in CSS px of the content box.\nfunction scan(r: Raster, step: number): Float32Array {\n  const pts: number[] = [];\n  for (let y = 0; y < r.ch; y += step) {\n    for (let x = 0; x < r.cw; x += step) {\n      const px = Math.min(r.w - 1, Math.floor(x * r.dpr));\n      const py = Math.min(r.h - 1, Math.floor(y * r.dpr));\n      if (r.data[(py * r.w + px) * 4 + 3] > 128) pts.push(x, y);\n    }\n  }\n  return Float32Array.from(pts);\n}\n\ninterface BuildOptions {\n  text: string;\n  cap: number;\n  seed: string;\n  windAngle: number;\n  spill: number;\n  accentRatio: number;\n  cs: CSSStyleDeclaration;\n  heading: HTMLElement;\n}\n\nasync function buildScene(o: BuildOptions): Promise<Scene | null> {\n  const { heading, cs } = o;\n  const padL = parseFloat(cs.paddingLeft) || 0;\n  const padR = parseFloat(cs.paddingRight) || 0;\n  const padT = parseFloat(cs.paddingTop) || 0;\n  const padB = parseFloat(cs.paddingBottom) || 0;\n  const borderL = parseFloat(cs.borderLeftWidth) || 0;\n  const borderT = parseFloat(cs.borderTopWidth) || 0;\n\n  // Optical: the canvas is aligned to the text box, so subtract the padding.\n  const cw = Math.round(heading.clientWidth - padL - padR);\n  const ch = Math.round(heading.clientHeight - padT - padB);\n  if (cw <= 0 || ch <= 0) return null;\n\n  const raster = await rasterize(heading, cs, cw, ch);\n  if (!raster) return null;\n\n  // Sampling step grows by 1.5 until the ink count is within 2x the cap; the seeded subsample below trims the rest,\n  // so medium headlines keep a full cap of dust instead of a much sparser coarse grid.\n  let step = STEP_CSS;\n  let xy = scan(raster, step);\n  for (let i = 0; i < 8 && xy.length / 2 > o.cap * 2; i++) {\n    step *= 1.5;\n    xy = scan(raster, step);\n  }\n\n  const rng = mulberry32(hashString(o.seed));\n  let count = xy.length / 2;\n  if (count > o.cap) {\n    // Deterministic subsample: shuffle the indices with a seeded RNG, then keep the first `cap`.\n    const shuffleRng = mulberry32(hashString(o.seed) ^ 0x5bd1e995);\n    const idx = Array.from({ length: count }, (_, i) => i);\n    for (let i = count - 1; i > 0; i--) {\n      const j = Math.floor(shuffleRng() * (i + 1));\n      [idx[i], idx[j]] = [idx[j], idx[i]];\n    }\n    const picked = new Float32Array(o.cap * 2);\n    for (let k = 0; k < o.cap; k++) {\n      picked[k * 2] = xy[idx[k] * 2];\n      picked[k * 2 + 1] = xy[idx[k] * 2 + 1];\n    }\n    xy = picked;\n    count = o.cap;\n  }\n\n  const angle = (o.windAngle * Math.PI) / 180;\n  const wx = Math.cos(angle);\n  const wy = Math.sin(angle);\n  const px = -wy;\n  const py = wx;\n\n  // Canvas margin: spill along the wind on each side, plus the padding for the spread.\n  const mL = CANVAS_PAD + o.spill * Math.max(0, -wx);\n  const mR = CANVAS_PAD + o.spill * Math.max(0, wx);\n  const mT = CANVAS_PAD + o.spill * Math.max(0, -wy);\n  const mB = CANVAS_PAD + o.spill * Math.max(0, wy);\n\n  let minP = Infinity;\n  let maxP = -Infinity;\n  for (let i = 0; i < count; i++) {\n    const p = xy[i * 2] * wx + xy[i * 2 + 1] * wy;\n    if (p < minP) minP = p;\n    if (p > maxP) maxP = p;\n  }\n  const span = maxP - minP || 1;\n\n  const hx = new Float32Array(count);\n  const hy = new Float32Array(count);\n  const dx = new Float32Array(count);\n  const dy = new Float32Array(count);\n  const rel = new Float32Array(count);\n  const ph = new Float32Array(count);\n  const acc = new Uint8Array(count);\n  const ord = new Float32Array(count);\n\n  for (let i = 0; i < count; i++) {\n    const x = xy[i * 2];\n    const y = xy[i * 2 + 1];\n    const order = (x * wx + y * wy - minP) / span;\n    // Six draws per particle, always in this order, so the seed fixes every particle.\n    const rA = rng();\n    const rB = rng();\n    const rC = rng();\n    const rD = rng();\n    const rE = rng();\n    const rF = rng();\n    rel[i] = RELEASE_SPAN * order + RELEASE_JITTER * rA;\n    ord[i] = order;\n    const dist = 120 + 140 * rB;\n    const perpOff = (rC - 0.5) * 60;\n    const upOff = -20 * rD;\n    // up = (0, -1), so `up * upOff` adds -upOff to y.\n    hx[i] = x + mL;\n    hy[i] = y + mT;\n    dx[i] = hx[i] + wx * dist + px * perpOff;\n    dy[i] = hy[i] + wy * dist + py * perpOff - upOff;\n    ph[i] = rE * Math.PI * 2;\n    acc[i] = rF < o.accentRatio ? 1 : 0;\n  }\n\n  return {\n    particles: { n: count, hx, hy, dx, dy, rel, ph, acc, ord },\n    // Canvas origin sits at the content box origin, minus the margins.\n    left: heading.offsetLeft + borderL + padL - mL,\n    top: heading.offsetTop + borderT + padT - mT,\n    width: cw + mL + mR,\n    height: ch + mT + mB,\n    wx,\n    wy,\n    minP,\n    span,\n    offX: borderL + padL,\n    offY: borderT + padT,\n    boxW: heading.offsetWidth,\n    boxH: heading.offsetHeight,\n    ink: cs.color && cs.color !== \"rgba(0, 0, 0, 0)\" ? cs.color : \"\",\n  };\n}\n\nexport function ScatterRewind({\n  text,\n  as = \"h2\",\n  trigger = \"inView\",\n  progress,\n  duration = 1600,\n  rewindDuration = 1200,\n  holdMs = 900,\n  loop = true,\n  windAngle = -15,\n  spill = 280,\n  accentRatio = 0.12,\n  maxParticles,\n  seed = \"bjork\",\n  onPhaseChange,\n  tone,\n  attract = false,\n  className,\n  debugShowCanvasAt0 = false,\n}: ScatterRewindProps) {\n  const resolvedTone = useBjorkTone(tone);\n  const reducedRaw = useReducedMotion();\n  const mounted = useSyncExternalStore(subscribeNoop, getMounted, getServerMounted);\n  const reduce = mounted && reducedRaw === true;\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const headingRef = useRef<HTMLElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const simRef = useRef<Sim>(newSim());\n  const liveRef = useRef<Live>({ root: null, heading: null, canvas: null, scene: null, dpr: 1, phase: \"intact\" });\n  const propsRef = useRef<Snapshot | null>(null);\n  const lastTextRef = useRef<string | null>(null);\n  const resumeRef = useRef<number | null>(null);\n  const [sizeTick, setSizeTick] = useState(0);\n\n  // Keeps the latest props visible to the loop and the async build without re-running effects.\n  useEffect(() => {\n    propsRef.current = {\n      text,\n      reduce,\n      attract,\n      trigger: attract ? \"inView\" : trigger,\n      loop,\n      duration,\n      rewindDuration,\n      holdMs,\n      tone: resolvedTone,\n      debug: debugShowCanvasAt0,\n      onPhaseChange,\n    };\n  });\n\n  const frame = (dt: number): boolean => {\n    const p = propsRef.current;\n    if (!p) return false;\n    const sim = simRef.current;\n    const keep = stepSim(dt, p, sim);\n    paint(liveRef.current, sim, p);\n    return keep;\n  };\n\n  const { wake } = useVisibleLoop(rootRef, frame, { enabled: true });\n\n  // Trigger and attract changes reset the timeline. Manual mode takes its T from `progress` instead.\n  useEffect(() => {\n    const sim = simRef.current;\n    if (trigger === \"manual\" && !attract) return;\n    sim.t = 0;\n    sim.target = 0;\n    sim.dir = 1;\n    sim.timer = null;\n    sim.next = null;\n    sim.started = false;\n    sim.paused = false;\n    if (resumeRef.current !== null) {\n      window.clearTimeout(resumeRef.current);\n      resumeRef.current = null;\n    }\n    const p = propsRef.current;\n    if (p) paint(liveRef.current, sim, { ...p, trigger: attract ? \"inView\" : trigger });\n    wake();\n  }, [trigger, attract, wake]);\n\n  useEffect(() => {\n    if (!(trigger === \"manual\" && !attract)) return;\n    const sim = simRef.current;\n    const next = clamp01(progress ?? 0);\n    sim.dir = next >= sim.t ? 1 : -1;\n    sim.t = next;\n    sim.target = next;\n    const p = propsRef.current;\n    if (p) paint(liveRef.current, sim, { ...p, trigger: \"manual\" });\n  }, [progress, trigger, attract]);\n\n  // Builds the sampled particles. Reruns on text, box size, or any particle constant change.\n  const isClick = trigger === \"click\";\n  const hasText = text.length > 0;\n  useEffect(() => {\n    const live = liveRef.current;\n    const root = rootRef.current;\n    const heading = headingRef.current;\n    const canvas = canvasRef.current;\n    live.root = root;\n    live.heading = heading;\n    live.canvas = canvas;\n    const p = propsRef.current;\n    if (!root || !heading || !canvas || !p || !p.text) {\n      live.scene = null;\n      return;\n    }\n    const sim = simRef.current;\n    if (lastTextRef.current !== p.text) {\n      // Only a real text change resets T. The first registration must keep a manual `progress` set on mount.\n      const changed = lastTextRef.current !== null;\n      lastTextRef.current = p.text;\n      if (changed) {\n        sim.t = 0;\n        sim.target = 0;\n        sim.timer = null;\n        sim.next = null;\n        sim.started = false;\n      }\n    }\n\n    let cancelled = false;\n    const cs = getComputedStyle(heading);\n    const cap = maxParticles ?? (isCoarsePointer() ? 700 : 1400);\n\n    (async () => {\n      try {\n        await document.fonts.ready;\n      } catch {\n        // Proceed with the fallback face.\n      }\n      if (cancelled) return;\n      const scene = await buildScene({\n        text: p.text,\n        cap,\n        seed,\n        windAngle,\n        spill,\n        accentRatio,\n        cs,\n        heading,\n      });\n      if (cancelled) return;\n      if (!scene) {\n        live.scene = null;\n        paint(live, sim, p);\n        return;\n      }\n      live.dpr = sizeCanvas(canvas, scene.width, scene.height, defaultMaxDpr());\n      canvas.style.left = `${scene.left}px`;\n      canvas.style.top = `${scene.top}px`;\n      live.scene = scene;\n      paint(live, sim, p);\n      wake();\n    })();\n\n    return () => {\n      cancelled = true;\n    };\n  }, [text, as, className, maxParticles, seed, windAngle, spill, accentRatio, isClick, hasText, sizeTick, resolvedTone, wake]);\n\n  // Re-samples when the heading box changes size, since its wrap and ink change with it.\n  useEffect(() => {\n    const heading = headingRef.current;\n    if (!heading || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => setSizeTick((n) => n + 1));\n    observer.observe(heading);\n    return () => observer.disconnect();\n  }, [as, hasText]);\n\n  // A late-loading face changes the ink, so it triggers a re-sample too.\n  useEffect(() => {\n    const fonts = document.fonts;\n    if (!fonts) return;\n    const onFonts = () => setSizeTick((n) => n + 1);\n    fonts.addEventListener(\"loadingdone\", onFonts);\n    return () => fonts.removeEventListener(\"loadingdone\", onFonts);\n  }, []);\n\n  // Cleanup for the attract resume timer.\n  useEffect(() => {\n    return () => {\n      if (resumeRef.current !== null) window.clearTimeout(resumeRef.current);\n    };\n  }, []);\n\n  const noteInput = () => {\n    const p = propsRef.current;\n    if (!p || !p.attract || p.reduce) return;\n    const sim = simRef.current;\n    sim.paused = true;\n    if (resumeRef.current !== null) window.clearTimeout(resumeRef.current);\n    resumeRef.current = window.setTimeout(() => {\n      resumeRef.current = null;\n      sim.paused = false;\n      if (sim.t <= 0) {\n        sim.target = 0;\n        sim.timer = RESUME_DELAY;\n        sim.next = \"dissolve\";\n      } else {\n        sim.target = 0;\n        sim.timer = null;\n        sim.next = null;\n      }\n      wake();\n    }, ATTRACT_IDLE_MS);\n  };\n\n  const onEnter = () => {\n    noteInput();\n    const p = propsRef.current;\n    if (p && p.trigger === \"hover\") {\n      simRef.current.target = 1;\n      wake();\n    }\n  };\n\n  const onLeave = () => {\n    const p = propsRef.current;\n    if (p && p.trigger === \"hover\") {\n      simRef.current.target = 0;\n      wake();\n    }\n  };\n\n  const onClick = () => {\n    const sim = simRef.current;\n    sim.target = sim.target >= 1 ? 0 : 1;\n    sim.timer = null;\n    sim.next = null;\n    wake();\n  };\n\n  const Tag = as;\n  if (!text) return null;\n\n  return (\n    <div\n      ref={rootRef}\n      data-scatter-rewind=\"\"\n      className=\"relative w-full\"\n      onPointerEnter={onEnter}\n      onPointerLeave={onLeave}\n      onPointerDown={noteInput}\n      onPointerMove={noteInput}\n      onKeyDown={noteInput}\n    >\n      <Tag\n        ref={headingRef as RefObject<HTMLHeadingElement & HTMLParagraphElement>}\n        className={cn(\"m-0 text-[color:var(--bjork-text,#ededed)]\", className)}\n        // explicit tone wins for canvas tiles; otherwise the page token\n        style={tone ? { color: BJORK_PALETTE[resolvedTone].text } : undefined}\n      >\n        {isClick ? (\n          <button\n            type=\"button\"\n            onClick={onClick}\n            className=\"cursor-pointer rounded-[6px] outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--bjork-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bjork-ring-offset)]\"\n          >\n            {text}\n          </button>\n        ) : (\n          text\n        )}\n      </Tag>\n      <canvas\n        ref={canvasRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none hidden absolute left-0 top-0\"\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/text/scatter-rewind.tsx"
    },
    {
      "path": "lib/utils.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n",
      "type": "registry:lib",
      "target": "@lib/utils.ts"
    },
    {
      "path": "components/bjork-ui/_core/palette.ts",
      "content": "export type BjorkTone = \"dark\" | \"light\";\n\n// Canvas, WebGL and SVG attribute colours. DOM code uses the CSS tokens with these as fallbacks.\nexport const BJORK_PALETTE = {\n  dark: {\n    bg: \"#050505\", stage: \"#111111\", surface: \"#121212\", raised: \"#161616\", active: \"#202020\",\n    border: \"#232323\", borderStrong: \"#343434\",\n    text: \"#ededed\", textMedium: \"rgba(237,237,237,0.72)\", textMuted: \"rgba(237,237,237,0.6)\",\n    textSoft: \"rgba(237,237,237,0.5)\", textFaint: \"rgba(237,237,237,0.22)\", hair: \"rgba(237,237,237,0.12)\",\n    accent: \"#ec5c13\", accentInk: \"#ec5c13\", accentFill: \"#b84a12\", accentSoft: \"rgba(236,92,19,0.12)\", accentMuted: \"rgba(236,92,19,0.24)\",\n    accentFg: \"#fff2ea\", success: \"#4cc38a\", warning: \"#f2b544\", error: \"#ff5c4d\",\n  },\n  light: {\n    bg: \"#f7f5ef\", stage: \"#f7f5ef\", surface: \"#fffcf6\", raised: \"#f5efe3\", active: \"#efe7d8\",\n    border: \"#eee6db\", borderStrong: \"#e1d7c8\",\n    text: \"#171717\", textMedium: \"rgba(23,23,23,0.78)\", textMuted: \"rgba(23,23,23,0.68)\",\n    textSoft: \"rgba(23,23,23,0.62)\", textFaint: \"rgba(23,23,23,0.22)\", hair: \"rgba(23,23,23,0.10)\",\n    accent: \"#ec7d43\", accentInk: \"#b4531f\", accentFill: \"#ec7d43\", accentSoft: \"rgba(236,125,67,0.10)\", accentMuted: \"rgba(236,125,67,0.20)\",\n    accentFg: \"#3f2112\", success: \"#1f8a55\", warning: \"#a86b00\", error: \"#c8361f\",\n  },\n} as const;\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/palette.ts"
    },
    {
      "path": "components/bjork-ui/_core/tone.ts",
      "content": "\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\nimport { useTheme } from \"next-themes\";\nimport type { BjorkTone } from \"./palette\";\n\nconst subscribeNoop = () => () => {};\nconst getMounted = () => true;\nconst getServerMounted = () => false;\n\n/**\n * Resolves the tone for a component. An explicit `tone` always wins.\n * Otherwise it follows next-themes' resolved theme, but only after mount,\n * so the server render and first client render agree on dark.\n */\nexport function useBjorkTone(tone?: BjorkTone): BjorkTone {\n  const { resolvedTheme } = useTheme();\n  const mounted = useSyncExternalStore(subscribeNoop, getMounted, getServerMounted);\n  if (tone) return tone;\n  if (!mounted) return \"dark\";\n  return resolvedTheme === \"light\" ? \"light\" : \"dark\";\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/tone.ts"
    },
    {
      "path": "components/bjork-ui/_core/loop.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, type RefObject } from \"react\";\n\nexport interface VisibleLoopOptions {\n  enabled?: boolean;\n  fpsCap?: number;\n  rootMargin?: string;\n}\n\n/**\n * Runs `frame(dt, t)` on requestAnimationFrame while the element is near the viewport,\n * the tab is visible and `enabled` is not false. Return `false` from `frame` to go idle\n * until `wake()` is called. Writes `data-loop` on the element: running, idle or paused.\n */\nexport function useVisibleLoop(\n  ref: RefObject<HTMLElement | null>,\n  frame: (dt: number, t: number) => boolean | void,\n  opts?: VisibleLoopOptions,\n): { wake: () => void } {\n  const enabled = opts?.enabled !== false;\n  const fpsCap = opts?.fpsCap ?? 0;\n  const rootMargin = opts?.rootMargin ?? \"96px\";\n\n  const frameRef = useRef(frame);\n  useEffect(() => {\n    frameRef.current = frame;\n  }, [frame]);\n\n  const wakeRef = useRef<() => void>(() => {});\n  const wake = useCallback(() => wakeRef.current(), []);\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof window === \"undefined\") return;\n\n    let wants = true; // false once the frame callback returns false\n    let inView = false;\n    let raf = 0;\n    let last = 0;\n    let t = 0;\n    const minFrame = fpsCap > 0 ? 1000 / fpsCap : 0;\n\n    const canRun = () => enabled && inView && document.visibilityState === \"visible\";\n    const stop = () => {\n      if (raf) cancelAnimationFrame(raf);\n      raf = 0;\n    };\n\n    const update = () => {\n      if (!enabled) {\n        stop();\n        el.dataset.loop = \"paused\";\n        return;\n      }\n      if (!wants) {\n        stop();\n        el.dataset.loop = \"idle\";\n        return;\n      }\n      if (!canRun()) {\n        stop();\n        el.dataset.loop = \"paused\";\n        return;\n      }\n      el.dataset.loop = \"running\";\n      if (!raf) {\n        last = performance.now();\n        raf = requestAnimationFrame(tick);\n      }\n    };\n\n    const tick = (now: number) => {\n      raf = 0;\n      if (!canRun() || !wants) {\n        update();\n        return;\n      }\n      const elapsed = now - last;\n      if (minFrame && elapsed < minFrame - 0.5) {\n        raf = requestAnimationFrame(tick);\n        return;\n      }\n      last = now;\n      const dt = Math.min(0.05, Math.max(0, elapsed / 1000));\n      t += dt;\n      const keep = frameRef.current(dt, t);\n      if (keep === false) {\n        wants = false;\n        update();\n        return;\n      }\n      raf = requestAnimationFrame(tick);\n    };\n\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        inView = entry.isIntersecting;\n        update();\n      },\n      { rootMargin },\n    );\n    observer.observe(el);\n\n    const onVisibility = () => update();\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    wakeRef.current = () => {\n      wants = true;\n      update();\n    };\n\n    update();\n\n    return () => {\n      stop();\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      wakeRef.current = () => {};\n    };\n  }, [ref, enabled, fpsCap, rootMargin]);\n\n  return useMemo(() => ({ wake }), [wake]);\n}\n\n// matchMedia based check. False on the server.\nexport function isCoarsePointer(): boolean {\n  if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return false;\n  return window.matchMedia(\"(pointer: coarse)\").matches;\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/loop.ts"
    },
    {
      "path": "components/bjork-ui/_core/canvas.ts",
      "content": "\"use client\";\n\nimport { useEffect, useState, type RefObject } from \"react\";\nimport { isCoarsePointer } from \"./loop\";\n\n// Caps DPR at 1.5 on coarse pointers and 2 otherwise.\nexport function defaultMaxDpr(): number {\n  return isCoarsePointer() ? 1.5 : 2;\n}\n\n// Sets the backing size for a CSS box and returns the DPR used.\nexport function sizeCanvas(\n  canvas: HTMLCanvasElement,\n  cssW: number,\n  cssH: number,\n  maxDpr: number = defaultMaxDpr(),\n): number {\n  const dpr = Math.min(typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1, maxDpr);\n  canvas.width = Math.max(1, Math.round(cssW * dpr));\n  canvas.height = Math.max(1, Math.round(cssH * dpr));\n  canvas.style.width = `${cssW}px`;\n  canvas.style.height = `${cssH}px`;\n  return dpr;\n}\n\n// Rounded size of an element, kept current with a ResizeObserver.\nexport function useElementSize(ref: RefObject<HTMLElement | null>): { width: number; height: number } {\n  const [size, setSize] = useState({ width: 0, height: 0 });\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver((entries) => {\n      const box = entries[0]?.contentRect;\n      if (!box) return;\n      const width = Math.round(box.width);\n      const height = Math.round(box.height);\n      setSize((prev) => (prev.width === width && prev.height === height ? prev : { width, height }));\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [ref]);\n\n  return size;\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/canvas.ts"
    },
    {
      "path": "components/bjork-ui/_core/random.ts",
      "content": "// Seeded randomness and noise. Deterministic for a given seed, so previews and tests repeat.\n\n// Small fast PRNG. Returns floats in [0, 1).\nexport function mulberry32(seed: number): () => number {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\n// FNV-1a 32-bit.\nexport function hashString(s: string): number {\n  let h = 0x811c9dc5;\n  for (let i = 0; i < s.length; i++) {\n    h ^= s.charCodeAt(i);\n    h = Math.imul(h, 0x01000193);\n  }\n  return h >>> 0;\n}\n\n// Integer lattice hash to [-1, 1].\nfunction latticeHash(seed: number, x: number, y = 0): number {\n  let h = Math.imul(x | 0, 374761393) ^ Math.imul(y | 0, 668265263) ^ Math.imul(seed | 0, 2246822519);\n  h = Math.imul(h ^ (h >>> 13), 1274126177);\n  h ^= h >>> 16;\n  return ((h >>> 0) / 4294967295) * 2 - 1;\n}\n\nfunction smooth(t: number): number {\n  return t * t * (3 - 2 * t);\n}\n\nexport function valueNoise1D(seed: number): (x: number) => number {\n  return (x: number) => {\n    const i = Math.floor(x);\n    const f = x - i;\n    const a = latticeHash(seed, i);\n    const b = latticeHash(seed, i + 1);\n    return a + (b - a) * smooth(f);\n  };\n}\n\nexport function fbm1D(seed: number, octaves = 3): (x: number) => number {\n  const layers = Array.from({ length: octaves }, (_, o) => valueNoise1D(seed + o * 101));\n  let norm = 0;\n  for (let o = 0; o < octaves; o++) norm += 0.5 ** o;\n  return (x: number) => {\n    let sum = 0;\n    let amp = 1;\n    let freq = 1;\n    for (let o = 0; o < octaves; o++) {\n      sum += amp * layers[o](x * freq);\n      amp *= 0.5;\n      freq *= 2;\n    }\n    return sum / norm;\n  };\n}\n\nexport function valueNoise2D(seed: number): (x: number, y: number) => number {\n  return (x: number, y: number) => {\n    const xi = Math.floor(x);\n    const yi = Math.floor(y);\n    const fx = smooth(x - xi);\n    const fy = smooth(y - yi);\n    const a = latticeHash(seed, xi, yi);\n    const b = latticeHash(seed, xi + 1, yi);\n    const c = latticeHash(seed, xi, yi + 1);\n    const d = latticeHash(seed, xi + 1, yi + 1);\n    const top = a + (b - a) * fx;\n    const bottom = c + (d - c) * fx;\n    return top + (bottom - top) * fy;\n  };\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/random.ts"
    }
  ],
  "cssVars": {
    "light": {
      "bjork-bg": "#f7f3ea",
      "bjork-surface": "#fffcf6",
      "bjork-surface-muted": "rgba(250, 246, 237, 0.94)",
      "bjork-surface-hover": "#f5efe3",
      "bjork-surface-active": "#efe7d8",
      "bjork-panel": "#f8f2e7",
      "bjork-menu": "rgba(255, 252, 246, 0.98)",
      "bjork-field": "rgba(255, 252, 246, 0.96)",
      "bjork-field-muted": "rgba(248, 242, 231, 0.82)",
      "bjork-field-inset": "rgba(239, 231, 216, 0.72)",
      "bjork-error-bg": "#fff0e8",
      "bjork-text": "#171717",
      "bjork-text-strong": "rgba(23, 23, 23, 0.9)",
      "bjork-text-medium": "rgba(23, 23, 23, 0.78)",
      "bjork-text-muted": "rgba(23, 23, 23, 0.68)",
      "bjork-text-soft": "rgba(23, 23, 23, 0.62)",
      "bjork-text-faint": "rgba(23, 23, 23, 0.22)",
      "bjork-inverted-text": "#fff8f0",
      "bjork-border": "#eee6db",
      "bjork-border-muted": "#f5ede2",
      "bjork-border-strong": "#e1d7c8",
      "bjork-thumb": "#a49b8e",
      "bjork-track": "#ded6ca",
      "bjork-accent": "#ec7d43",
      "bjork-accent-hover": "#f0935f",
      "bjork-accent-soft": "rgba(236, 125, 67, 0.1)",
      "bjork-accent-muted": "rgba(236, 125, 67, 0.2)",
      "bjork-accent-badge": "rgba(236, 125, 67, 0.16)",
      "bjork-accent-badge-foreground": "#8d421d",
      "bjork-accent-foreground": "#3f2112",
      "bjork-accent-fill": "#ec7d43",
      "bjork-accent-fill-hover": "#f0935f",
      "bjork-shadow-surface": "inset 0 7px 14px rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.92), inset 1px 0 0 rgba(88, 72, 49, 0.026), inset -1px 0 0 rgba(255, 255, 255, 0.68), 0 14px 22px -9px rgba(66, 52, 33, 0.11)",
      "bjork-shadow-soft": "inset 0 1px 0 rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.86)",
      "bjork-shadow-panel": "inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.64), 0 18px 38px -30px rgba(66, 52, 33, 0.2)",
      "bjork-shadow-menu": "inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 0 12px 24px rgba(88, 72, 49, 0.022), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.62), 0 22px 44px -26px rgba(66, 52, 33, 0.22)",
      "bjork-shadow-inset": "inset 0 1px 10px rgba(88, 72, 49, 0.12), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.72)",
      "bjork-ring-offset": "#f7f3ea"
    },
    "dark": {
      "bjork-bg": "#111111",
      "bjork-surface": "#121212",
      "bjork-surface-muted": "rgba(22, 22, 22, 0.92)",
      "bjork-surface-hover": "#161616",
      "bjork-surface-active": "#202020",
      "bjork-panel": "#0d0d0d",
      "bjork-menu": "rgba(18, 18, 18, 0.98)",
      "bjork-field": "rgba(18, 18, 18, 0.95)",
      "bjork-field-muted": "rgba(18, 18, 18, 0.55)",
      "bjork-field-inset": "rgba(9, 9, 9, 0.75)",
      "bjork-error-bg": "#130d0a",
      "bjork-text": "#ededed",
      "bjork-text-strong": "rgba(237, 237, 237, 0.9)",
      "bjork-text-medium": "rgba(237, 237, 237, 0.72)",
      "bjork-text-muted": "rgba(237, 237, 237, 0.6)",
      "bjork-text-soft": "rgba(237, 237, 237, 0.5)",
      "bjork-text-faint": "rgba(237, 237, 237, 0.22)",
      "bjork-inverted-text": "#080808",
      "bjork-border": "#232323",
      "bjork-border-muted": "#1c1c1c",
      "bjork-border-strong": "#343434",
      "bjork-thumb": "#626262",
      "bjork-track": "#090909",
      "bjork-accent": "#ec5c13",
      "bjork-accent-hover": "#f07832",
      "bjork-accent-soft": "rgba(236, 92, 19, 0.12)",
      "bjork-accent-muted": "rgba(236, 92, 19, 0.24)",
      "bjork-accent-badge": "#b84a12",
      "bjork-accent-badge-foreground": "#fff2ea",
      "bjork-accent-foreground": "#fff2ea",
      "bjork-accent-fill": "#b84a12",
      "bjork-accent-fill-hover": "#a84a14",
      "bjork-shadow-surface": "inset 0 7px 14px rgba(255, 255, 255, 0.03), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.06), 0 14px 20px -6px rgba(0, 0, 0, 0.45)",
      "bjork-shadow-soft": "inset 0 1px 0 rgba(255, 255, 255, 0.045)",
      "bjork-shadow-panel": "inset 0 1px 0 rgba(255, 255, 255, 0.035), 0 18px 36px -28px rgba(0, 0, 0, 0.9)",
      "bjork-shadow-menu": "inset 0 1px 0 rgba(255, 255, 255, 0.055), inset 0 12px 24px rgba(255, 255, 255, 0.018), inset 0 -18px 26px rgba(0, 0, 0, 0.24), 0 22px 42px -22px rgba(0, 0, 0, 0.9)",
      "bjork-shadow-inset": "inset 0 1px 10px rgba(0, 0, 0, 0.45)",
      "bjork-ring-offset": "#080808"
    }
  },
  "css": {
    ".bjork-range": {
      "box-shadow": "inset 0 1px 1px rgba(255, 255, 255, 0.04), 0 0 14px rgba(189, 69, 20, 0.06)"
    },
    ".bjork-range::-webkit-slider-thumb": {
      "appearance": "none",
      "width": "16px",
      "height": "8px",
      "border": "0",
      "border-radius": "999px",
      "background": "var(--bjork-thumb)",
      "box-shadow": "inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.42)"
    },
    ".bjork-range::-moz-range-thumb": {
      "width": "16px",
      "height": "8px",
      "border": "0",
      "border-radius": "999px",
      "background": "var(--bjork-thumb)",
      "box-shadow": "inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.42)"
    },
    ".bjork-layered-button-accent, .bjork-layered-button-raised": {
      "position": "relative",
      "isolation": "isolate",
      "appearance": "none",
      "overflow": "hidden",
      "border": "0",
      "border-radius": "13px",
      "box-shadow": "inset 0 1px 0 #ffffff38, 0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a",
      "transition": "filter 150ms ease-out, box-shadow 150ms ease-out"
    },
    ".bjork-layered-button-accent::after, .bjork-layered-button-raised::after": {
      "position": "absolute",
      "inset": "0",
      "pointer-events": "none",
      "content": "\"\"",
      "border": "1.5px solid transparent",
      "border-radius": "inherit",
      "background": "linear-gradient(#ffffffb8, #0000003d 41% 75%, #ffffff47) border-box",
      "mix-blend-mode": "overlay",
      "-webkit-mask-image": "linear-gradient(#000, #000), linear-gradient(#000, #000)",
      "mask-image": "linear-gradient(#000, #000), linear-gradient(#000, #000)",
      "-webkit-mask-clip": "padding-box, border-box",
      "mask-clip": "padding-box, border-box",
      "-webkit-mask-origin": "padding-box, border-box",
      "mask-origin": "padding-box, border-box",
      "-webkit-mask-composite": "xor",
      "mask-composite": "exclude"
    },
    ".bjork-layered-button-accent": {
      "color": "oklch(100% 0 0)",
      "text-shadow": "0 -1px #00000040",
      "background": "radial-gradient(ellipse at -20px top, rgba(255, 255, 255, 0.14), transparent), linear-gradient(180deg, #ec5c13 0%, #ec5c13 62%, color-mix(in oklab, #ec5c13 84%, black 16%) 100%)"
    },
    ".bjork-layered-button-raised": {
      "color": "oklch(100% 0 0)",
      "text-shadow": "0 -1px #00000026",
      "background": "radial-gradient(ellipse at -20px top, #ffffff38, #fff0), linear-gradient(180deg, oklch(0.1881 0.006 265), oklch(0.5493 0.0081 265))"
    },
    ".dark .bjork-layered-button-raised": {
      "color": "oklch(0.2574 0.0056 265)",
      "text-shadow": "0 -1px #00000026",
      "background": "radial-gradient(ellipse at -20px top, #ffffff38, #fff0), linear-gradient(180deg, color-mix(in oklab, oklch(0.9674 0.0013 265) 80%, oklch(0.2574 0.0056 265) 20%), color-mix(in oklab, oklch(0.669 0.0107 265) 80%, oklch(0.2574 0.0056 265) 20%))"
    },
    "@media (hover: hover)": {
      ".bjork-layered-button-accent:hover": {
        "filter": "brightness(1.08) saturate(1.04)"
      },
      ".bjork-layered-button-raised:hover": {
        "filter": "brightness(1.08) contrast(0.9)"
      }
    }
  },
  "meta": {
    "collection": "Text",
    "registryUrl": "https://ui.isaiahbjork.com/scatter-rewind.json",
    "installCommand": "npx shadcn@latest add https://ui.isaiahbjork.com/scatter-rewind.json"
  },
  "type": "registry:component"
}