{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "lattice-orb",
  "title": "Lattice orb",
  "description": "Lattice orb is a production-ready ai component from BJORK UI, packaged for shadcn registries.",
  "dependencies": [
    "clsx",
    "framer-motion",
    "next-themes",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/bjork-ui/ai/lattice-orb.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState, useSyncExternalStore } 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 { isCoarsePointer, useVisibleLoop } from \"@/components/bjork-ui/_core/loop\";\nimport { defaultMaxDpr, sizeCanvas } from \"@/components/bjork-ui/_core/canvas\";\nimport { hashString, mulberry32 } from \"@/components/bjork-ui/_core/random\";\nimport { LiveRegion } from \"@/components/bjork-ui/_core/a11y\";\nimport { springAt } from \"@/components/bjork-ui/_core/spring\";\nimport { cubicBezier, springs } from \"@/components/bjork-ui/_core/motion\";\n\nexport type OrbState = \"idle\" | \"searching\" | \"connecting\" | \"composing\" | \"error\";\n\nexport interface LatticeOrbHit {\n  lat: number;\n  lon: number;\n}\n\nexport interface LatticeOrbProps {\n  state: OrbState;\n  hits?: LatticeOrbHit[];\n  dots?: number;\n  size?: number;\n  tilt?: number;\n  speed?: number;\n  sweepSpeed?: number;\n  morphMs?: number;\n  stagger?: number;\n  seed?: string;\n  label?: string;\n  showCaption?: boolean;\n  /** Freezes the orb at this time with no loop. Used for posed previews. */\n  time?: number;\n  tone?: BjorkTone;\n  attract?: boolean;\n  className?: string;\n}\n\nconst TAU = Math.PI * 2;\nconst DEG = Math.PI / 180;\nconst FRONT_HALF_WIDTH = 6 * DEG;\nconst IDLE_SPIN = 0.15;\nconst SEARCH_SPIN = 0.5;\nconst CONNECT_SPIN = 0.2;\nconst MERIDIAN_RATE = 1.2;\nconst PULSE_S = 0.6;\nconst ERROR_JITTER_S = 0.3;\nconst ERROR_SETTLED_S = 10; // age used for static error poses, long past the spring\nconst ANCHOR_COUNT = 7;\nconst ANCHOR_SCALE = 2.2;\nconst ANCHOR_PAIRS: ReadonlyArray<readonly [number, number]> = [\n  [0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [5, 6], [6, 0], [1, 4],\n];\nconst EDGE_STEP_S = 0.4;\nconst EDGE_CYCLE_S = 4;\nconst BAND_COUNT = 5;\nconst MERIDIAN_SEGMENTS = 72;\nconst STRIDE = 6; // x, y, z, brightness, colour code (0 text, 1 accent, 2 error), size scale\nconst COLOR_TEXT = 0;\nconst COLOR_ACCENT = 1;\nconst COLOR_ERROR = 2;\nconst BUCKETS = 8;\nconst FRAME_SAMPLES = 120;\nconst EASE = cubicBezier(0.23, 1, 0.32, 1);\n\nconst ATTRACT_STEP_S = 3.5;\nconst ATTRACT_ERROR_S = 1.2;\nconst ATTRACT_HIT_S = 0.8;\nconst ATTRACT_HIT_CAP = 6;\nconst ATTRACT_IDLE_PAUSE_S = 4;\nconst ATTRACT_CYCLE_S = 4 * ATTRACT_STEP_S;\nconst ATTRACT_PERIOD_S = 4 * ATTRACT_CYCLE_S + ATTRACT_ERROR_S;\n\nconst STATE_LABEL: Record<OrbState, string> = {\n  idle: \"Idle\",\n  searching: \"Searching\",\n  connecting: \"Connecting\",\n  composing: \"Composing\",\n  error: \"Error\",\n};\n\nconst EMPTY_HITS: LatticeOrbHit[] = [];\n\ninterface Geo {\n  n: number;\n  base: Float32Array; // unit sphere, x y z per dot\n  rank: Float32Array; // 0 at the top, 1 at the bottom\n  band: Uint8Array; // latitude band 0 to 4\n  noise: Float32Array; // seeded -1..1, three per dot\n  anchor: Int16Array; // anchor slot 0..6, or -1\n  anchorIds: Int32Array; // dot index for each anchor slot\n}\n\nfunction buildGeo(n: number, seed: string): Geo {\n  const base = new Float32Array(n * 3);\n  const rank = new Float32Array(n);\n  const band = new Uint8Array(n);\n  const noise = new Float32Array(n * 3);\n  const anchor = new Int16Array(n).fill(-1);\n  const golden = Math.PI * (3 - Math.sqrt(5));\n\n  for (let i = 0; i < n; i++) {\n    const y = 1 - (2 * (i + 0.5)) / n;\n    const r = Math.sqrt(1 - y * y);\n    const th = golden * i;\n    base[i * 3] = Math.cos(th) * r;\n    base[i * 3 + 1] = y;\n    base[i * 3 + 2] = Math.sin(th) * r;\n    rank[i] = n > 1 ? i / (n - 1) : 0;\n    band[i] = Math.min(BAND_COUNT - 1, Math.floor(((y + 1) / 2) * BAND_COUNT));\n  }\n\n  const nrnd = mulberry32(hashString(`${seed}:noise`));\n  for (let i = 0; i < n * 3; i++) noise[i] = nrnd() * 2 - 1;\n\n  const arnd = mulberry32(hashString(seed));\n  const anchorIds = new Int32Array(ANCHOR_COUNT);\n  for (let s = 0; s < ANCHOR_COUNT; s++) {\n    let idx = Math.floor(arnd() * n);\n    while (anchor[idx] >= 0) idx = (idx + 1) % n;\n    anchor[idx] = s;\n    anchorIds[s] = idx;\n  }\n\n  return { n, base, rank, band, noise, anchor, anchorIds };\n}\n\nfunction wrapPi(d: number): number {\n  return d - TAU * Math.round(d / TAU);\n}\n\nfunction hitKey(h: LatticeOrbHit): string {\n  return `${h.lat.toFixed(5)}:${h.lon.toFixed(5)}`;\n}\n\n// Dot whose spun position faces the hit most directly. Used when a meridian passes a hit.\nfunction nearestDot(g: Geo, hit: LatticeOrbHit, tau: number): number {\n  const ang = SEARCH_SPIN * tau;\n  const c = Math.cos(ang);\n  const s = Math.sin(ang);\n  const cl = Math.cos(hit.lat);\n  const hx = cl * Math.cos(hit.lon);\n  const hy = Math.sin(hit.lat);\n  const hz = cl * Math.sin(hit.lon);\n  let best = -2;\n  let pick = 0;\n  for (let i = 0; i < g.n; i++) {\n    const k = i * 3;\n    const x0 = g.base[k];\n    const z0 = g.base[k + 2];\n    const x = x0 * c + z0 * s;\n    const y = g.base[k + 1];\n    const z = -x0 * s + z0 * c;\n    const d = x * hx + y * hy + z * hz;\n    if (d > best) {\n      best = d;\n      pick = i;\n    }\n  }\n  return pick;\n}\n\ninterface FrameEnv {\n  R: number;\n  sweepSpeed: number;\n  errAge: number;\n  latched: Uint8Array | null;\n}\n\n// Pure target frame for a state at time tau. Writes x, y, z (unit sphere, before tilt), brightness, colour and size.\nfunction fillTarget(state: OrbState, tau: number, g: Geo, out: Float32Array, env: FrameEnv): void {\n  const { n, base, band, noise, anchor } = g;\n  const phi = tau * MERIDIAN_RATE * env.sweepSpeed;\n  let spin = IDLE_SPIN * tau;\n  let radius = 1;\n  if (state === \"idle\") radius = 1 + 0.02 * Math.sin((TAU * tau) / 4);\n  else if (state === \"searching\") spin = SEARCH_SPIN * tau;\n  else if (state === \"connecting\") spin = CONNECT_SPIN * tau;\n  else if (state === \"error\") radius = springAt(env.errAge, springs.settle, 1, 0.9);\n\n  // One pixel in unit-sphere space, applied only during the first 300ms of an error.\n  const jitter = state === \"error\" && env.errAge < ERROR_JITTER_S ? 1 / env.R : 0;\n\n  const bandAngle = [0, 0, 0, 0, 0];\n  if (state === \"composing\") {\n    for (let j = 0; j < BAND_COUNT; j++) bandAngle[j] = (0.2 + 0.25 * j) * (j % 2 ? -1 : 1) * tau;\n  }\n\n  for (let i = 0; i < n; i++) {\n    const k = i * 3;\n    const ang = state === \"composing\" ? bandAngle[band[i]] : spin;\n    const c = Math.cos(ang);\n    const s = Math.sin(ang);\n    const x0 = base[k];\n    const y0 = base[k + 1];\n    const z0 = base[k + 2];\n    let x = x0 * c + z0 * s;\n    let y = y0;\n    const z = -x0 * s + z0 * c;\n    if (jitter) {\n      x += noise[k] * jitter;\n      y += noise[k + 1] * jitter;\n    }\n\n    let b = 0.55;\n    let col = COLOR_TEXT;\n    let sz = 1;\n    if (state === \"searching\") {\n      const d = wrapPi(Math.atan2(z, x) - phi);\n      b = Math.abs(d) < FRONT_HALF_WIDTH ? 1 : 0.45;\n    } else if (state === \"composing\") {\n      b = band[i] === 2 ? 1 : 0.6;\n    } else if (state === \"connecting\") {\n      if (anchor[i] >= 0) {\n        b = 1;\n        sz = ANCHOR_SCALE;\n      }\n    } else if (state === \"error\") {\n      b = 0.8;\n      col = COLOR_ERROR;\n    }\n\n    if (env.latched && env.latched[i] && col !== COLOR_ERROR) {\n      col = COLOR_ACCENT;\n      b = 1;\n    }\n\n    const o = i * STRIDE;\n    out[o] = x * radius;\n    out[o + 1] = y * radius;\n    out[o + 2] = z * radius;\n    out[o + 3] = b;\n    out[o + 4] = col;\n    out[o + 5] = sz;\n  }\n}\n\ninterface EngineConfig {\n  n: number;\n  size: number;\n  tilt: number;\n  speed: number;\n  sweepSpeed: number;\n  morphMs: number;\n  stagger: number;\n  seed: string;\n  state: OrbState;\n  hits: LatticeOrbHit[];\n  tone: BjorkTone;\n  attract: boolean;\n  reduce: boolean;\n}\n\ninterface Pulse {\n  dot: number;\n  t0: number;\n}\n\ninterface HitRecord {\n  key: string;\n  lat: number;\n  lon: number;\n}\n\ninterface AttractSlot {\n  state: OrbState;\n  cycle: number;\n  count: number;\n}\n\n// Deterministic attract schedule for a clock value. idle, searching, connecting, composing; error after every 4th cycle.\nfunction attractAt(clock: number): AttractSlot {\n  const period = clock % ATTRACT_PERIOD_S;\n  const base = Math.floor(clock / ATTRACT_PERIOD_S) * 4;\n  let start = 0;\n  for (let j = 0; j < 4; j++) {\n    const hasError = j === 3;\n    const len = ATTRACT_CYCLE_S + (hasError ? ATTRACT_ERROR_S : 0);\n    if (period < start + len) {\n      const u = period - start;\n      const cycle = base + j;\n      if (u < ATTRACT_STEP_S) return { state: \"idle\", cycle, count: 0 };\n      if (u < 2 * ATTRACT_STEP_S) {\n        const count = Math.min(ATTRACT_HIT_CAP, Math.floor((u - ATTRACT_STEP_S) / ATTRACT_HIT_S));\n        return { state: \"searching\", cycle, count };\n      }\n      if (u < 3 * ATTRACT_STEP_S) return { state: \"connecting\", cycle, count: 0 };\n      if (u < 4 * ATTRACT_STEP_S) return { state: \"composing\", cycle, count: 0 };\n      return { state: \"error\", cycle, count: 0 };\n    }\n    start += len;\n  }\n  return { state: \"idle\", cycle: base, count: 0 };\n}\n\nfunction attractHitPool(cycle: number): LatticeOrbHit[] {\n  const rnd = mulberry32(hashString(`attract:${cycle}`));\n  const pool: LatticeOrbHit[] = [];\n  for (let j = 0; j < ATTRACT_HIT_CAP; j++) {\n    pool.push({ lat: (rnd() - 0.5) * 1.0, lon: rnd() * TAU });\n  }\n  return pool;\n}\n\n// The engine owns every mutable buffer, the morph, latches and the attract clock. React only pushes config into it.\nclass LatticeEngine {\n  cfg: EngineConfig = {\n    n: 0, size: 240, tilt: 18, speed: 1, sweepSpeed: 1, morphMs: 520, stagger: 0.35, seed: \"bjork\",\n    state: \"idle\", hits: EMPTY_HITS, tone: \"dark\", attract: false, reduce: false,\n  };\n  private onShown: ((s: OrbState) => void) | null = null;\n\n  private geo: Geo | null = null;\n  private geoKey = \"\";\n  private target = new Float32Array(0);\n  private drawn = new Float32Array(0);\n  private from = new Float32Array(0);\n  private staticMask = new Uint8Array(0);\n  private mask = new Uint8Array(0);\n  private hitsRef: LatticeOrbHit[] | null = null;\n  private hitList: HitRecord[] = [];\n  private latch = new Map<string, number>();\n  private pulses: Pulse[] = [];\n  private lastPhi: number | null = null;\n  private curState: OrbState | null = null;\n  private hasDrawn = false;\n  private morphing = false;\n  private morphStart = 0;\n  private errStart = 0;\n  private t = 0;\n  private lastInput = Number.NEGATIVE_INFINITY;\n  private attractClock = 0;\n  private attractShown: OrbState | null = null;\n  private attractCache: { key: string; hits: LatticeOrbHit[] } = { key: \"\", hits: EMPTY_HITS };\n  private canvas: HTMLCanvasElement | null = null;\n  private ctx: CanvasRenderingContext2D | null = null;\n  private root: HTMLElement | null = null;\n  private cssSize = 0;\n  private dpr = 1;\n  private frameMs: number[] = [];\n  private projX = new Float32Array(0);\n  private projY = new Float32Array(0);\n  private projD = new Float32Array(0);\n  private projA = new Float32Array(0);\n  private projR = new Float32Array(0);\n  private projC = new Uint8Array(0);\n  private bucketOf = new Uint8Array(0);\n  private order = new Int32Array(0);\n  private latchedCount = 0;\n\n  attach(canvas: HTMLCanvasElement | null, root: HTMLElement | null) {\n    this.root = root;\n    if (canvas !== this.canvas) {\n      this.canvas = canvas;\n      this.cssSize = 0;\n      this.ctx = null;\n    }\n  }\n\n  private ensureCanvas(size: number) {\n    if (!this.canvas) return;\n    if (this.cssSize === size && this.ctx) return;\n    this.dpr = sizeCanvas(this.canvas, size, size, defaultMaxDpr());\n    this.ctx = this.canvas.getContext(\"2d\");\n    this.cssSize = size;\n  }\n\n  configure(next: EngineConfig) {\n    const key = `${next.n}|${next.seed}`;\n    if (key !== this.geoKey || !this.geo) {\n      this.geoKey = key;\n      this.geo = buildGeo(next.n, next.seed);\n      const len = next.n * STRIDE;\n      this.target = new Float32Array(len);\n      this.drawn = new Float32Array(len);\n      this.from = new Float32Array(len);\n      this.staticMask = new Uint8Array(next.n);\n      this.mask = new Uint8Array(next.n);\n      this.projX = new Float32Array(next.n);\n      this.projY = new Float32Array(next.n);\n      this.projD = new Float32Array(next.n);\n      this.projA = new Float32Array(next.n);\n      this.projR = new Float32Array(next.n);\n      this.projC = new Uint8Array(next.n);\n      this.bucketOf = new Uint8Array(next.n);\n      this.order = new Int32Array(next.n);\n      this.hasDrawn = false;\n      this.morphing = false;\n      this.lastPhi = null;\n      this.latch.clear();\n      this.pulses = [];\n      this.hitsRef = null;\n      this.hitList = [];\n      this.mask.fill(0);\n    }\n    this.cfg = next;\n    this.ensureCanvas(next.size);\n  }\n\n  noteInput() {\n    this.lastInput = this.t;\n  }\n\n  // Receives the attract state whenever it changes, so React can update the caption.\n  listen(callback: ((s: OrbState) => void) | null) {\n    this.onShown = callback;\n  }\n\n  private syncHits(hits: LatticeOrbHit[]) {\n    if (hits === this.hitsRef) return;\n    this.hitsRef = hits;\n    const list: HitRecord[] = hits.map((h) => ({ key: hitKey(h), lat: h.lat, lon: h.lon }));\n    const keys = new Set(list.map((h) => h.key));\n    for (const key of Array.from(this.latch.keys())) {\n      if (!keys.has(key)) this.latch.delete(key);\n    }\n    this.hitList = list;\n    this.rebuildMask();\n  }\n\n  private rebuildMask() {\n    this.mask.fill(0);\n    for (const dot of this.latch.values()) this.mask[dot] = 1;\n    this.latchedCount = this.latch.size;\n  }\n\n  private startMorph(t: number) {\n    if (!this.hasDrawn || !this.geo) {\n      this.morphing = false;\n      return;\n    }\n    this.from.set(this.drawn);\n    this.morphStart = t;\n    this.morphing = true;\n  }\n\n  private checkCrossings(prev: number, now: number, tau: number, t: number) {\n    const g = this.geo;\n    if (!g) return;\n    for (const h of this.hitList) {\n      if (this.latch.has(h.key)) continue;\n      const kPrev = Math.floor((prev - h.lon) / TAU);\n      const kNow = Math.floor((now - h.lon) / TAU);\n      if (kNow > kPrev) {\n        const dot = nearestDot(g, h, tau);\n        this.latch.set(h.key, dot);\n        this.mask[dot] = 1;\n        this.pulses.push({ dot, t0: t });\n      }\n    }\n    this.latchedCount = this.latch.size;\n  }\n\n  private advanceBuffers(t: number) {\n    const g = this.geo;\n    if (!g) return;\n    const { n, rank, noise } = g;\n    const { target, drawn, from } = this;\n    const { morphMs, stagger } = this.cfg;\n\n    if (!this.morphing) {\n      drawn.set(target);\n      return;\n    }\n\n    const elapsedMs = (t - this.morphStart) * 1000;\n    const total = morphMs * (1 + stagger);\n    for (let i = 0; i < n; i++) {\n      const delay = stagger * morphMs * rank[i];\n      const raw = (elapsedMs - delay) / morphMs;\n      const m = EASE(Math.min(1, Math.max(0, raw)));\n      const scramble = 0.08 * Math.sin(Math.PI * m);\n      const o = i * STRIDE;\n      for (let c = 0; c < 3; c++) {\n        drawn[o + c] = from[o + c] + (target[o + c] - from[o + c]) * m + noise[i * 3 + c] * scramble;\n      }\n      drawn[o + 3] = from[o + 3] + (target[o + 3] - from[o + 3]) * m;\n      drawn[o + 4] = target[o + 4];\n      drawn[o + 5] = from[o + 5] + (target[o + 5] - from[o + 5]) * m;\n    }\n    if (elapsedMs >= total) {\n      this.morphing = false;\n      this.setMorphAttr(false);\n    }\n  }\n\n  private setMorphAttr(value: boolean) {\n    if (this.root) this.root.dataset.morphing = value ? \"true\" : \"false\";\n  }\n\n  private projectAndDraw(tau: number, state: OrbState, t: number) {\n    const ctx = this.ctx;\n    const g = this.geo;\n    const { size, tilt, tone } = this.cfg;\n    if (!ctx || !g) return;\n\n    const pal = BJORK_PALETTE[tone];\n    const R = size * 0.42;\n    // Blur test (alpha-weighted ink centroid, all five states): the tilted lattice already sits at 0.500 of\n    // the canvas height, so there is no vertical nudge. The +1% drop the plan suggested left it 1% low.\n    const cx = size / 2;\n    const cy = size / 2;\n    const base = size / 120;\n    const cT = Math.cos(tilt * DEG);\n    const sT = Math.sin(tilt * DEG);\n    const n = g.n;\n    const { drawn, projX, projY, projD, projA, projR, projC, bucketOf, order } = this;\n\n    for (let i = 0; i < n; i++) {\n      const o = i * STRIDE;\n      const x = drawn[o];\n      const y0 = drawn[o + 1];\n      const z0 = drawn[o + 2];\n      const y = y0 * cT - z0 * sT;\n      const z = y0 * sT + z0 * cT;\n      const d = Math.min(1, Math.max(0, (z + 1) / 2));\n      projX[i] = cx + x * R;\n      projY[i] = cy - y * R;\n      projD[i] = z;\n      projA[i] = (0.25 + 0.75 * d) * drawn[o + 3];\n      projR[i] = base * (0.55 + 0.45 * d) * drawn[o + 5];\n      projC[i] = drawn[o + 4];\n      bucketOf[i] = Math.min(BUCKETS - 1, Math.floor(d * BUCKETS));\n    }\n\n    // Counting sort into 8 depth buckets, back to front. No full sort.\n    const counts = new Int32Array(BUCKETS + 1);\n    for (let i = 0; i < n; i++) counts[bucketOf[i] + 1]++;\n    for (let b = 0; b < BUCKETS; b++) counts[b + 1] += counts[b];\n    for (let i = 0; i < n; i++) order[counts[bucketOf[i]]++] = i;\n\n    ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);\n    ctx.clearRect(0, 0, size, size);\n\n    // Connecting edges sit under the dots.\n    if (state === \"connecting\" && g.anchorIds.length) {\n      const phase = tau % EDGE_CYCLE_S;\n      ctx.strokeStyle = pal.accentInk;\n      ctx.lineWidth = 1;\n      for (let k = 0; k < ANCHOR_PAIRS.length; k++) {\n        if (phase < k * EDGE_STEP_S) continue;\n        const [pa, pb] = ANCHOR_PAIRS[k];\n        const a = g.anchorIds[pa];\n        const b = g.anchorIds[pb];\n        ctx.globalAlpha = 0.7;\n        ctx.beginPath();\n        ctx.moveTo(projX[a], projY[a]);\n        ctx.lineTo(projX[b], projY[b]);\n        ctx.stroke();\n      }\n    }\n\n    let lastColor = -1;\n    for (let j = 0; j < n; j++) {\n      const i = order[j];\n      if (projC[i] !== lastColor) {\n        lastColor = projC[i];\n        ctx.fillStyle = lastColor === COLOR_ACCENT ? pal.accent : lastColor === COLOR_ERROR ? pal.error : pal.text;\n      }\n      ctx.globalAlpha = projA[i];\n      const r = projR[i];\n      const px = projX[i];\n      const py = projY[i];\n      if (r < 1.6) {\n        ctx.fillRect(px - r, py - r, 2 * r, 2 * r);\n      } else {\n        ctx.beginPath();\n        ctx.arc(px, py, r, 0, TAU);\n        ctx.fill();\n      }\n    }\n\n    // Meridian: front hemisphere only.\n    if (state === \"searching\") {\n      const phi = tau * MERIDIAN_RATE * this.cfg.sweepSpeed;\n      const cp = Math.cos(phi);\n      const sp = Math.sin(phi);\n      ctx.strokeStyle = pal.accentInk;\n      ctx.lineWidth = 1;\n      ctx.globalAlpha = 1;\n      ctx.beginPath();\n      let pen = false;\n      for (let s = 0; s <= MERIDIAN_SEGMENTS; s++) {\n        const lat = -Math.PI / 2 + (Math.PI * s) / MERIDIAN_SEGMENTS;\n        const cl = Math.cos(lat);\n        const x = cl * cp;\n        const y0 = Math.sin(lat);\n        const z0 = cl * sp;\n        const y = y0 * cT - z0 * sT;\n        const z = y0 * sT + z0 * cT;\n        if (z > 0) {\n          const px = cx + x * R;\n          const py = cy - y * R;\n          if (pen) ctx.lineTo(px, py);\n          else ctx.moveTo(px, py);\n          pen = true;\n        } else {\n          pen = false;\n        }\n      }\n      ctx.stroke();\n    }\n\n    // Search pulses on newly latched hits.\n    if (this.pulses.length) {\n      const alive: Pulse[] = [];\n      ctx.strokeStyle = pal.accent;\n      ctx.lineWidth = 1;\n      for (const p of this.pulses) {\n        const prog = (t - p.t0) / PULSE_S;\n        if (prog >= 1) continue;\n        alive.push(p);\n        ctx.globalAlpha = 0.6 * (1 - prog);\n        ctx.beginPath();\n        ctx.arc(projX[p.dot], projY[p.dot], 2 + 8 * prog, 0, TAU);\n        ctx.stroke();\n      }\n      this.pulses = alive;\n    }\n\n    ctx.globalAlpha = 1;\n    if (this.root) {\n      this.root.dataset.latched = String(this.latchedCount);\n      this.root.dataset.drawn = String(n);\n    }\n  }\n\n  private recordFrame(ms: number) {\n    if (process.env.NODE_ENV === \"production\") return;\n    this.frameMs.push(ms);\n    if (this.frameMs.length > FRAME_SAMPLES) this.frameMs.shift();\n    if (this.frameMs.length % 30 === 0 && this.root) {\n      const sorted = [...this.frameMs].sort((a, b) => a - b);\n      const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];\n      this.root.dataset.frameMs = p95.toFixed(2);\n    }\n  }\n\n  // Draws one static pose at `t`. Every hit is latched, and there is no morph.\n  drawStatic(t: number) {\n    const g = this.geo;\n    if (!g || !this.canvas) return;\n    this.ensureCanvas(this.cfg.size);\n    const state = this.cfg.state;\n    const tau = t * this.cfg.speed;\n    const hits = this.cfg.hits;\n    this.staticMask.fill(0);\n    for (const h of hits) this.staticMask[nearestDot(g, h, tau)] = 1;\n    this.target.fill(0);\n    fillTarget(state, tau, g, this.target, {\n      R: this.cfg.size * 0.42,\n      sweepSpeed: this.cfg.sweepSpeed,\n      errAge: ERROR_SETTLED_S,\n      latched: this.staticMask,\n    });\n    this.drawn.set(this.target);\n    this.morphing = false;\n    this.hasDrawn = true;\n    this.curState = state;\n    this.lastPhi = null;\n    this.pulses = [];\n    this.latchedCount = new Set(hits.map(hitKey)).size;\n    this.setMorphAttr(false);\n    this.projectAndDraw(tau, state, t);\n    this.noteShown(state);\n  }\n\n  private noteShown(state: OrbState) {\n    if (this.cfg.attract && this.attractShown !== state) {\n      this.attractShown = state;\n      this.onShown?.(state);\n    }\n  }\n\n  // Called by useVisibleLoop each frame. Returns false to go idle (reduced motion).\n  tick(dt: number, t: number): boolean {\n    if (this.cfg.reduce) {\n      this.t = t;\n      this.drawStatic(0);\n      return false;\n    }\n    const start = typeof performance !== \"undefined\" ? performance.now() : 0;\n    this.t = t;\n    if (!this.geo || !this.canvas) return true;\n\n    let state: OrbState = this.cfg.state;\n    let hits = this.cfg.hits;\n\n    if (this.cfg.attract) {\n      const paused = t - this.lastInput < ATTRACT_IDLE_PAUSE_S;\n      if (!paused) this.attractClock += dt;\n      const slot = attractAt(this.attractClock);\n      state = slot.state;\n      const key = `${slot.cycle}|${slot.count}`;\n      if (this.attractCache.key !== key) {\n        this.attractCache = {\n          key,\n          hits: slot.count > 0 ? attractHitPool(slot.cycle).slice(0, slot.count) : EMPTY_HITS,\n        };\n      }\n      hits = this.attractCache.hits;\n      this.noteShown(state);\n    }\n\n    this.syncHits(hits);\n\n    if (state !== this.curState) {\n      this.startMorph(t);\n      this.curState = state;\n      if (state === \"error\") this.errStart = t;\n      if (this.morphing) this.setMorphAttr(true);\n    }\n\n    const tau = t * this.cfg.speed;\n    const { sweepSpeed, size } = this.cfg;\n    if (state === \"searching\") {\n      const phi = tau * MERIDIAN_RATE * sweepSpeed;\n      if (this.lastPhi !== null) this.checkCrossings(this.lastPhi, phi, tau, t);\n      this.lastPhi = phi;\n    } else {\n      this.lastPhi = null;\n    }\n\n    fillTarget(state, tau, this.geo, this.target, {\n      R: size * 0.42,\n      sweepSpeed,\n      errAge: t - this.errStart,\n      latched: this.mask,\n    });\n    this.advanceBuffers(t);\n    this.hasDrawn = true;\n    this.projectAndDraw(tau, state, t);\n    this.recordFrame(typeof performance !== \"undefined\" ? performance.now() - start : 0);\n    return true;\n  }\n}\n\nconst subscribeNone = () => () => {};\nconst mountedClient = () => true;\nconst mountedServer = () => false;\n\nfunction subscribeCoarse(callback: () => void) {\n  if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return () => {};\n  const query = window.matchMedia(\"(pointer: coarse)\");\n  query.addEventListener(\"change\", callback);\n  return () => query.removeEventListener(\"change\", callback);\n}\n\nexport function LatticeOrb({\n  state,\n  hits,\n  dots,\n  size = 240,\n  tilt = 18,\n  speed = 1,\n  sweepSpeed = 1,\n  morphMs = 520,\n  stagger = 0.35,\n  seed = \"bjork\",\n  label,\n  showCaption = true,\n  time,\n  tone: toneProp,\n  attract = false,\n  className,\n}: LatticeOrbProps) {\n  const tone = useBjorkTone(toneProp);\n  const coarse = useSyncExternalStore(subscribeCoarse, isCoarsePointer, () => false);\n  // Reduced motion is read only after mount, so the server render and first client render match.\n  const mounted = useSyncExternalStore(subscribeNone, mountedClient, mountedServer);\n  const prefersReduced = useReducedMotion();\n  const reduce = mounted && !!prefersReduced;\n\n  const n = dots ?? (coarse ? 360 : 600);\n  const attractOn = attract && !reduce;\n  const isStatic = time !== undefined;\n  const hitList = hits ?? EMPTY_HITS;\n\n  const [engine] = useState(() => new LatticeEngine());\n  const [attractShown, setAttractShown] = useState<OrbState>(\"idle\");\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  const shown: OrbState = attractOn ? attractShown : state;\n  const caption = label ?? STATE_LABEL[shown];\n\n  useEffect(() => {\n    engine.listen(setAttractShown);\n    return () => engine.listen(null);\n  }, [engine]);\n\n  useEffect(() => {\n    engine.attach(canvasRef.current, rootRef.current);\n    engine.configure({\n      n,\n      size,\n      tilt,\n      speed,\n      sweepSpeed,\n      morphMs,\n      stagger,\n      seed,\n      state,\n      hits: hitList,\n      tone,\n      attract: attractOn,\n      reduce,\n    });\n    if (reduce || isStatic) engine.drawStatic(time ?? 0);\n  }, [engine, n, size, tilt, speed, sweepSpeed, morphMs, stagger, seed, state, hitList, tone, attractOn, reduce, isStatic, time]);\n\n  useVisibleLoop(rootRef, (dt, t) => engine.tick(dt, t), {\n    enabled: !isStatic,\n    fpsCap: coarse ? 30 : 0,\n  });\n\n  return (\n    <div\n      ref={rootRef}\n      data-state={shown}\n      onPointerEnter={() => engine.noteInput()}\n      onPointerMove={() => engine.noteInput()}\n      onPointerDown={() => engine.noteInput()}\n      onKeyDown={() => engine.noteInput()}\n      className={cn(\"relative flex w-full select-none flex-col items-center\", className)}\n    >\n      <canvas\n        ref={canvasRef}\n        role=\"img\"\n        aria-label={`Agent status: ${caption}`}\n        className=\"block\"\n        style={{ width: size, height: size }}\n      />\n      {showCaption && (\n        <span\n          aria-hidden=\"true\"\n          /* The caption follows the resolved tone, so an explicit light or dark tile reads on any page theme. */\n          style={{ color: BJORK_PALETTE[tone].textMuted }}\n          className=\"mt-3 block font-mono text-[11px] uppercase leading-none tabular-nums tracking-[0.1em]\"\n        >\n          {caption}\n        </span>\n      )}\n      {/* Attract is decorative: announce the real state only, never the demo cycle. */}\n      <LiveRegion message={attractOn ? (label ?? STATE_LABEL[state]) : caption} />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/ai/lattice-orb.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"
    },
    {
      "path": "components/bjork-ui/_core/a11y.tsx",
      "content": "import type { ReactNode } from \"react\";\n\n// Text for assistive tech only.\nexport function VisuallyHidden({ children }: { children: ReactNode }) {\n  return <span className=\"sr-only\">{children}</span>;\n}\n\n// Announces `message` when it changes. Same text re-rendered does not re-announce.\nexport function LiveRegion({\n  message,\n  politeness = \"polite\",\n}: {\n  message: string;\n  politeness?: \"polite\" | \"assertive\";\n}) {\n  return (\n    <span role=\"status\" aria-live={politeness} aria-atomic=\"true\" className=\"sr-only\">\n      {message}\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/a11y.tsx"
    },
    {
      "path": "components/bjork-ui/_core/spring.ts",
      "content": "// Pure spring math, no React. Units: stiffness k, damping d (coefficient), mass m, time in seconds.\n\nexport interface SpringConfig {\n  stiffness: number;\n  damping: number;\n  mass: number;\n}\n\nexport interface SpringState {\n  x: number;\n  v: number;\n}\n\n// Damping ratio zeta = d / (2 * sqrt(k * m)).\nexport function dampingRatio(c: SpringConfig): number {\n  return c.damping / (2 * Math.sqrt(c.stiffness * c.mass));\n}\n\n// Exact solution of y'' = -(k/m) y - (d/m) y' for displacement y0 and velocity v0 at time t.\n// Returns displacement and velocity together so stepping can be exact, not integrated.\nfunction solveLinear(t: number, c: SpringConfig, y0: number, v0: number): { y: number; v: number } {\n  const w0 = Math.sqrt(c.stiffness / c.mass);\n  const z = dampingRatio(c);\n\n  if (Math.abs(z - 1) < 1e-9) {\n    // Critically damped.\n    const e = Math.exp(-w0 * t);\n    const b = v0 + w0 * y0;\n    return { y: e * (y0 + b * t), v: e * (b - w0 * (y0 + b * t)) };\n  }\n  if (z < 1) {\n    // Underdamped.\n    const wd = w0 * Math.sqrt(1 - z * z);\n    const a = z * w0;\n    const B = (v0 + a * y0) / wd;\n    const e = Math.exp(-a * t);\n    const cos = Math.cos(wd * t);\n    const sin = Math.sin(wd * t);\n    return {\n      y: e * (y0 * cos + B * sin),\n      v: e * (-a * (y0 * cos + B * sin) + wd * (-y0 * sin + B * cos)),\n    };\n  }\n  // Overdamped.\n  const s = w0 * Math.sqrt(z * z - 1);\n  const r1 = -z * w0 + s;\n  const r2 = -z * w0 - s;\n  const B = (v0 - r1 * y0) / (r2 - r1);\n  const A = y0 - B;\n  const e1 = Math.exp(r1 * t);\n  const e2 = Math.exp(r2 * t);\n  return { y: A * e1 + B * e2, v: A * r1 * e1 + B * r2 * e2 };\n}\n\n// Analytic position at time t for a spring from `from` to `to` with initial velocity v0.\nexport function springAt(t: number, c: SpringConfig, from = 0, to = 1, v0 = 0): number {\n  return to + solveLinear(t, c, from - to, v0).y;\n}\n\n// Overshoot as a fraction of the total travel. Zero when critically or overdamped.\nexport function overshoot(c: SpringConfig): number {\n  const z = dampingRatio(c);\n  if (z >= 1) return 0;\n  return Math.exp((-Math.PI * z) / Math.sqrt(1 - z * z));\n}\n\n// Seconds until the spring stays within epsilon of its target (fraction of travel).\n// Found by scanning backwards from a bound derived from the slowest decay rate.\nexport function settleTime(c: SpringConfig, epsilon = 0.001): number {\n  const w0 = Math.sqrt(c.stiffness / c.mass);\n  const z = dampingRatio(c);\n  const slowest = z < 1 ? z * w0 : (z - Math.sqrt(z * z - 1)) * w0;\n  const tEnd = (Math.log(1 / epsilon) / slowest) * 1.5 + 0.05;\n  const steps = 4000;\n  const dt = tEnd / steps;\n  const band = epsilon;\n  let last = 0;\n  for (let i = 0; i <= steps; i++) {\n    const t = i * dt;\n    if (Math.abs(springAt(t, c, 1, 0, 0)) >= band) last = t;\n  }\n  return last + dt;\n}\n\n// Advances the state by dt towards `target`. Uses the exact solution of the linear spring\n// (no integration), so it tracks springAt to rounding error at any step size and stays stable\n// for stiff configs where a fixed-step integrator drifts.\nexport function stepSpring(s: SpringState, target: number, c: SpringConfig, dt: number): void {\n  if (dt <= 0) return;\n  const r = solveLinear(dt, c, s.x - target, s.v);\n  s.x = target + r.y;\n  s.v = r.v;\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/spring.ts"
    },
    {
      "path": "components/bjork-ui/_core/motion.ts",
      "content": "// Shared springs, easings and durations. Components import these instead of inventing values.\n\nexport const springs = {\n  press: { type: \"spring\", stiffness: 500, damping: 30, mass: 0.6 },\n  snappy: { type: \"spring\", stiffness: 400, damping: 25, mass: 1 },\n  standard: { type: \"spring\", stiffness: 300, damping: 30, mass: 0.8 },\n  soft: { type: \"spring\", stiffness: 200, damping: 25, mass: 1.2 },\n  settle: { type: \"spring\", stiffness: 380, damping: 30, mass: 0.8 },\n  blurIn: { type: \"spring\", stiffness: 400, damping: 28, mass: 0.6 },\n} as const;\n\nexport const ease = {\n  out: [0.23, 1, 0.32, 1],\n  inOut: [0.77, 0, 0.175, 1],\n  drawer: [0.32, 0.72, 0, 1],\n} as const;\n\nexport const easeCss = {\n  out: \"cubic-bezier(0.23,1,0.32,1)\",\n  inOut: \"cubic-bezier(0.77,0,0.175,1)\",\n  drawer: \"cubic-bezier(0.32,0.72,0,1)\",\n} as const;\n\nexport const durations = { press: 140, tooltip: 160, menu: 200, panel: 280 } as const; // ms\n\n// Cubic bezier easing for canvas and rAF code. Returns progress y for input x in [0, 1].\nexport function cubicBezier(x1: number, y1: number, x2: number, y2: number): (t: number) => number {\n  const cx = 3 * x1;\n  const bx = 3 * (x2 - x1) - cx;\n  const ax = 1 - cx - bx;\n  const cy = 3 * y1;\n  const by = 3 * (y2 - y1) - cy;\n  const ay = 1 - cy - by;\n  const sampleX = (s: number) => ((ax * s + bx) * s + cx) * s;\n  const sampleY = (s: number) => ((ay * s + by) * s + cy) * s;\n  const sampleDX = (s: number) => (3 * ax * s + 2 * bx) * s + cx;\n\n  const solveX = (x: number) => {\n    let s = x;\n    for (let i = 0; i < 8; i++) {\n      const err = sampleX(s) - x;\n      if (Math.abs(err) < 1e-6) return s;\n      const d = sampleDX(s);\n      if (Math.abs(d) < 1e-6) break;\n      s -= err / d;\n    }\n    let lo = 0;\n    let hi = 1;\n    s = x;\n    while (lo < hi) {\n      const v = sampleX(s);\n      if (Math.abs(v - x) < 1e-6) return s;\n      if (x > v) lo = s;\n      else hi = s;\n      s = (lo + hi) / 2;\n      if (hi - lo < 1e-7) break;\n    }\n    return s;\n  };\n\n  return (t: number) => {\n    if (t <= 0) return 0;\n    if (t >= 1) return 1;\n    return sampleY(solveX(t));\n  };\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/motion.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": "AI",
    "registryUrl": "https://ui.isaiahbjork.com/lattice-orb.json",
    "installCommand": "npx shadcn@latest add https://ui.isaiahbjork.com/lattice-orb.json"
  },
  "type": "registry:component"
}