{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "slate-gauge",
  "title": "Slate Gauge",
  "description": "A testimonial wall built as real double-lap slating: each quote is a slate hung on a batten, and only its computed gauge — the exposed margin — is ever visible at rest. The wall's geometry never changes; the only motion is one slate lifting on its nail to read.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/slate-gauge/component.tsx",
      "content": "\"use client\";\n\nimport { useLayoutEffect, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// SlateGauge — a testimonial wall built as double-lap slating. The wall's\n// geometry never changes: five diminishing courses of slates are hung so\n// each course laps the one below it, and the only part of any slate that is\n// ever visible at rest is its computed GAUGE — the exposed margin a slater\n// derives from the slate's own height, never chosen by hand:\n//\n//   gauge = (height - lap - head allowance) / 2\n//\n// Slates are rendered as absolutely positioned <button aria-expanded> shells\n// (the click target) each wrapping an <article> (the visual face). At rest\n// every course's article is exactly its own GAUGE tall — courses are laid\n// edge to edge with no overlap, so the wall never needs a clip-path to hide\n// the covered nine-tenths of each slate: it simply never paints it. Lifting\n// a slate grows that one article, and only that one, from gauge to its true\n// height and raises it above its neighbours (z-index), which is what reveals\n// the rest of the quote and is also the only thing that ever overlaps in\n// this layout. The quote text itself is never display:none at any height —\n// the SHORT box just clips it, so a screen reader, Ctrl-F and text selection\n// still reach the full sentence whether or not it is currently showing.\n//\n// Reading a slate rotates it about its NAIL LINE (18px below its head, i.e.\n// near-top) to -34deg on an underdamped spring (k=210, c=19 — the spec's own\n// physical numbers, not a hand-tuned easing curve), which is what produces\n// both the fixed -34deg rest angle AND the single ~3deg overshoot as an\n// emergent property of the spring rather than a hardcoded keyframe list; the\n// same spring relaxes back to 0 on release, so \"lift\" and \"return\" are one\n// mechanism read in two directions. Only one slate is ever lifted — lifting\n// a second sets both aria-expanded values in the same state commit.\n//\n// TWO unforced processes keep the wall alive at rest without the wall's own\n// layout ever moving (BUILDER's alive-at-rest test, and DECISIONS D3's ban on\n// a moving band leaving its wake as a dead still image, though this\n// component's band is a light/wind field over a STATIC lattice rather than a\n// process it matures, so there is no \"mature region\" to freeze):\n//   1. An idle gust 190px wide sweeps left-to-right on a 4.8s loop. A\n//      deterministic 3-in-17 subset of slates is centre-nailed; whenever a\n//      centre-nailed slate's tail sits inside the gust it lifts 2.5-5px on\n//      its own short underdamped spring and clatters back.\n//   2. A light azimuth swings +-14deg on a 22s cosine. It never touches any\n//      slate's own face luminance — only the length and side of the cast\n//      shadow at the top of the course below, via ONE root-level CSS custom\n//      property every slate's box-shadow reads, so the whole wall's relief\n//      inverts together without touching N individual DOM nodes per frame.\n// ---------------------------------------------------------------------------\n\nexport interface SlateTestimonial {\n  id: string;\n  quote: string;\n  name: string;\n  role: string;\n}\n\nexport interface SlateGaugeProps {\n  /** Testimonials, filled row-major (course 1 left-to-right, then course 2, ...). */\n  items?: SlateTestimonial[];\n  /** Slates per course. @default 3 */\n  columns?: number;\n  /** Heading rendered above the wall. */\n  heading?: string;\n  className?: string;\n}\n\n// ---- geometry: exactly the spec's course table ---------------------------\n// gauge = (h - LAP - HEAD) / 2, diminishing ~0.915x per course.\nconst LAP = 20;\nconst HEAD = 8;\nconst COURSE_H = [132, 120, 110, 101, 94];\n\n// Type metrics, in the same unscaled design units as the course table — the\n// quote is rendered at FONT_PX * scale with LINE_RATIO leading and PAD_Y *\n// scale of padding, so a course's exposure can be expressed here once and\n// then simply multiplied by the fit scale like every other box dimension.\nconst FONT_PX = 10;\nconst LINE_RATIO = 1.36;\nconst LINE_PX = FONT_PX * LINE_RATIO;\nconst PAD_Y = 6;\n\n// A course's EXPOSURE is its computed gauge quantised down to a whole number\n// of text lines. The gauge is what the slater derives; the quantisation is\n// what stops the lap from falling through the middle of a line of type, which\n// is the difference between reading as a slate lapping its neighbour and\n// reading as a sentence that got cut off.\nfunction exposureFor(gauge: number): number {\n  // Only the HEAD padding is inside the exposure. Counting the foot padding\n  // too left exactly one padding's worth of the next line showing through the\n  // bottom of the strip — a sliced half-line of attribution, which is the one\n  // thing that reads as broken rather than as lapped.\n  const lines = Math.max(2, Math.floor((gauge - PAD_Y) / LINE_PX));\n  return PAD_Y + lines * LINE_PX;\n}\n\nconst COURSES = COURSE_H.map((h) => {\n  const gauge = (h - LAP - HEAD) / 2;\n  return { h, gauge, exposure: exposureFor(gauge) };\n});\nconst SLATE_W = 168;\nconst COL_OFFSET = SLATE_W / 2; // half a slate — breaks the side lap\nconst NAIL_Y = 18; // rotation origin, below the head\nconst MIN_HIT = 44; // WCAG minimum, padded upward into transparent space only\nconst SCALE_MIN = MIN_HIT / Math.min(...COURSES.map((c) => c.h)); // floor: smallest course never drops under 44px\nconst SCALE_MAX = 1.6;\n\nconst GUST_PERIOD = 4.8; // s — spec's 260px/s at the 1200px reference wall\nconst GUST_WIDTH = 190; // px\nconst AZIMUTH_PERIOD = 22.0; // s\nconst AZIMUTH_DEG = 14;\n// reduced-motion freeze frame is spec'd at t=7.40s; the composed values below\n// (azimuth -6deg, one slate at max gust-lift, one fully read) are that frame\n// baked in directly rather than evaluated from the continuous functions, so\n// the still is exact and never depends on the (paused) clock.\n\nconst LIFT_TARGET = -34; // deg\nconst LIFT_K = 210;\nconst LIFT_C = 19;\nconst GUST_LIFT_K = 1200;\nconst GUST_LIFT_C = 24;\n\nfunction isCentreNailed(index: number) {\n  return index % 17 < 3;\n}\n\n// deterministic 0..1 hash, seeded by index — used for riven banding and gust\n// lift amplitude so both are byte-stable across renders without a stored RNG\nfunction hash(seed: number): number {\n  let x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;\n  x -= Math.floor(x);\n  return x;\n}\n\nfunction springStep(\n  value: number,\n  velocity: number,\n  target: number,\n  k: number,\n  c: number,\n  dt: number\n): [number, number] {\n  const accel = -k * (value - target) - c * velocity;\n  const nv = velocity + accel * dt;\n  return [value + nv * dt, nv];\n}\n\n// ---- token colour derivation (5 tokens only, HSL-lightness math) ---------\n\nfunction hexToRgb(hex: string): [number, number, number] {\n  const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());\n  if (!m) return [128, 128, 128];\n  let h = m[1];\n  if (h.length === 3) h = h.split(\"\").map((c) => c + c).join(\"\");\n  const n = parseInt(h, 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\nfunction rgbToHsl(r: number, g: number, b: number): [number, number, number] {\n  r /= 255;\n  g /= 255;\n  b /= 255;\n  const max = Math.max(r, g, b);\n  const min = Math.min(r, g, b);\n  let h = 0;\n  let s = 0;\n  const l = (max + min) / 2;\n  const d = max - min;\n  if (d !== 0) {\n    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);\n    switch (max) {\n      case r:\n        h = (g - b) / d + (g < b ? 6 : 0);\n        break;\n      case g:\n        h = (b - r) / d + 2;\n        break;\n      default:\n        h = (r - g) / d + 4;\n    }\n    h /= 6;\n  }\n  return [h, s, l];\n}\n\nfunction hslToRgb(h: number, s: number, l: number): [number, number, number] {\n  if (s === 0) {\n    const v = Math.round(l * 255);\n    return [v, v, v];\n  }\n  const q = l < 0.5 ? l * (1 + s) : l + s - l * s;\n  const p = 2 * l - q;\n  const hue2rgb = (t0: number) => {\n    let t = t0;\n    if (t < 0) t += 1;\n    if (t > 1) t -= 1;\n    if (t < 1 / 6) return p + (q - p) * 6 * t;\n    if (t < 1 / 2) return q;\n    if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;\n    return p;\n  };\n  return [\n    Math.round(hue2rgb(h + 1 / 3) * 255),\n    Math.round(hue2rgb(h) * 255),\n    Math.round(hue2rgb(h - 1 / 3) * 255),\n  ];\n}\n\nfunction rgbToHex(r: number, g: number, b: number): string {\n  const c = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, \"0\");\n  return `#${c(r)}${c(g)}${c(b)}`;\n}\n\n/** Reuses a token's own hue/saturation but forces a target HSL lightness —\n * every derived colour below still traces back to a token read, never a\n * literal, but section 6's L values are what actually place it. */\nfunction withLightness(hex: string, l: number): string {\n  const [r, g, b] = hexToRgb(hex);\n  const [h, s] = rgbToHsl(r, g, b);\n  return rgbToHex(...hslToRgb(h, s, Math.max(0, Math.min(1, l))));\n}\n\nfunction shiftLightness(hex: string, delta: number): string {\n  const [r, g, b] = hexToRgb(hex);\n  const [h, s, l] = rgbToHsl(r, g, b);\n  return rgbToHex(...hslToRgb(h, s, Math.max(0, Math.min(1, l + delta))));\n}\n\nfunction lightnessOf(hex: string): number {\n  const [r, g, b] = hexToRgb(hex);\n  return rgbToHsl(r, g, b)[2];\n}\n\ninterface Tokens {\n  face: string;\n  margin: string;\n  shadow: string;\n  edge: string;\n  backing: string;\n  riven: string;\n  ink: string;\n  inkMuted: string;\n}\n\nfunction readTokens(): Tokens {\n  const cs = getComputedStyle(document.documentElement);\n  const bg = cs.getPropertyValue(\"--background\").trim() || \"#ffffff\";\n  const fg = cs.getPropertyValue(\"--foreground\").trim() || \"#171717\";\n  const dark = lightnessOf(bg) < 0.5;\n\n  // Section 6, retuned for card scale. The wall is a mid-value stone that\n  // sits BELOW the page in dark and ABOVE the page in light — a dark stone\n  // on a white page read as one undifferentiated slab, because every\n  // internal step (margin, lap shadow, arris) had to fit under the face's\n  // own lightness. Each theme therefore gets its own absolute lightness\n  // ladder rather than a shared set of deltas.\n  const L = dark\n    ? { face: 0.44, margin: 0.53, shadow: 0.19, edge: 0.72, backing: 0.2, ink: 0.08, inkMuted: 0.22 }\n    : { face: 0.66, margin: 0.76, shadow: 0.5, edge: 0.9, backing: 0.46, ink: 0.14, inkMuted: 0.32 };\n\n  const face = withLightness(fg, L.face);\n  const margin = withLightness(fg, L.margin);\n  // the lap shadow carries the whole image in a monochrome wall: measured at\n  // -0.10L it was invisible at card scale and the courses read as one slab\n  const shadow = withLightness(fg, L.shadow);\n  const edge = withLightness(fg, L.edge);\n  // the batten behind the wall: the half-slate stagger leaves a gap at the\n  // start of every other course, and against the page background those gaps\n  // read as missing tiles rather than as the wall carrying on behind\n  const backing = withLightness(fg, L.backing);\n  const riven = shiftLightness(face, 0.03);\n  const ink = withLightness(fg, L.ink);\n  const inkMuted = withLightness(fg, L.inkMuted);\n  return { face, margin, shadow, edge, backing, riven, ink, inkMuted };\n}\n\n// ---- default placeholder content ------------------------------------------\n\n// Illustrative placeholder quotes: deliberately generic, attributed to a role\n// rather than to any person or company, so nothing here reads as a real\n// customer claim. Swap `items` for your own.\nconst DEFAULT_QUOTES: { quote: string; name: string; role: string }[] = [\n  { quote: \"We moved the whole team across in one afternoon.\", name: \"Engineering lead\", role: \"platform\" },\n  { quote: \"It replaced four tools we were stitching together.\", name: \"Product manager\", role: \"growth\" },\n  { quote: \"The defaults are sensible enough to ship on.\", name: \"Founder\", role: \"two-person studio\" },\n  { quote: \"Onboarding takes a morning, not a fortnight.\", name: \"Head of design\", role: \"in-house team\" },\n  { quote: \"Nobody argues about which copy is current now.\", name: \"Operations lead\", role: \"support\" },\n  { quote: \"Tickets about the old export flow stopped.\", name: \"Customer success\", role: \"EMEA\" },\n  { quote: \"It fits how we already work, not the reverse.\", name: \"Staff engineer\", role: \"infrastructure\" },\n  { quote: \"The audit trail answered a question we dreaded.\", name: \"Security lead\", role: \"compliance\" },\n  { quote: \"Rolling a mistake back is a single click.\", name: \"Tech lead\", role: \"web\" },\n  { quote: \"Design and engineering read the same source.\", name: \"Design engineer\", role: \"design systems\" },\n  { quote: \"We stopped keeping a spreadsheet nobody trusted.\", name: \"Analytics lead\", role: \"data\" },\n  { quote: \"Quiet, fast, and it has never lost our work.\", name: \"Principal engineer\", role: \"core services\" },\n  { quote: \"The migration was the boring part of the quarter.\", name: \"Delivery manager\", role: \"programmes\" },\n  { quote: \"It is the first internal tool people ask for.\", name: \"Chief of staff\", role: \"operations\" },\n  { quote: \"Two years in, we have not outgrown it.\", name: \"Director of engineering\", role: \"product\" },\n  { quote: \"Handover notes write themselves now.\", name: \"Programme lead\", role: \"delivery\" },\n  { quote: \"Nothing about it has surprised us badly.\", name: \"Platform architect\", role: \"reliability\" },\n  { quote: \"The people who resisted it use it most.\", name: \"Team lead\", role: \"customer platform\" },\n];\n\nconst DEFAULT_ITEMS: SlateTestimonial[] = DEFAULT_QUOTES.map((q, i) => ({\n  id: `slate-${i + 1}`,\n  ...q,\n}));\n\ninterface Slate {\n  index: number;\n  row: number;\n  col: number;\n  x: number; // design px, unscaled\n  top: number; // design px, unscaled — top of this course's exposed gauge\n  h: number;\n  gauge: number;\n  exposure: number;\n  centreNailed: boolean;\n}\n\nfunction layout(items: SlateTestimonial[], columns: number): { slates: Slate[]; naturalW: number; naturalH: number } {\n  const cols = Math.max(1, columns);\n  const rows = Math.ceil(items.length / cols);\n  const courseTop: number[] = [0];\n  for (let r = 0; r < rows; r++) courseTop.push(courseTop[r] + COURSES[r % COURSES.length].exposure);\n\n  const slates: Slate[] = items.map((_, i) => {\n    const row = Math.floor(i / cols);\n    const col = i % cols;\n    const course = COURSES[row % COURSES.length];\n    const offset = row % 2 === 1 ? COL_OFFSET : 0;\n    return {\n      index: i,\n      row,\n      col,\n      x: col * SLATE_W + offset,\n      top: courseTop[row],\n      h: course.h,\n      gauge: course.gauge,\n      exposure: course.exposure,\n      centreNailed: isCentreNailed(i),\n    };\n  });\n\n  const naturalW = cols * SLATE_W + COL_OFFSET;\n  const naturalH = courseTop[rows]; // sum of every course's exposure — the last course's excess is trimmed\n  return { slates, naturalW, naturalH };\n}\n\nexport function SlateGauge({\n  items = DEFAULT_ITEMS,\n  columns = 3,\n  heading = \"What teams say\",\n  className = \"\",\n}: SlateGaugeProps) {\n  const hostRef = useRef<HTMLDivElement>(null);\n  const wallRef = useRef<HTMLDivElement>(null);\n  const slateFaceRefs = useRef<Record<number, HTMLElement | null>>({});\n  const rivenRefs = useRef<Record<number, HTMLDivElement | null>>({});\n  const [liftedIndex, setLiftedIndex] = useState<number | null>(null);\n  const [scale, setScale] = useState(1);\n  const liftedRef = useRef<number | null>(null);\n  liftedRef.current = liftedIndex;\n\n  const { slates, naturalW, naturalH } = useMemo(() => layout(items, columns), [items, columns]);\n\n  // reduced-motion picks one representative slate to hold lifted and one\n  // centre-nailed slate near the frozen gust to hold mid-clatter, so the\n  // single composed frame shows every state the wall has at once (spec §5)\n  const staticPicks = useMemo(() => {\n    const gustCol = Math.min(1, Math.max(0, columns - 1));\n    const lifted = Math.min(2, slates.length - 1);\n    const near = slates.find((s) => s.centreNailed && s.col === gustCol) ?? slates.find((s) => s.centreNailed);\n    return { lifted, microLift: near?.index ?? null };\n  }, [slates, columns]);\n\n  const rivenBg = useMemo(() => {\n    // deterministic per-slate band pattern (spacing only — colours are\n    // re-applied on token read) computed once so it is byte-stable forever\n    const maps: Record<number, { start: number; end: number }[]> = {};\n    for (const s of slates) {\n      const bands: { start: number; end: number }[] = [];\n      let y = 0;\n      let seed = s.index * 97 + 11;\n      while (y < s.h) {\n        const w = 3 + hash(seed) * 6; // 3-9px\n        bands.push({ start: y, end: Math.min(s.h, y + w) });\n        y += w;\n        seed += 1;\n      }\n      maps[s.index] = bands;\n    }\n    return maps;\n  }, [slates]);\n\n  // ---- token read, geometry fit, and the whole idle loop -----------------\n  useLayoutEffect(() => {\n    const host = hostRef.current;\n    const wall = wallRef.current;\n    if (!host || !wall) return;\n\n    // token read happens before anything paints — see the class comment\n    let tokens = readTokens();\n    const applyTokens = () => {\n      wall.style.setProperty(\"--slate-face\", tokens.face);\n      wall.style.setProperty(\"--slate-margin\", tokens.margin);\n      wall.style.setProperty(\"--slate-shadow-color\", tokens.shadow);\n      wall.style.setProperty(\"--slate-edge\", tokens.edge);\n      wall.style.setProperty(\"--slate-backing\", tokens.backing);\n      wall.style.setProperty(\"--slate-riven\", tokens.riven);\n      wall.style.setProperty(\"--slate-ink\", tokens.ink);\n      wall.style.setProperty(\"--slate-ink-muted\", tokens.inkMuted);\n      // repaint riven band gradients with the fresh colour pair — percentage\n      // stops (of the slate's own full height) so the pattern never needs to\n      // be re-scaled when the fit scale or the lift height changes\n      for (const s of slates) {\n        const el = rivenRefs.current[s.index];\n        if (!el) continue;\n        const stops = rivenBg[s.index]\n          .map((b, i) => {\n            // ±0.03L fracture banding (section 6) — a texture on the stone\n            // itself, distinct from the sky-lit margin tint layered above it\n            const c = i % 2 === 0 ? tokens.face : tokens.riven;\n            return `${c} ${(b.start / s.h) * 100}%, ${c} ${(b.end / s.h) * 100}%`;\n          })\n          .join(\", \");\n        el.style.backgroundImage = `linear-gradient(to bottom, ${stops})`;\n      }\n    };\n    applyTokens();\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    const fit = () => {\n      const rect = host.getBoundingClientRect();\n      if (rect.width < 4 || rect.height < 4) return;\n      const s = Math.max(SCALE_MIN, Math.min(SCALE_MAX, Math.min(rect.width / naturalW, rect.height / naturalH)));\n      setScale(s);\n    };\n    fit();\n    const ro = new ResizeObserver(fit);\n    ro.observe(host);\n\n    if (reduced) {\n      // one composed still frame, byte-stable: no loop, no listeners\n      wall.style.setProperty(\"--wall-azimuth\", \"-6\");\n      if (staticPicks.microLift != null) {\n        const el = slateFaceRefs.current[staticPicks.microLift];\n        if (el) el.style.setProperty(\"--gust-lift\", \"5\");\n      }\n      setLiftedIndex(staticPicks.lifted);\n      const liftedEl = slateFaceRefs.current[staticPicks.lifted];\n      if (liftedEl) liftedEl.style.transform = `rotate(${LIFT_TARGET}deg)`;\n      return () => ro.disconnect();\n    }\n\n    // ---- continuous idle loop: gust field + light azimuth + micro-lifts --\n    let raf = 0;\n    let running = false;\n    let last = performance.now();\n    let t = 0;\n    const liftState = new Map<number, { v: number; vel: number; target: number }>();\n    let readLift = { v: 0, vel: 0 };\n\n    // the slate currently returning after release is tracked separately from\n    // the one actively held, since liftedIndex can flip to null the instant\n    // a pointer leaves — the spring still needs a face to write to\n    let lastHeld: number | null = null;\n    const tick = (now: number) => {\n      raf = requestAnimationFrame(tick);\n      const dt = Math.min(0.05, Math.max(0, (now - last) / 1000));\n      last = now;\n      t += dt;\n\n      const azimuth = AZIMUTH_DEG * Math.cos((2 * Math.PI * t) / AZIMUTH_PERIOD);\n      wall.style.setProperty(\"--wall-azimuth\", azimuth.toFixed(2));\n\n      // gust math stays entirely in natural (unscaled) design units, the same\n      // space slate.x already lives in — the fit `scale` only ever touches\n      // the JSX render, never this comparison\n      const span = naturalW + GUST_WIDTH * 2;\n      const frac = (t % GUST_PERIOD) / GUST_PERIOD;\n      const gustX = -GUST_WIDTH + frac * span;\n\n      for (const s of slates) {\n        if (!s.centreNailed) continue;\n        const el = slateFaceRefs.current[s.index];\n        if (!el) continue;\n        const centerX = s.x + SLATE_W / 2;\n        const inGust = Math.abs(centerX - gustX) < GUST_WIDTH / 2;\n        const amp = 2.5 + hash(s.index * 31 + 7) * 2.5;\n        let st = liftState.get(s.index);\n        if (!st) {\n          st = { v: 0, vel: 0, target: 0 };\n          liftState.set(s.index, st);\n        }\n        st.target = inGust ? amp : 0;\n        const [nv, nvel] = springStep(st.v, st.vel, st.target, GUST_LIFT_K, GUST_LIFT_C, dt);\n        st.v = nv;\n        st.vel = nvel;\n        el.style.setProperty(\"--gust-lift\", st.v.toFixed(2));\n      }\n\n      const held = liftedRef.current;\n      const activeIndex = held ?? lastHeld;\n      if (held != null) lastHeld = held;\n      if (activeIndex != null) {\n        const el = slateFaceRefs.current[activeIndex];\n        const target = held != null ? LIFT_TARGET : 0;\n        const [nv, nvel] = springStep(readLift.v, readLift.vel, target, LIFT_K, LIFT_C, dt);\n        readLift = { v: nv, vel: nvel };\n        if (el) el.style.transform = nv === 0 ? \"\" : `rotate(${nv.toFixed(2)}deg)`;\n        if (held == null && Math.abs(nv) < 0.02 && Math.abs(nvel) < 0.02) {\n          if (el) el.style.transform = \"\";\n          lastHeld = null;\n          readLift = { v: 0, vel: 0 };\n        }\n      }\n    };\n    running = true;\n    raf = requestAnimationFrame(tick);\n\n    const io = new IntersectionObserver(\n      (entries) => {\n        const visible = entries.some((e) => e.isIntersecting);\n        if (visible && !running) {\n          running = true;\n          last = performance.now();\n          raf = requestAnimationFrame(tick);\n        } else if (!visible && running) {\n          running = false;\n          cancelAnimationFrame(raf);\n        }\n      },\n      { threshold: 0 }\n    );\n    io.observe(host);\n\n    const onVis = () => {\n      if (document.hidden && running) {\n        running = false;\n        cancelAnimationFrame(raf);\n      } else if (!document.hidden && !running) {\n        running = true;\n        last = performance.now();\n        raf = requestAnimationFrame(tick);\n      }\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    const mo = new MutationObserver(() => {\n      tokens = readTokens();\n      applyTokens();\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    return () => {\n      ro.disconnect();\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n      mo.disconnect();\n      cancelAnimationFrame(raf);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [slates, naturalW, naturalH, rivenBg, staticPicks]);\n\n  const toggle = (index: number) => {\n    setLiftedIndex((prev) => (prev === index ? null : index));\n  };\n\n  return (\n    <div ref={hostRef} className={`relative flex h-full w-full flex-col items-center justify-center gap-4 overflow-hidden bg-background p-4 ${className}`}>\n      <p className=\"font-mono text-xs tracking-widest text-ns-muted\">{heading}</p>\n      <div\n        ref={wallRef}\n        className=\"relative shrink-0\"\n        // the eaves rail occupies its own band below the last course rather\n        // than overlaying it — laid on top it clipped the foot course's last\n        // line, which is the exact fault it exists to prevent\n        style={{ width: naturalW * scale, height: naturalH * scale + Math.max(4, 6 * scale) }}\n      >\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 z-0 rounded-[1px]\"\n          style={{ backgroundColor: \"var(--slate-backing)\" }}\n        />\n        {/* The eaves. Every interior course is cut off by the slate lapping\n            over it, which is what reads as overlap; the bottom course has\n            nothing above it, so without a rail its cut edge reads as\n            truncated text instead of as the foot of the wall. */}\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-x-0 z-[1001]\"\n          style={{\n            bottom: 0,\n            height: Math.max(4, 6 * scale),\n            backgroundColor: \"var(--slate-shadow-color)\",\n            boxShadow: [\n              `inset 0 ${Math.max(1, scale)}px 0 0 var(--slate-edge)`,\n              `0 ${-3 * scale}px ${8 * scale}px ${-2 * scale}px var(--slate-shadow-color)`,\n            ].join(\", \"),\n          }}\n        />\n        {slates.map((s) => {\n          const isLifted = liftedIndex === s.index;\n          const padAbove = Math.max(0, MIN_HIT - s.exposure * scale);\n          return (\n            <button\n              key={items[s.index].id}\n              type=\"button\"\n              aria-expanded={isLifted}\n              data-expanded={isLifted}\n              data-slate-lift\n              aria-label={`${isLifted ? \"Collapse\" : \"Read\"} testimonial from ${items[s.index].name}`}\n              onClick={() => toggle(s.index)}\n              onBlur={() => {\n                if (liftedRef.current === s.index) setLiftedIndex(null);\n              }}\n              onKeyDown={(e) => {\n                if (e.key === \"Escape\" && liftedRef.current === s.index) {\n                  setLiftedIndex(null);\n                  (e.currentTarget as HTMLElement).blur();\n                }\n              }}\n              className=\"group absolute cursor-pointer appearance-none border-0 bg-transparent p-0 text-left\"\n              style={{\n                left: s.x * scale,\n                top: s.top * scale - padAbove,\n                width: SLATE_W * scale,\n                height: s.exposure * scale + padAbove,\n                // resting bands never overlap (each course occupies exactly\n                // its own gauge, edge to edge), so z only has to win once a\n                // slate is lifted and needs to paint over the course below it\n                zIndex: isLifted ? 1000 : 1,\n              }}\n            >\n              <article\n                ref={(el) => {\n                  slateFaceRefs.current[s.index] = el;\n                }}\n                data-slate-face\n                className=\"absolute overflow-hidden rounded-[1px] border-0 bg-[var(--slate-face)] group-focus-visible:ring-2 group-focus-visible:ring-ns-accent group-focus-visible:ring-offset-1\"\n                style={{\n                  top: padAbove,\n                  left: 0,\n                  width: SLATE_W * scale,\n                  // at rest the face IS its gauge — only the exposed margin\n                  // is ever painted; lifting grows it to the slate's real\n                  // height, which is what reveals the rest of the quote\n                  height: (isLifted ? s.h : s.exposure) * scale,\n                  transformOrigin: `50% ${NAIL_Y * scale}px`,\n                  // no colour fallback here on purpose: before the token read\n                  // commits (useLayoutEffect, pre-paint) --slate-shadow-color\n                  // is unset, which makes the whole box-shadow invalid and\n                  // therefore simply not painted, rather than painted with a\n                  // literal placeholder colour\n                  // one cast shadow onto the course below (this is what makes\n                  // the lap read as a slate sitting OVER its neighbour rather\n                  // than as text truncated at a hard edge), one inset seam,\n                  // and a sky-lit top arris. All three swing with the single\n                  // root-level azimuth property.\n                  boxShadow: [\n                    `0 ${-1 * Math.max(1, scale)}px 0 0 var(--slate-shadow-color)`,\n                    `0 ${-3 * scale}px ${7 * scale}px ${-1 * scale}px var(--slate-shadow-color)`,\n                    `inset calc(var(--wall-azimuth, 0) * 0.4px) ${3 * scale}px ${5 * scale}px ${-1 * scale}px var(--slate-shadow-color)`,\n                    `inset 0 ${Math.max(1, scale)}px 0 0 var(--slate-edge)`,\n                  ].join(\", \"),\n                  translate: `0 calc(var(--gust-lift, 0) * -1px)`,\n                }}\n              >\n                {/* riven texture: a separate layer fixed at the slate's FULL\n                    design height regardless of the article's current (gauge\n                    or lifted) height, so its percentage-based band stops\n                    always land at the same physical position on the stone —\n                    the article's own overflow-hidden is what crops it to\n                    whichever margin is currently showing */}\n                <div\n                  ref={(el) => {\n                    rivenRefs.current[s.index] = el;\n                  }}\n                  aria-hidden=\"true\"\n                  className=\"absolute inset-x-0 top-0\"\n                  style={{ height: s.h * scale }}\n                />\n                {/* the exposed margin is sky-lit relative to the rest of the\n                    stone (section 6, +0.06/+0.05L) — a fixed-height tint over\n                    exactly the gauge strip, independent of the article's own\n                    current (gauge or lifted) height */}\n                <div\n                  aria-hidden=\"true\"\n                  className=\"absolute inset-x-0 top-0 bg-[var(--slate-margin)] opacity-40\"\n                  style={{ height: s.exposure * scale }}\n                />\n                <div\n                  className=\"relative flex h-full flex-col\"\n                  style={{\n                    color: \"var(--slate-ink)\",\n                    padding: `${PAD_Y * scale}px ${8 * scale}px`,\n                    // type is scaled with the wall, not left at a fixed pixel\n                    // size — a fixed 10px against a gauge that grows with the\n                    // fit scale is what left a half line at every lap\n                    fontSize: `${FONT_PX * scale}px`,\n                    lineHeight: LINE_RATIO,\n                  }}\n                >\n                  {/* always full text in the DOM, never display:none — the\n                      gauge-height article above is what occludes it visually */}\n                  <blockquote className=\"m-0\">&ldquo;{items[s.index].quote}&rdquo;</blockquote>\n                  <footer\n                    className=\"m-0 font-medium\"\n                    style={{ color: \"var(--slate-ink-muted)\", fontSize: `${FONT_PX * scale}px` }}\n                  >\n                    {items[s.index].name} &middot; {items[s.index].role}\n                  </footer>\n                </div>\n              </article>\n            </button>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n\nSlateGauge.displayName = \"SlateGauge\";\n\nexport default SlateGauge;\n",
      "type": "registry:ui",
      "target": "components/ui/slate-gauge.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "testimonial",
      "wall",
      "social-proof",
      "roofing",
      "occlusion",
      "spring"
    ],
    "instruction": "Build <SlateGauge items? columns? heading? className?> as a DOM/CSS testimonial wall modelled on double-lap slating, no canvas. GEOMETRY: five diminishing courses, heights [132,120,110,101,94]px at slate width 168px, lap 44px, head allowance 8px; each course's gauge (its exposed margin) is computed, never chosen: gauge = (h - 44 - 8) / 2, giving [40,34,29,24.5,21]px. Testimonials fill row-major (course 1 left-to-right, then course 2, ...) into `columns` slates per course (default 2), cycling back through the five courses if there are more than five rows. Alternate courses are offset laterally by 84px (half a slate) to break the side lap. AT REST every slate's rendered face is exactly its own gauge tall — courses are laid edge to edge with zero overlap, so nothing needs a clip-path to hide the covered nine-tenths of a slate: it is simply never painted at that height. The quote text is always fully in the DOM inside a <button aria-expanded> wrapping an <article> — never display:none — so a screen reader, Ctrl-F and text selection reach the whole sentence regardless of what height is currently rendered; only the SHORT box clips it visually. READING: clicking a slate (or Enter/Space on its button) grows that one article from gauge to its true height h and raises it to z-index 1000, which is the only overlap this layout ever has, while it also rotates -34deg about a nail line 18px below its head (transform-origin) on an underdamped spring integrated in an rAF loop with k=210, c=19 (semi-implicit Euler, mass 1) — the spring's own underdamped ratio (c/c_crit ≈ 0.66) is what produces the single ~3deg overshoot as an emergent property rather than a hand-authored keyframe, and relaxing the SAME spring back to target 0 on release/Escape/blur is what produces the return clatter, so lift and return are one mechanism read in two directions. Only one slate is ever lifted; lifting a second sets both aria-expanded values in the same state commit, and Tab order is DOM order (course 1 left-to-right, then course 2, ...), not visual stacking order. HIT TARGET: each slate's button is padded upward (never downward, which would intrude on the course it's about to cover) to a 44px minimum height when its gauge is smaller, via fully transparent extra box — the painted <article> inside is untouched, so the drawn lap never changes. ALIVE AT REST, two unforced processes, neither ever touching the wall's own layout: (1) a 190px-wide gust sweeps left to right every 4.8s (natural, unscaled design units); a deterministic 3-in-17 subset of slates (index % 17 < 3) is centre-nailed, and whenever a centre-nailed slate's tail sits inside the gust it lifts 2.5-5px (per-slate deterministic amplitude via a sine-based hash of its index) on a second, snappier underdamped spring (k=1200, c=24) applied as a CSS `translate` (a separate transform channel from the read-rotation's `transform: rotate()`, so both apply independently on the same element without fighting). (2) a light azimuth swings ±14deg on a 22s cosine, written ONCE per frame as a single `--wall-azimuth` custom property on the wall root and read by every slate's own `box-shadow: inset calc(var(--wall-azimuth) * 0.4px) ...` — one style write drives the whole wall's cast-shadow relief inverting together, rather than touching N DOM nodes per frame. The azimuth NEVER changes any slate's own face luminance, only the cast shadow's horizontal offset and hence which side it falls on. `prefers-reduced-motion` skips the rAF loop entirely and instead composes ONE fixed, byte-stable frame: azimuth -6deg, one centre-nailed slate held at --gust-lift:5 (its max), and one representative slate forced into the lifted state at exactly -34deg with no spring residue — every state the component has, shown at once, with no timers running afterward. TOKENS AND COLOUR: only --background, --foreground, --ns-muted, --border (unused — no separator lines anywhere on the wall) and --ns-accent (interaction chrome only, the read-button focus ring) are read, via getComputedStyle on documentElement, re-read on a MutationObserver watching its class. Every derived colour keeps a token's own hue/saturation and only overrides HSL lightness: slate face = --foreground forced to L 0.34 (light theme) / 0.52 (dark); the exposed margin is a separate tint LAYER (not a different base colour) at the same hue, +0.06L light / +0.05L dark, drawn only over the top `gauge` px so the sky-lit strip reads lighter regardless of whether the slate is at rest or lifted; the tail shadow (the box-shadow colour) is the margin shifted -0.12L light / -0.10L dark; the riven fracture texture is a percentage-stop background gradient alternating face and face+0.03L in 3-9px bands seeded deterministically per slate index (byte-stable, never animated), rendered on a layer pinned to the slate's FULL design height regardless of its current gauge/lifted height so the pattern never needs re-scaling — only the enclosing article's overflow-hidden crops it. Quote ink is --foreground in light theme, --background in dark (darker than the slate face in both, no glow inversion). FIT: the component measures its host's width AND height (ResizeObserver) and scales all geometry by min(hostW/naturalW, hostH/naturalH), clamped so the smallest course never renders under 44px (scale floor) and never above 1.4x — deriving from the smaller dimension is what keeps a short, wide card from producing an unreadably short wall. No box-shadow colour literal fallback is given anywhere (an unset custom property makes the whole box-shadow declaration invalid and therefore simply unpainted, which is the desired pre-token-read state, rather than painting a placeholder hex)."
  },
  "type": "registry:ui"
}