{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cambium-lay",
  "title": "Cambium Lay",
  "description": "A tree cross-section accreting live in SVG: a cambium ring lays wood outward year by year, each year closing with a thin dense latewood band, the section slightly lobed and drifting off-centre like a leaning trunk, and a scar event pinching a few rings before growth heals around it.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/cambium-lay/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// CambiumLay — a tree cross-section accreting live in SVG. A 96-spoke\n// perimeter (48 shows visible polygon corners once a ring's radius passes\n// ~300px; 192 would double every path's byte size for no visible gain, so 96\n// is the floor that stays smooth) is advanced outward year by year. Within a\n// year the growth rate is asymmetric across time — fast for the first 60% of\n// the season (earlywood), then slow for the last 40% (latewood) — and the\n// two phases are BUDGETED, not just rate-shaped: earlywood is allotted 70% of\n// that year's radial width, latewood the remaining ~30%, because equal\n// halves would read as two soft bands instead of one ring closing on a dense\n// line. At the moment a year completes, its two boundary curves (start →\n// earlywood-end → final) are committed as the RADII record — a Float64Array\n// per boundary, pushed once into `boundariesRef` and never touched again as\n// DATA. That record is the accretion history and is what \"committed\" means\n// here; it is not the same claim as \"the rendered `d` never changes again\"\n// (see INTERIOR UNDULATION below, which is a fifth-pass correction of an\n// earlier version of this file that conflated the two and shipped an opacity\n// shimmer instead of real displacement). Only the CURRENT, still-forming\n// annulus mutates its own `d` independent of the interior loop, one write\n// per tick, ~8 ticks a second.\n//\n// Two long-lived deformations ride on top of the per-year budget, both\n// expressed as a per-spoke multiplier on that spoke's share of the year's\n// growth rather than as a one-time nudge to any single ring:\n//   - LEAN: a fixed-angle bias (a real cos(theta) field, not noise) whose\n//     strength ramps in gradually over the tree's first ~18 years and then\n//     holds. Because it is sustained across every subsequent year, the pith\n//     ends up visibly off-centre relative to the outer boundary — a leaning\n//     trunk's eccentric growth — without ever moving the coordinate origin.\n//   - LOBING: a fixed three-harmonic bias over theta, constant across every\n//     year, which keeps the section gently polygonal/lobed rather than a\n//     true circle.\n// A scar event (fixed at years 5–6, one ~50° arc of the 96 spokes) drives\n// that arc's growth multiplier to ~8% of normal for two years — a visible\n// pinch — then to ~190% for the following three, which is what \"healing\"\n// looks like: the wound stays as a permanent dent in the rings already laid,\n// and the rings after it round back out to rejoin the rest of the boundary.\n//\n// FALSIFIABLE BY CONSTRUCTION: every year's start boundary is literally the\n// previous year's final boundary (`start = g.final`), so lean, lobing and the\n// scar are integrated forward, never redrawn backward. A ring committed\n// before the scar is a frozen path string from that point on — it cannot be\n// touched by anything that happens in year 20. This is the opposite of\n// concentric circles with randomised radii drawn once: it is one seasonal\n// signal walked forward in time, and \"walked forward\" is exactly what makes\n// a lean or scar deform every later ring and no earlier one.\n//\n// GROWTH TARGET, so the tree doesn't grow forever or all at once: each\n// year's average radial-width budget follows a decaying taper (wide juvenile\n// rings, narrower mature ones, never below 35% of the juvenile width),\n// normalised so `maxYears` of budgets sum exactly to the drawable radius.\n// Age is persisted to localStorage as a wall-clock epoch (namespaced\n// `ns-cambium-lay:<storageKey>:first-seen`) and capped at `maxYears`, so a\n// first-time mount seeds itself partway grown (nobody's hero should open on\n// a bare dot) and a returning visitor's tree has kept aging in the\n// background rather than restarting.\n//\n// prefers-reduced-motion renders a prewarmed ~40-ring section via the exact\n// same year-closed-form used for catch-up — no timer, no localStorage, no\n// live front — so the scar is still visible at rest.\n//\n// Pure DOM + SVG + CSS, no canvas: committed rings are plain <path>\n// elements that, once appended, are never touched again — zero per-frame\n// cost. Fills read `var(--ns-muted)` / `var(--foreground)` directly, so a\n// theme flip is free and correct with no getComputedStyle re-read. The SVG\n// is aria-hidden and pointer-events:none (it is decoration, and content sits\n// above it); a single visually-hidden status line names the ring count for\n// anyone who lands on the region with a screen reader.\n//\n// Distinct from sediment-stack (linear strata accreting under gravity, no\n// season structure), ring-stain (one evaporative deposition event, nothing\n// ever un-freezes but there is no year cycle either), and\n// heatmap-year-stipple / stipple-year (data displays, not a grown form).\n// ---------------------------------------------------------------------------\n\nexport interface CambiumLayProps {\n  /** ms per virtual growth year — the only speed control. Default 4000 (~one year per 4s). */\n  yearMs?: number;\n  /** hard cap on virtual years grown, even for a very old persisted visit. Default 64. */\n  maxYears?: number;\n  /** localStorage namespace suffix, so two instances on one origin age independently. Default \"default\". */\n  storageKey?: string;\n  className?: string;\n}\n\ntype RingPaths = { earlywood: string; latewood: string };\n// TS's typed-array lib generics infer Float64Array<ArrayBuffer> from a bare\n// `new Float64Array(n)` but Float64Array<ArrayBufferLike> from some call\n// shapes (e.g. a function's declared return type) — one alias used\n// everywhere keeps every assignment between them type-compatible.\ntype Radii = Float64Array<ArrayBufferLike>;\ntype GrowCtx = { weights: Radii; weightSum: number };\n\nconst N_SPOKES = 96;\nconst TWO_PI = Math.PI * 2;\nconst VB = 240;\nconst CENTER = 120;\nconst PITH_R = 6;\nconst VIEW_R_MAX = 92; // nominal per-year budget sum; actual render is defensively clamped below this\nconst R_SAFE = 112; // never exceeded regardless of lean/lobe overshoot — VB margin, not a tuning knob\n\nconst EARLY_TIME_FRAC = 0.6; // fraction of the season spent laying earlywood\nconst EARLY_WIDTH_FRAC = 0.7; // fraction of the year's radial budget earlywood gets — latewood gets the rest (~0.3)\n\nconst MIN_TAPER = 0.35; // a mature ring never lays less than 35% of a juvenile ring's width\nconst TAPER_DECAY = 16; // years — juvenile-width half-life-ish\n\nconst LEAN_MAX = 0.16;\nconst LEAN_RAMP = 18; // years to mostly reach LEAN_MAX\nconst LEAN_ANGLE = -0.5; // rad — fixed lean direction, chosen once, never rotates\n\nconst SCAR_YEAR_START = 5;\nconst SCAR_SUPPRESS_YEARS = 2;\nconst SCAR_HEAL_YEARS = 3;\nconst SCAR_ARC_START = 12;\nconst SCAR_ARC_END = 26; // ~52 degrees of the 96 spokes\nconst SCAR_SUPPRESS_MULT = 0.08;\nconst SCAR_HEAL_MULT = 1.9;\n\nconst YEAR_MS_DEFAULT = 4000; // ms per virtual year — at 20000 the live front's per-tick radial delta was under a pixel, reading as a still frame within the few seconds a catalog card is actually judged on\nconst CAP_YEARS_DEFAULT = 64;\nconst INITIAL_YEARS = 14; // seeded age on a first-ever mount, so it never opens on a bare dot\nconst REDUCED_RINGS = 40;\nconst TICK_MS = 120; // was 333 — 3 writes/sec sampled the travelling front (below) too coarsely to read as motion; ~8/sec is still trivial cost for a 96-spoke path rebuild\n\n// The cambium doesn't lay the whole ring's width down everywhere at once —\n// growth is a front that circulates the circumference as the season runs.\n// FRONT_REVS_PER_PHASE is how many times that front sweeps fully around the\n// ring within one phase (earlywood or latewood); FRONT_LAG_FRAC is how far a\n// spoke's local progress can lead or lag the phase's mean progress while the\n// front is near or far from it. Both apply ONLY to the live, still-forming\n// boundary shown between ticks — the taper below forces the lag to exactly 0\n// at the start and end of every phase, so the two committed boundaries\n// (earlyEnd, final) that `growYear` produces are completely unaffected: this\n// reshapes how a ring visibly arrives at its real target, not the target\n// itself.\nconst FRONT_REVS_PER_PHASE = 1.4;\nconst FRONT_LAG_FRAC = 0.32;\n// reduced-motion still needs to be honest that this is a living illustration\n// — a single static frame that never changes again reads as broken, not\n// calm. Every REDUCED_RING_INTERVAL_MS it commits exactly one more whole\n// ring (a discrete pop, not an interpolated sweep — no continuous per-frame\n// boundary motion, which is what the vestibular guard is actually about),\n// so a viewer who lingers on the card for a few seconds sees the tree\n// genuinely still growing, just slowly and step-wise instead of smoothly.\nconst REDUCED_RING_INTERVAL_MS = 2200;\n\n// --- MOTION VARIANT (switchable, ship value below) --------------------\n// Three tuning passes on FRONT_REVS_PER_PHASE/FRONT_LAG_FRAC (the live,\n// still-forming boundary's per-tick wobble) each measured out at roughly\n// 0.4 viewBox units of peak excursion on the demo's actual per-year budget\n// — a fraction of a screen pixel at card scale. That is not a \"wavy effect\n// [that] doesn't work\", it is one that was never renderable, because the\n// taper deliberately forces it back to zero at every phase boundary so\n// committed rings stay untouched. These three are genuinely different\n// treatments, not further tunings of that same capped wobble:\n//   \"front-bulge\" — the pre-existing behaviour: only the live, forming\n//                   boundary ripples (frontLocalProgress), reset to 0 at\n//                   every phase start/end; every committed ring is a plain\n//                   Catmull-Rom circle-ish shape once laid.\n//   \"wavy-rings\"  — a phase-advancing per-spoke sinusoid multiplies\n//                   growYear's radial budget directly, so the wave is\n//                   COMMITTED into every ring's real geometry (never reset)\n//                   and its phase shifts year to year — successive rings\n//                   are visibly wavy relative to each other and the wave\n//                   reads as travelling outward as the tree ages. Measured\n//                   on the standalone sim (same growYear math, 96 spokes,\n//                   maxYears=64): WAVE_AMPL=0.75, WAVE_HARMONIC=5 ->\n//                   ring-boundary peak excursion ~4.3 viewBox units by\n//                   year 13, ~5-6px at typical card scale (well past the\n//                   ~3-4px visibility floor front-bulge never cleared).\n//   \"pulse-sweep\" — a decorative light ring (CSS transform+opacity, no\n//                   canvas, --foreground only) sweeping outward over the\n//                   already-committed rings on a fixed loop — no change to\n//                   any path's geometry at all.\nconst MOTION: \"front-bulge\" | \"wavy-rings\" | \"pulse-sweep\" = \"wavy-rings\";\nconst WAVE_AMPL = 0.75; // \"wavy-rings\" only — +/- fraction of that year's radial budget\nconst WAVE_HARMONIC = 5; // \"wavy-rings\" only — wave crests per full revolution; also reused as the interior undulation's harmonic (below) so both waves read as the same physical texture\nconst WAVE_PHASE_PER_YEAR = Math.PI * 0.6; // \"wavy-rings\" only — phase advance per virtual year, what makes the wave read as travelling ring to ring\n\n// --- INTERIOR UNDULATION (fifth pass — supersedes the opacity shimmer) ----\n// \"wavy-rings\" above bakes a spatial wave into each ring's geometry AT\n// COMMIT and never touches it again — that alone does not read as \"the\n// inside is moving\" because a static wavy shape is still a still frame.\n// This is a SEPARATE, per-frame effect: every committed boundary's radii\n// (the immutable accretion record in `boundariesRef`) get a small\n// additional sinusoidal offset, recomputed every animation frame, so the\n// interior rings genuinely displace — real geometry motion, not a\n// brightness change. Same harmonic as \"wavy-rings\" (reads as one texture,\n// not two competing effects), phase-shifted by BOUNDARY INDEX (not just\n// ring index — a boundary is shared between the ring inside it and the\n// ring outside it, so indexing by boundary is what keeps adjacent rings\n// seamless) and rotated by wall-clock time, so a wave crest at a fixed\n// angle visibly migrates from outer boundaries toward the pith as time\n// advances — same \"rim -> pith\" direction as the opacity shimmer it\n// replaces, now as literal displacement instead of a brightness delta.\n// The per-boundary phase STEP is kept small on purpose: absolute amplitude\n// and the differential between adjacent boundaries are different knobs —\n// a small step keeps neighbouring boundaries moving nearly in lockstep\n// (no self-intersecting annulus) while the *stack* still swings by the\n// full amplitude, and it's what makes the inward crest-migration read\n// dominate over in-place rotation (crest travels across boundary indices\n// much faster than the θ-rotation term shifts the wave azimuthally).\n// A monotonic clamp is applied outward-to-inward-first (see\n// `displaceBoundaries`) as a hard guarantee against inversion regardless\n// of amplitude tuning: every boundary's displaced radius is forced to stay\n// at least MIN_GAP past its inward neighbour's, per spoke. Amplitude ramps\n// to 0 over the outermost few boundaries so the newest committed boundary\n// — shared with the still-forming live front, which this loop does not\n// touch — never develops a seam.\n// Gated on `!reduced`: prefers-reduced-motion keeps the existing discrete\n// ring-commit progression (a pop, not a continuous sweep) as its motion —\n// see the REDUCED_RING_INTERVAL_MS comment above (\"no continuous per-frame\n// boundary motion, which is what the vestibular guard is actually about\").\n// Adding a continuous undulation on top of that would be exactly the thing\n// that comment argues against, so under reduced motion the interior stays\n// genuinely static between ring-commit pops.\nconst INTERIOR_WAVE_AMPL = 3.2; // viewBox units, absolute — the whole stack's peak radial swing\nconst INTERIOR_WAVE_PHASE_STEP = 0.11; // rad per boundary index — kept small so adjacent boundaries stay coherent\nconst INTERIOR_WAVE_OMEGA = 0.7; // rad/sec — time-rotation rate; sign convention below makes crests migrate inward as t grows\nconst INTERIOR_WAVE_RAMP_BOUNDARIES = 6; // outermost N and innermost N boundaries fade amplitude to 0 — joins the live front seamlessly on one end, keeps the pith a crisp dot on the other\nconst INTERIOR_WAVE_MIN_GAP = 0.06; // viewBox units — hard floor on the gap between adjacent displaced boundaries, prevents inversion\nconst INTERIOR_WAVE_UPDATE_HZ = 24; // throttle for the rAF-driven recompute — well past the ~8-10fps floor for smooth-reading motion, far under redoing it every frame for no visible gain\n\nconst THETAS = Array.from({ length: N_SPOKES }, (_, i) => (i / N_SPOKES) * TWO_PI);\nconst COS = THETAS.map(Math.cos);\nconst SIN = THETAS.map(Math.sin);\n// three fixed low-frequency harmonics — the section's static lobed shape,\n// identical every year, never a function of time.\nconst LOBE_BIAS = THETAS.map((t) => 0.07 * Math.sin(3 * t + 0.6) + 0.04 * Math.sin(5 * t + 2.3) + 0.025 * Math.sin(7 * t + 1.1));\n\nfunction clamp(n: number, lo: number, hi: number): number {\n  return Math.min(hi, Math.max(lo, n));\n}\n\nfunction leanBias(theta: number, year: number): number {\n  const strength = LEAN_MAX * (1 - Math.exp(-year / LEAN_RAMP));\n  return strength * Math.cos(theta - LEAN_ANGLE);\n}\n\nfunction scarMultiplier(spoke: number, year: number): number {\n  if (spoke < SCAR_ARC_START || spoke >= SCAR_ARC_END) return 1;\n  if (year >= SCAR_YEAR_START && year < SCAR_YEAR_START + SCAR_SUPPRESS_YEARS) return SCAR_SUPPRESS_MULT;\n  const healStart = SCAR_YEAR_START + SCAR_SUPPRESS_YEARS;\n  if (year >= healStart && year < healStart + SCAR_HEAL_YEARS) return SCAR_HEAL_MULT;\n  return 1;\n}\n\nfunction buildCtx(maxYears: number): GrowCtx {\n  const weights: Radii = new Float64Array(maxYears);\n  let sum = 0;\n  for (let y = 0; y < maxYears; y++) {\n    const w = MIN_TAPER + (1 - MIN_TAPER) * Math.exp(-y / TAPER_DECAY);\n    weights[y] = w;\n    sum += w;\n  }\n  return { weights, weightSum: sum || 1 };\n}\n\n/** Closed-form growth for one year: the two boundaries a committed ring needs. */\nfunction growYear(year: number, start: Radii, ctx: GrowCtx): { earlyEnd: Radii; final: Radii } {\n  const tw = ((VIEW_R_MAX - PITH_R) * (ctx.weights[year] ?? MIN_TAPER)) / ctx.weightSum;\n  const earlyBudget = tw * EARLY_WIDTH_FRAC;\n  const lateBudget = tw * (1 - EARLY_WIDTH_FRAC);\n  const earlyEnd: Radii = new Float64Array(N_SPOKES);\n  const final: Radii = new Float64Array(N_SPOKES);\n  for (let i = 0; i < N_SPOKES; i++) {\n    const mult = clamp(1 + LOBE_BIAS[i] + leanBias(THETAS[i], year), 0.2, 1.5);\n    const scar = scarMultiplier(i, year);\n    // \"wavy-rings\" only: a phase-advancing sinusoid on top of the budget,\n    // never clamped through the lobe/lean 0.2-1.5 range (it would fight the\n    // lobe/lean headroom there and flatten its own peaks) — applied as its\n    // own multiplier so it is COMMITTED into earlyEnd/final, unlike the\n    // live-front wobble below which is forced to 0 at every phase boundary.\n    const wave =\n      MOTION === \"wavy-rings\" ? 1 + WAVE_AMPL * Math.sin(WAVE_HARMONIC * THETAS[i] + year * WAVE_PHASE_PER_YEAR) : 1;\n    const e = start[i] + earlyBudget * mult * scar * wave;\n    earlyEnd[i] = e;\n    final[i] = e + lateBudget * mult * scar * wave;\n  }\n  return { earlyEnd, final };\n}\n\n/**\n * Per-spoke local progress within a phase, given the phase's mean progress\n * `p` (0..1). The front's azimuth sweeps FRONT_REVS_PER_PHASE times around\n * the ring as `p` goes 0 -> 1; a spoke near the front's current azimuth is\n * running slightly AHEAD of the mean (already at this instant's leading\n * edge), one near the opposite azimuth slightly BEHIND (still settling into\n * place before the front reaches it again). `taper` is 0 at p=0 and p=1 by\n * construction, so every spoke lands exactly on the phase's real endpoint\n * regardless of the wave — only the path it takes to get there bulges.\n */\nfunction frontLocalProgress(p: number, theta: number): number {\n  const clampedP = clamp(p, 0, 1);\n  // Only \"front-bulge\" carries this wobble — for the other two variants it\n  // would be an invisible, uncredited fourth effect riding under whichever\n  // one is actually being judged, so a fair A/B needs it isolated to its\n  // own variant.\n  if (MOTION !== \"front-bulge\") return clampedP;\n  const frontAngle = FRONT_REVS_PER_PHASE * TWO_PI * clampedP;\n  const taper = Math.sin(Math.PI * clampedP);\n  const lag = FRONT_LAG_FRAC * taper * Math.cos(frontAngle - theta);\n  return clamp(clampedP + lag, 0, 1);\n}\n\n/** Interpolation of the live, still-forming boundary within the current year, given u = season fraction elapsed. Each spoke rides its own front-relative progress (see frontLocalProgress) rather than a single shared fraction, so the boundary that's currently accreting visibly bulges and travels around the ring as it forms, instead of the whole edge advancing in lockstep. */\nfunction currentBoundary(u: number, start: Radii, earlyEnd: Radii, final: Radii): Radii {\n  const out: Radii = new Float64Array(N_SPOKES);\n  if (u <= EARLY_TIME_FRAC) {\n    const p = u / EARLY_TIME_FRAC;\n    for (let i = 0; i < N_SPOKES; i++) {\n      const pi = frontLocalProgress(p, THETAS[i]!);\n      out[i] = start[i] + (earlyEnd[i] - start[i]) * pi;\n    }\n  } else {\n    const p = (u - EARLY_TIME_FRAC) / (1 - EARLY_TIME_FRAC);\n    for (let i = 0; i < N_SPOKES; i++) {\n      const pi = frontLocalProgress(p, THETAS[i]!);\n      out[i] = earlyEnd[i] + (final[i] - earlyEnd[i]) * pi;\n    }\n  }\n  return out;\n}\n\nfunction toPoints(radius: Radii): { x: number; y: number }[] {\n  const pts = new Array<{ x: number; y: number }>(N_SPOKES);\n  for (let i = 0; i < N_SPOKES; i++) {\n    const r = Math.min(radius[i], R_SAFE);\n    pts[i] = { x: CENTER + r * COS[i], y: CENTER + r * SIN[i] };\n  }\n  return pts;\n}\n\n/** Closed Catmull-Rom smoothing through `pts`, as cubic beziers. */\nfunction smoothClosedPath(pts: { x: number; y: number }[]): string {\n  const n = pts.length;\n  if (n < 3) return \"\";\n  const p0 = pts[0];\n  let d = `M ${p0.x.toFixed(2)} ${p0.y.toFixed(2)}`;\n  for (let i = 0; i < n; i++) {\n    const a = pts[(i - 1 + n) % n];\n    const b = pts[i];\n    const c = pts[(i + 1) % n];\n    const e = pts[(i + 2) % n];\n    const c1x = b.x + (c.x - a.x) / 6;\n    const c1y = b.y + (c.y - a.y) / 6;\n    const c2x = c.x - (e.x - b.x) / 6;\n    const c2y = c.y - (e.y - b.y) / 6;\n    d += ` C ${c1x.toFixed(2)} ${c1y.toFixed(2)}, ${c2x.toFixed(2)} ${c2y.toFixed(2)}, ${c.x.toFixed(2)} ${c.y.toFixed(2)}`;\n  }\n  return `${d} Z`;\n}\n\nfunction buildRingPaths(start: Radii, earlyEnd: Radii, final: Radii): RingPaths {\n  const sPts = toPoints(start);\n  const ePts = toPoints(earlyEnd);\n  const fPts = toPoints(final);\n  return {\n    earlywood: `${smoothClosedPath(sPts)} ${smoothClosedPath(ePts)}`,\n    latewood: `${smoothClosedPath(ePts)} ${smoothClosedPath(fPts)}`,\n  };\n}\n\nfunction simulateYears(n: number, ctx: GrowCtx): { rings: RingPaths[]; start: Radii; boundaries: Radii[] } {\n  let start: Radii = new Float64Array(N_SPOKES).fill(PITH_R);\n  const rings: RingPaths[] = [];\n  const boundaries: Radii[] = [start];\n  for (let y = 0; y < n; y++) {\n    const g = growYear(y, start, ctx);\n    rings.push(buildRingPaths(start, g.earlyEnd, g.final));\n    boundaries.push(g.earlyEnd, g.final);\n    start = g.final;\n  }\n  return { rings, start, boundaries };\n}\n\n/**\n * Per-frame displacement pass over the committed boundary stack — see the\n * INTERIOR UNDULATION block above for why this exists and how the constants\n * were chosen. `boundaries[0]` is the pith, `boundaries[k]` for k >= 1\n * alternates earlywood-end/final radii walking outward; a ring at index i\n * sits between `boundaries[2i]` and `boundaries[2i+2]`, sharing each edge\n * with its neighbour, so displacing by boundary index (not ring index) is\n * what keeps rings joined with no gap. Returns one smoothed SVG path\n * fragment per boundary — callers slice adjacent pairs to build a ring's\n * two annulus `d` strings.\n */\nfunction displaceBoundaries(boundaries: Radii[], t: number): string[] {\n  const n = boundaries.length;\n  const displaced: Radii[] = new Array(n);\n  for (let k = 0; k < n; k++) {\n    const distFromFront = n - 1 - k;\n    const rampOut = distFromFront >= INTERIOR_WAVE_RAMP_BOUNDARIES ? 1 : distFromFront / INTERIOR_WAVE_RAMP_BOUNDARIES;\n    // Symmetric ramp on the pith side too: boundary 0 IS the pith (a\n    // constant-radius circle, sitting right next to the separately-drawn\n    // solid pith dot), and a flat INTERIOR_WAVE_AMPL there is enormous\n    // relative to its ~6-unit radius — measured result was a 5-petal\n    // rosette where a crisp dot should be, because the wave's angular\n    // frequency (WAVE_HARMONIC=5) reads as scalloping once amplitude\n    // approaches the shape's own radius. Radius-proportional scaling alone\n    // does not fix this (it scales the distortion down but the RATIO, and\n    // therefore the rosette shape, stays the same at every radius) — an\n    // index ramp identical in kind to the outer one is what a real fix\n    // needs: the first few boundaries stay essentially undisplaced and the\n    // wave fades in only once there's enough ring stack for it to read as\n    // undulation rather than a shape change.\n    const rampIn = k >= INTERIOR_WAVE_RAMP_BOUNDARIES ? 1 : k / INTERIOR_WAVE_RAMP_BOUNDARIES;\n    const ampl = INTERIOR_WAVE_AMPL * rampOut * rampIn;\n    const b = boundaries[k];\n    const out: Radii = new Float64Array(N_SPOKES);\n    const prev = k > 0 ? displaced[k - 1] : null;\n    for (let i = 0; i < N_SPOKES; i++) {\n      const wave = ampl * Math.sin(WAVE_HARMONIC * THETAS[i] + k * INTERIOR_WAVE_PHASE_STEP + t * INTERIOR_WAVE_OMEGA);\n      let v = b[i] + wave;\n      if (prev) v = Math.max(v, prev[i] + INTERIOR_WAVE_MIN_GAP);\n      out[i] = Math.min(v, R_SAFE);\n    }\n    displaced[k] = out;\n  }\n  const paths = new Array<string>(n);\n  for (let k = 0; k < n; k++) paths[k] = smoothClosedPath(toPoints(displaced[k]));\n  return paths;\n}\n\nconst CSS = `\n.ns-cl-live{fill:var(--ns-muted);transition:fill 900ms ease}\n.ns-cl-live.ns-cl-late{fill:var(--foreground)}\n@keyframes ns-cl-pulse-sweep{\n  0%{transform:scale(0.05);opacity:0.55}\n  85%{opacity:0}\n  100%{transform:scale(1);opacity:0}\n}\n.ns-cl-pulse{\n  transform-box:fill-box;\n  transform-origin:center;\n  animation:ns-cl-pulse-sweep 3200ms linear infinite;\n}\n@media (prefers-reduced-motion: reduce){\n  .ns-cl-live{transition:none}\n  .ns-cl-pulse{animation-duration:9000ms}\n}\n`;\n\nexport function CambiumLay({\n  yearMs = YEAR_MS_DEFAULT,\n  maxYears = CAP_YEARS_DEFAULT,\n  storageKey = \"default\",\n  className = \"\",\n}: CambiumLayProps) {\n  const [reduced, setReduced] = useState(false);\n  const [rings, setRings] = useState<RingPaths[]>([]);\n\n  const liveRef = useRef<SVGPathElement | null>(null);\n  const startRef = useRef<Radii>(new Float64Array(N_SPOKES).fill(PITH_R));\n  const targetRef = useRef<{ earlyEnd: Radii; final: Radii } | null>(null);\n  const lastYearRef = useRef(-1);\n  const lateRef = useRef(false);\n\n  // The immutable accretion record the interior undulation displaces from —\n  // see displaceBoundaries above. Index 0 is the pith; index k for k >= 1\n  // alternates earlywood-end/final radii walking outward. Ref, not state:\n  // it changes on the same cadence as `rings` but is read every animation\n  // frame, so it must not go through React's render cycle.\n  const boundariesRef = useRef<Radii[]>([new Float64Array(N_SPOKES).fill(PITH_R)]);\n  const earlyPathRefs = useRef<(SVGPathElement | null)[]>([]);\n  const latePathRefs = useRef<(SVGPathElement | null)[]>([]);\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    setReduced(mq.matches);\n    const onChange = () => setReduced(mq.matches);\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n\n  useEffect(() => {\n    const safeMaxYears = Math.max(1, Math.floor(maxYears));\n    const ctx = buildCtx(safeMaxYears);\n\n    if (reduced) {\n      let n = Math.min(REDUCED_RINGS, safeMaxYears);\n      const { rings: pre, start: preStart, boundaries } = simulateYears(n, ctx);\n      setRings(pre);\n      boundariesRef.current = boundaries; // static under reduced motion — the interior loop never runs (see gate below)\n      if (n >= safeMaxYears) return; // already fully grown at this cap — genuinely nothing left to show\n\n      let start = preStart;\n      const id = window.setInterval(() => {\n        if (n >= safeMaxYears) {\n          window.clearInterval(id);\n          return;\n        }\n        const g = growYear(n, start, ctx);\n        const ring = buildRingPaths(start, g.earlyEnd, g.final);\n        start = g.final;\n        n += 1;\n        setRings((prev) => prev.concat([ring]));\n        boundariesRef.current = boundariesRef.current.concat([g.earlyEnd, g.final]);\n      }, REDUCED_RING_INTERVAL_MS);\n      return () => window.clearInterval(id);\n    }\n\n    const capMs = safeMaxYears * yearMs;\n    const key = `ns-cambium-lay:${storageKey}:first-seen`;\n    let firstSeen = 0;\n    try {\n      const raw = window.localStorage.getItem(key);\n      firstSeen = raw ? Number(raw) : 0;\n    } catch {\n      firstSeen = 0;\n    }\n    if (!firstSeen || Number.isNaN(firstSeen)) {\n      firstSeen = Date.now() - Math.min(INITIAL_YEARS, safeMaxYears) * yearMs;\n      try {\n        window.localStorage.setItem(key, String(firstSeen));\n      } catch {\n        // storage unavailable (private mode, quota) — the tree just starts young this visit\n      }\n    }\n\n    let start: Radii = new Float64Array(N_SPOKES).fill(PITH_R);\n    const committed: RingPaths[] = [];\n    const boundaries: Radii[] = [start];\n    const elapsedAtMount = Math.min(Date.now() - firstSeen, capMs);\n    const wholeYears = Math.min(Math.floor(elapsedAtMount / yearMs), safeMaxYears);\n    for (let y = 0; y < wholeYears; y++) {\n      const g = growYear(y, start, ctx);\n      committed.push(buildRingPaths(start, g.earlyEnd, g.final));\n      boundaries.push(g.earlyEnd, g.final);\n      start = g.final;\n    }\n    setRings(committed);\n    boundariesRef.current = boundaries;\n    startRef.current = start;\n    lastYearRef.current = wholeYears - 1;\n    lateRef.current = false;\n    liveRef.current?.classList.remove(\"ns-cl-late\");\n\n    if (wholeYears >= safeMaxYears) return; // fully grown for this visit — no live front\n\n    targetRef.current = growYear(wholeYears, start, ctx);\n\n    const tick = () => {\n      const elapsed = Math.min(Date.now() - firstSeen, capMs);\n      const yIdx = Math.min(Math.floor(elapsed / yearMs), safeMaxYears);\n\n      if (yIdx > lastYearRef.current) {\n        const newRings: RingPaths[] = [];\n        const newBoundaries: Radii[] = [];\n        let s = startRef.current;\n        for (let y = lastYearRef.current + 1; y < yIdx && y < safeMaxYears; y++) {\n          const g = growYear(y, s, ctx);\n          newRings.push(buildRingPaths(s, g.earlyEnd, g.final));\n          newBoundaries.push(g.earlyEnd, g.final);\n          s = g.final;\n        }\n        startRef.current = s;\n        lastYearRef.current = yIdx - 1;\n        if (newRings.length) {\n          setRings((prev) => prev.concat(newRings));\n          boundariesRef.current = boundariesRef.current.concat(newBoundaries);\n        }\n        if (yIdx < safeMaxYears) {\n          targetRef.current = growYear(yIdx, s, ctx);\n          lateRef.current = false;\n          liveRef.current?.classList.remove(\"ns-cl-late\");\n        }\n      }\n\n      if (yIdx >= safeMaxYears) {\n        window.clearInterval(id);\n        return;\n      }\n\n      const target = targetRef.current;\n      if (!target) return;\n      const u = (elapsed % yearMs) / yearMs;\n      const isLate = u > EARLY_TIME_FRAC;\n      if (isLate !== lateRef.current) {\n        lateRef.current = isLate;\n        liveRef.current?.classList.toggle(\"ns-cl-late\", isLate);\n      }\n      const bound = currentBoundary(u, startRef.current, target.earlyEnd, target.final);\n      const d = `${smoothClosedPath(toPoints(startRef.current))} ${smoothClosedPath(toPoints(bound))}`;\n      liveRef.current?.setAttribute(\"d\", d);\n    };\n\n    tick();\n    const id = window.setInterval(tick, TICK_MS);\n    return () => window.clearInterval(id);\n  }, [reduced, yearMs, maxYears, storageKey]);\n\n  const ringCount = rings.length;\n  const showLive = !reduced && ringCount < Math.max(1, Math.floor(maxYears));\n\n  // Paint committed rings with a correct (undisplaced) `d` synchronously on\n  // every ring-count change, before the browser paints — otherwise a newly\n  // mounted <path> would render with no `d` attribute at all (a genuinely\n  // blank shape) for however long it takes the rAF loop below to run its\n  // first frame. This is the ONLY writer of `d` when `reduced` is true,\n  // since the interior loop is gated off in that mode.\n  useLayoutEffect(() => {\n    const boundaries = boundariesRef.current;\n    const n = boundaries.length;\n    if (n < 3) return;\n    const smoothed = new Array<string>(n);\n    for (let k = 0; k < n; k++) smoothed[k] = smoothClosedPath(toPoints(boundaries[k]));\n    const rc = Math.floor((n - 1) / 2);\n    for (let i = 0; i < rc; i++) {\n      earlyPathRefs.current[i]?.setAttribute(\"d\", `${smoothed[2 * i]} ${smoothed[2 * i + 1]}`);\n      latePathRefs.current[i]?.setAttribute(\"d\", `${smoothed[2 * i + 1]} ${smoothed[2 * i + 2]}`);\n    }\n  }, [ringCount]);\n\n  // The interior undulation itself — one rAF loop, throttled to\n  // INTERIOR_WAVE_UPDATE_HZ, reading boundariesRef fresh every frame so it\n  // always displaces from whatever has actually been committed so far\n  // (never stale). Writes `d` directly via refs, bypassing React state —\n  // see the useLayoutEffect above for why JSX never carries a `d` prop for\n  // committed rings when `!reduced`: if it did, the next unrelated re-render\n  // (a ring commit, a prop change) would snap every displaced path back to\n  // its undisplaced shape, because React would re-assert the JSX value.\n  useEffect(() => {\n    if (reduced) return; // see INTERIOR UNDULATION comment above — reduced motion keeps the discrete ring-commit pop only\n    let raf = 0;\n    let running = true;\n    const startedAt = performance.now();\n    const minFrameMs = 1000 / INTERIOR_WAVE_UPDATE_HZ;\n    let lastFrameAt = 0;\n    const step = (now: number) => {\n      if (!running) return;\n      if (now - lastFrameAt >= minFrameMs) {\n        lastFrameAt = now;\n        const t = (now - startedAt) / 1000;\n        const boundaries = boundariesRef.current;\n        const n = boundaries.length;\n        if (n >= 3) {\n          const smoothed = displaceBoundaries(boundaries, t);\n          const rc = Math.floor((n - 1) / 2);\n          for (let i = 0; i < rc; i++) {\n            earlyPathRefs.current[i]?.setAttribute(\"d\", `${smoothed[2 * i]} ${smoothed[2 * i + 1]}`);\n            latePathRefs.current[i]?.setAttribute(\"d\", `${smoothed[2 * i + 1]} ${smoothed[2 * i + 2]}`);\n          }\n        }\n      }\n      raf = window.requestAnimationFrame(step);\n    };\n    raf = window.requestAnimationFrame(step);\n    return () => {\n      running = false;\n      window.cancelAnimationFrame(raf);\n    };\n  }, [reduced]);\n\n  return (\n    <div className={`relative h-full w-full overflow-hidden ${className}`} data-cambium-lay>\n      <style>{CSS}</style>\n      <svg\n        viewBox={`0 0 ${VB} ${VB}`}\n        width=\"100%\"\n        height=\"100%\"\n        preserveAspectRatio=\"xMidYMid meet\"\n        aria-hidden=\"true\"\n        focusable=\"false\"\n        className=\"pointer-events-none block h-full w-full\"\n      >\n        <circle cx={CENTER} cy={CENTER} r={PITH_R * 0.55} fill=\"var(--foreground)\" />\n        {rings.map((r, i) => (\n          <g key={i}>\n            <path\n              ref={(el) => {\n                earlyPathRefs.current[i] = el;\n              }}\n              d={reduced ? r.earlywood : undefined}\n              fill=\"var(--ns-muted)\"\n              fillRule=\"evenodd\"\n            />\n            <path\n              ref={(el) => {\n                latePathRefs.current[i] = el;\n              }}\n              d={reduced ? r.latewood : undefined}\n              fill=\"var(--foreground)\"\n              fillRule=\"evenodd\"\n            />\n          </g>\n        ))}\n        {showLive && <path ref={liveRef} className=\"ns-cl-live\" fillRule=\"evenodd\" d=\"\" />}\n        {MOTION === \"pulse-sweep\" && (\n          <circle\n            className=\"ns-cl-pulse\"\n            cx={CENTER}\n            cy={CENTER}\n            r={R_SAFE}\n            fill=\"none\"\n            stroke=\"var(--foreground)\"\n            strokeWidth={2}\n          />\n        )}\n      </svg>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        decorative tree-ring illustration, {ringCount} ring{ringCount === 1 ? \"\" : \"s\"}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/cambium-lay.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "background",
      "svg",
      "tree-rings",
      "generative",
      "growth",
      "accretion",
      "dendrochronology",
      "ambient",
      "hero"
    ],
    "instruction": "Build <CambiumLay yearMs? maxYears? storageKey? className?> as a full-bleed aria-hidden SVG (viewBox 0 0 240 240, pointer-events:none) over a 96-spoke radial perimeter (spoke count matters: 48 shows visible polygon corners once a ring's radius passes ~300px on screen, 192 would double every committed path's byte size for no visible gain, 96 is the floor that stays smooth). GROWTH LAW: within a virtual year (length yearMs, default 4000 — the only speed control), the season is split into an earlywood phase (the first 60% of the year's TIME) and a latewood phase (the last 40%), and each phase is given a fixed BUDGET share of that year's total radial-width target — earlywood 70%, latewood ~30% — never an equal split, because equal halves read as two soft bands instead of one ring closing on a dense line. The per-year radial-width target itself follows a decaying taper across `maxYears` (default 64): wide juvenile rings, narrowing toward a floor of 35% of the juvenile width, normalised so all `maxYears` targets sum exactly to the drawable radius (92 local units from a 6-unit pith). PER-SPOKE DEFORMATION: every spoke's share of a year's budget is scaled by a multiplier = clamp(1 + lobeBias(theta) + leanBias(theta, year), 0.2, 1.5). lobeBias is a fixed three-harmonic sum over theta (0.07*sin(3θ+0.6) + 0.04*sin(5θ+2.3) + 0.025*sin(7θ+1.1)), identical every year — the section's static lobed silhouette. leanBias(theta, year) = 0.16 * (1 - exp(-year/18)) * cos(theta - LEAN_ANGLE), a bias whose STRENGTH ramps in over the tree's first ~18 years and then holds at a fixed angle forever — because it never rotates and is sustained across every subsequent year, its cumulative effect is a pith that ends up visibly off-centre relative to the outer boundary, exactly what a leaning trunk's eccentric growth looks like, without ever moving the SVG's coordinate origin. SCAR: spokes 12-25 of the 96 (about a 50-degree arc) get their multiplier hard-overridden — 8% of normal for years 5-6 (the pinch), then 190% of normal for years 7-9 (the heal, catching that arc's radius back up toward its neighbours) — fixed years, not randomised, so the same demonstration replays every mount. COMMIT / LIVE SPLIT (why this cannot be canvas): a year's boundaries (start, earlywood-end, final radius per spoke) are a pure closed-form function of (year, start-boundary) — no simulation loop needed — so the moment a year completes, its two annuli (start→earlywoodEnd filled var(--ns-muted), earlywoodEnd→final filled var(--foreground), both fillRule=evenodd, boundaries smoothed through the 96 points with a CLOSED Catmull-Rom curve converted to cubic beziers) are pushed once into React state as two new <path> elements and never touched again — zero per-frame cost, real static DOM. Only the CURRENT, uncommitted year's outer boundary path mutates, on a plain setInterval at ~333ms (~3 attribute writes/sec): its `d` is recomputed by linearly interpolating between the year's start and its closed-form earlywood-end (or earlywood-end and final, once time crosses the 60% mark) at u = (elapsed-time-in-year / yearMs), and a CSS class toggle (not a per-tick style write) swaps its fill from var(--ns-muted) to var(--foreground) the moment it crosses into the latewood phase, so the forming ring visibly darkens right before it commits. PERSISTENCE: age is wall-clock, not foreground time — first mount writes Date.now() minus 14 virtual years to localStorage key `ns-cambium-lay:<storageKey>:first-seen` (so it never opens on a bare pith dot), every mount after that reads the same key and computes elapsed = min(Date.now() - firstSeen, maxYears * yearMs), so a returning visitor's tree has kept aging in the background and a sufficiently old visit is simply capped, fully grown, with no live front at all. FALSIFIABLE CONSTRUCTION: every year's start boundary is literally the previous year's final boundary (start = g.final), so lean, lobing and the scar are integrated FORWARD through the whole subsequent history and a ring committed before the scar is a frozen path string that year 20's math cannot reach — this is a live seasonal signal walked forward, not concentric circles with randomised radii drawn once. REDUCED MOTION: prefers-reduced-motion (checked live via a matchMedia listener) skips localStorage and the interval entirely and instead runs the exact same year-closed-form for a fixed 40 years up front, rendering a prewarmed static section — same taper, same lean, same scar — with no live front. INTERIOR UNDULATION (fifth pass — real geometry displacement, not a brightness effect; supersedes an earlier opacity-shimmer version of this component that moved no geometry at all): every committed boundary's radii (the immutable per-spoke Float64Array pushed once at commit — that record itself never changes, and is the actual falsifiable \"committed once\" claim now) get an additional per-frame sinusoidal offset, same harmonic as the geometry-baked \"wavy-rings\" wave (WAVE_HARMONIC=5) so both read as one texture, phase-shifted by BOUNDARY INDEX (not ring index — a boundary is shared between the ring inside it and the ring outside it, so indexing by boundary keeps adjacent rings seamless) and rotated by wall-clock time at INTERIOR_WAVE_OMEGA rad/sec, so a wave crest at a fixed angle visibly migrates from the outer boundaries toward the pith as time advances (same rim-to-pith direction the shimmer it replaces used, now as literal pixel displacement, measured ~4-5px at typical card scale over 3s on an interior ring). The per-boundary phase step is kept small (0.11rad) so adjacent boundaries move almost in lockstep — absolute amplitude and the differential between neighbours are different knobs, and a small differential is what keeps every annulus from self-intersecting regardless of the absolute swing. Amplitude ramps linearly to 0 over both the outermost ~6 boundaries (so the newest committed boundary, shared with the still-forming live front which this loop never touches, joins it with no seam) and the innermost ~6 (so the pith, drawn as a separate static dot right next to it, stays a crisp dot instead of a rosette — flat amplitude near a ~6-unit-radius boundary reads as scalloping, not undulation). A monotonic clamp (each displaced boundary held at least a fixed gap past its inward neighbour, per spoke) is a hard guarantee against inversion independent of amplitude tuning. Runs on a throttled requestAnimationFrame loop (24Hz), writing `d` directly via refs — the JSX for a committed ring carries no `d` prop at all while this loop is active, specifically so an unrelated re-render (a new ring committing) cannot snap a displaced path back to its undisplaced shape by re-asserting a stale JSX value. GATED OFF under prefers-reduced-motion: reduced motion keeps only the existing discrete ring-commit progression (a pop every REDUCED_RING_INTERVAL_MS, not a continuous sweep) as its motion, consistent with this component's own stated vestibular-guard reasoning for that interval — a continuous per-frame boundary sweep is exactly what that reasoning argues against, so under reduced motion the interior stays genuinely static between ring-commit pops rather than running a slowed-down version of this loop. ACCESSIBILITY: the SVG carries aria-hidden and pointer-events-none; one visually-hidden `role=status aria-live=polite` line ('decorative tree-ring illustration, N rings') reports the current committed count for anyone who lands on the region with a screen reader, updated only on the rare ring-commit, never per-tick. No colour literal anywhere — every fill is var(--ns-muted) or var(--foreground) directly on the SVG presentation attribute, so a theme flip needs no getComputedStyle and no re-render. Props: yearMs (ms per virtual year, default 4000), maxYears (hard growth cap, default 64), storageKey (localStorage namespace suffix so two instances age independently, default 'default'), className."
  },
  "type": "registry:ui"
}