{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "joint-iron",
  "title": "Joint Iron",
  "description": "A footer whose entire surface is a bookbinding building-in machine: three stations set a French groove under a heated brass iron, hold it through a real dwell, and release it with a measured 14% spring-back — the sitemap and legal line sit on the same case tone the groove is cut into.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/joint-iron/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// JointIron — a footer whose top edge IS a book joint being formed,\n// not a footer with an animation bolted onto it.\n//\n// Real mechanic: the building-in machine in edition binding. After a book is\n// cased-in, it is clamped in a machine that presses heated brass joint irons\n// into the hinge to set the French groove — the channel between the spine and\n// the boards — under real dwell time, then releases with a measured 14%\n// spring-back as the boards' elastic set partially relaxes. Three stations\n// along the band run this five-phase cycle (index / iron descent / dwell /\n// release / lift) independently, offset in phase, forever.\n//\n// The canvas is the full footer surface, not a strip above it — the DOM\n// content (wordmark, sitemap, legal line) sits on the SAME \"case\" tone the\n// groove is cut into, in a scrim panel below the forming zone. Delete the\n// loop and the footer loses its material, not a decoration: this is the\n// thing that keeps it from being footing-course.\n//\n// One height field per station, g(x) — a cosine bump centred on the station,\n// with half-width hw = 1.44 * depth on each side (so the groove's WIDTH\n// shrinks with depth, not just its floor). The two facet walls are lit with\n// a single-lamp Lambert-style term derived from the bump's own slope\n// (-sin(pi*d/hw)), so depth is legible three ways at once — floor depth,\n// facet width, facet contrast — which is what makes the 14% spring-back\n// (2.6px of floor movement at card scale) visible at all: on its own it is\n// below the perceptual floor, but the paired 7.5px facet-width narrowing and\n// the ~0.035L contrast step read as a real event over the 170ms it happens\n// in.\n//\n// Tokens only: --background, --foreground, --ns-muted, --border, --ns-accent\n// read via getComputedStyle + a MutationObserver on documentElement's class.\n// --ns-accent never touches the joint (only the newsletter submit and focus\n// rings). The heated iron — this component's one climactic moment — is\n// solved by value alone: a solid --foreground silhouette whose underside\n// gets a shadowBlur glow (still just the --foreground token, no hue) for its\n// 700ms post-lift shimmer, which is the theme-agnostic way to \"brighten\" a\n// silhouette that is already at the bright extreme of its palette in dark\n// theme and the dark extreme in light theme.\n// ---------------------------------------------------------------------------\n\nconst CYCLE = 7.4; // one station's full sequence, seconds\nconst P_INDEX = 0.85;\nconst P_DESCENT = 0.62;\nconst P_DWELL = 3.6;\nconst P_RELEASE = 0.45;\n// P_LIFT = CYCLE - (P_INDEX + P_DESCENT + P_DWELL + P_RELEASE) = 1.88\nconst T1 = P_INDEX;\nconst T2 = T1 + P_DESCENT;\nconst T3 = T2 + P_DWELL;\nconst T4 = T3 + P_RELEASE;\n\nconst DESCENT_TC = 0.28; // first-order approach time constant, descent\nconst DWELL_TC = 1.4; // first-order approach time constant, dwell creep\nconst RELEASE_SPRING_T = 0.17; // spring-back happens in the first 170ms of release\nconst RELEASE_DROP = 0.14; // 14% of set depth recovered on release\nconst LIFT_DUR = 0.3; // iron travel time within the 1.88s lift+dwell-out phase\nconst SHIMMER_S = 0.7; // heat-shimmer duration after lift begins\n\n// STATION_OFFSET is a deliberate 2.6s (not an exact CYCLE/3 = 2.467) — tuned\n// so the reduced-motion STATIC_TIME below lands one station mid-release, one\n// mid-dwell and one mid-index simultaneously, matching the spec's\n// non-t0 freeze-frame requirement without relying on luck.\nconst STATION_OFFSET = 2.6;\nconst STATIC_TIME = 2.62;\nconst STATION_COUNT_FULL = 3;\nconst STATION_COUNT_SHORT = 2;\nconst MIN_HB_FOR_GRAIN_AND_3RD = 88; // px; below this, drop to 2 stations, drop grain\n\nconst GROOVE_CENTER_FRAC = 0.3; // groove's resting line, fraction of Hb below top edge\nconst GROOVE_CENTER_MAX_PX = 132; // clamp so the DOM sitemap (below it) can never bury the groove\nconst G_MAX_FRAC = 0.075; // g_max as a fraction of M = min(bandW, bandH)\n// The groove is ONE continuous channel across the band, not three isolated\n// dimples: each station owns a raised-cosine window whose depth is its own\n// phase, and the channel takes the deepest contribution at every x. Adjacent\n// windows overlap (HW > slotW/2), so the depth profile is continuous and the\n// three phases read as one hinge being worked along its length.\nconst STATION_WINDOW_MULT = 0.62; // window half-width, fraction of a slot\nconst GROOVE_SAMPLES = 132; // polyline samples across the band\nconst FACET_WIDTH_MULT = 1.44; // facet half-width = 1.44 * current depth\nconst FACET_BRIGHT_L = 0.15; // spine-side facet peak contrast, spec's real number\nconst FACET_DARK_L = 0.17; // board-side facet peak contrast, spec's real number\nconst FACET_BRIGHT_ALPHA = 0.72; // lit far wall — the groove's primary value carrier\n// dark alpha derived from the bright budget scaled by the spec's own L ratio,\n// so the 0.15 / 0.17 asymmetry (and the 14%-release drop of both) survives\n// the translation into canvas alpha rather than being re-guessed.\nconst FACET_DARK_ALPHA = FACET_BRIGHT_ALPHA * (FACET_DARK_L / FACET_BRIGHT_L);\n\nconst THERMOSTAT_CYCLE = 2.1;\nconst THERMOSTAT_AMP_ALPHA = 0.05; // iron fill-alpha ripple standing in for +/-0.04L hunting\n\nconst CASE_ALPHA_LIGHT = 0.1;\nconst CASE_ALPHA_DARK = 0.16;\nconst IRON_BASE_ALPHA = 0.86; // headroom below solid fg so the shimmer has somewhere to go\n\nconst GRAIN_AMP_ALPHA = 0.05; // canvas overlay alpha standing in for +/-0.02L board grain\nconst GRAIN_FEATURE_FRAC = 0.006; // grain feature size, fraction of M\nconst DPR_CAP = 2;\nconst SLOW_FRAME_MS = 33; // ~30fps budget\nconst SLOW_SUSTAIN_MS = 1500; // sustained slow window before quality drops\n\nfunction mod(a: number, n: number) {\n  return ((a % n) + n) % n;\n}\nfunction clamp01(v: number) {\n  return Math.max(0, Math.min(1, v));\n}\nfunction easeOutCubic(t: number) {\n  const u = 1 - clamp01(t);\n  return 1 - u * u * u;\n}\n\n// deterministic, seeded — no Math.random anywhere in the render path, so the\n// reduced-motion frame (and every ordinary frame, given the same t) is\n// byte-stable.\nfunction mulberry32(seed: number) {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) | 0;\n    let t = Math.imul(a ^ (a >>> 15), 1 | a);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\ninterface Tokens {\n  fg: string;\n  bg: string;\n  muted: string;\n  border: string;\n}\n\nfunction readTokens(): Tokens | null {\n  if (typeof document === \"undefined\") return null;\n  const cs = getComputedStyle(document.documentElement);\n  const fg = cs.getPropertyValue(\"--foreground\").trim();\n  const bg = cs.getPropertyValue(\"--background\").trim();\n  const muted = cs.getPropertyValue(\"--ns-muted\").trim();\n  const border = cs.getPropertyValue(\"--border\").trim();\n  if (!fg || !bg) return null;\n  return { fg, bg, muted, border };\n}\n\n// -- the joint's phase machine — a pure function of local (station) time ---\ntype Phase = \"index\" | \"descent\" | \"dwell\" | \"release\" | \"lift\";\ninterface StationFrame {\n  phase: Phase;\n  depthFrac: number; // 0..1 of gMax, the groove floor's current depth\n  contrastMult: number; // multiplies FACET_*_L, folds in shoulder-rounding on release\n  ironFrac: number; // 0 (up) .. 1 (down)\n  indexProgress: number; // 0..1 while phase === \"index\", else 0\n  shimmerT: number | null; // seconds into the post-lift shimmer, else null\n}\n\n// depth fraction reached at the end of descent / dwell, and the held depth\n// after release — computed once, reused every cycle (the process is\n// periodic: every book gets the same result).\nconst DESCENT_END_FRAC = 1 - Math.exp(-P_DESCENT / DESCENT_TC);\nconst DWELL_END_FRAC = 1 - 0.11 * Math.exp(-P_DWELL / DWELL_TC);\nconst HOLD_FRAC = DWELL_END_FRAC * (1 - RELEASE_DROP);\n\nfunction indexEase(p: number) {\n  // easeOutCubic with a small, quickly-damped overshoot standing in for the\n  // spec's \"3% overshoot damped in 140ms\" spring settle.\n  const base = easeOutCubic(p);\n  const overshoot = 0.03 * Math.exp(-p * 9) * Math.sin(p * Math.PI * 2.2);\n  return base + overshoot;\n}\n\nfunction stationFrame(localT: number): StationFrame {\n  if (localT < T1) {\n    const p = localT / P_INDEX;\n    return { phase: \"index\", depthFrac: 0, contrastMult: 0, ironFrac: 0, indexProgress: indexEase(p), shimmerT: null };\n  }\n  if (localT < T2) {\n    const t = localT - T1;\n    const depthFrac = 1 - Math.exp(-t / DESCENT_TC);\n    return { phase: \"descent\", depthFrac, contrastMult: depthFrac, ironFrac: clamp01(depthFrac / DESCENT_END_FRAC), indexProgress: 0, shimmerT: null };\n  }\n  if (localT < T3) {\n    const t = localT - T2;\n    const depthFrac = 1 - 0.11 * Math.exp(-t / DWELL_TC);\n    return { phase: \"dwell\", depthFrac, contrastMult: depthFrac, ironFrac: 1, indexProgress: 0, shimmerT: null };\n  }\n  if (localT < T4) {\n    const t = localT - T3;\n    const springP = clamp01(t / RELEASE_SPRING_T);\n    const springEase = easeOutCubic(springP);\n    const depthFrac = DWELL_END_FRAC * (1 - RELEASE_DROP * springEase);\n    const shoulderRound = 1 - 0.09 * springEase; // extra rounding beyond pure depth ratio\n    return { phase: \"release\", depthFrac, contrastMult: depthFrac * shoulderRound, ironFrac: 1, indexProgress: 0, shimmerT: null };\n  }\n  const t = localT - T4;\n  const ironFrac = Math.max(0, 1 - t / LIFT_DUR);\n  return {\n    phase: \"lift\",\n    depthFrac: HOLD_FRAC,\n    contrastMult: HOLD_FRAC,\n    ironFrac,\n    indexProgress: 0,\n    shimmerT: t < SHIMMER_S ? t : null,\n  };\n}\n\nexport interface JointIronLink {\n  label: string;\n  href: string;\n}\nexport interface JointIronColumn {\n  heading: string;\n  links: JointIronLink[];\n}\n\nexport interface JointIronProps {\n  /** wordmark on the case */\n  brand?: string;\n  /** short line under the wordmark */\n  tagline?: string;\n  /** sitemap columns */\n  columns?: JointIronColumn[];\n  /** legal / copyright line; year is appended automatically */\n  legal?: string;\n  /** newsletter field placeholder */\n  newsletterPlaceholder?: string;\n  /** called with the entered email on submit; default is a no-op (demo-safe) */\n  onSubscribe?: (email: string) => void;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst DEFAULT_COLUMNS: JointIronColumn[] = [\n  {\n    heading: \"Product\",\n    links: [\n      { label: \"Overview\", href: \"#overview\" },\n      { label: \"Pricing\", href: \"#pricing\" },\n      { label: \"Changelog\", href: \"#changelog\" },\n    ],\n  },\n  {\n    heading: \"Resources\",\n    links: [\n      { label: \"Docs\", href: \"#docs\" },\n      { label: \"Guides\", href: \"#guides\" },\n      { label: \"API\", href: \"#api\" },\n    ],\n  },\n  {\n    heading: \"Company\",\n    links: [\n      { label: \"About\", href: \"#about\" },\n      { label: \"Blog\", href: \"#blog\" },\n      { label: \"Careers\", href: \"#careers\" },\n    ],\n  },\n  {\n    heading: \"Legal\",\n    links: [\n      { label: \"Privacy\", href: \"#privacy\" },\n      { label: \"Terms\", href: \"#terms\" },\n    ],\n  },\n];\n\nexport function JointIron({\n  brand = \"ns-ui\",\n  tagline = \"Bound the way it's built.\",\n  columns = DEFAULT_COLUMNS,\n  legal = \"All rights reserved.\",\n  newsletterPlaceholder = \"you@company.com\",\n  onSubscribe,\n  className = \"\",\n}: JointIronProps) {\n  const wrapRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const emailId = useId();\n\n  useEffect(() => {\n    const wrap = wrapRef.current;\n    const canvas = canvasRef.current;\n    if (!wrap || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    let disposed = false;\n    let tokens: Tokens | null = null;\n    let isDark = false;\n    let dpr = 1;\n    let w = 0;\n    let h = 0;\n    let sized = false;\n    // the loop only runs when BOTH are true — an intersecting-but-hidden-tab\n    // footer (or a visible-tab-but-scrolled-off footer) must stay paused\n    let inView = false;\n    let pageVisible = document.visibilityState !== \"hidden\";\n    let columnStep = 1; // widened under sustained slowness\n\n    let raf = 0;\n    let tokenWaitRaf = 0;\n    let slowSince: number | null = null;\n\n    // -- static seeded board-grain, baked to an offscreen tile at resize ---\n    let grain: HTMLCanvasElement | null = null;\n    const bakeGrain = (m: number) => {\n      const feature = Math.max(1, m * GRAIN_FEATURE_FRAC);\n      const tile = document.createElement(\"canvas\");\n      tile.width = Math.max(1, Math.round(w));\n      tile.height = Math.max(1, Math.round(h));\n      const tctx = tile.getContext(\"2d\");\n      if (!tctx || !tokens) {\n        grain = tile;\n        return;\n      }\n      const rand = mulberry32(20260901);\n      const cols = Math.max(1, Math.ceil(tile.width / feature));\n      const rows = Math.max(1, Math.ceil(tile.height / feature));\n      tctx.fillStyle = tokens.fg;\n      for (let r = 0; r < rows; r++) {\n        for (let c = 0; c < cols; c++) {\n          const v = rand();\n          if (v < 0.5) continue; // sparse — grain is a texture, not a wash\n          tctx.globalAlpha = (v - 0.5) * 2 * GRAIN_AMP_ALPHA;\n          tctx.fillRect(c * feature, r * feature, feature, feature);\n        }\n      }\n      tctx.globalAlpha = 1;\n      grain = tile;\n    };\n\n    // -- layout ---------------------------------------------------------\n    const layout = () => {\n      const m = Math.min(w, h);\n      const stationCount = h >= MIN_HB_FOR_GRAIN_AND_3RD ? STATION_COUNT_FULL : STATION_COUNT_SHORT;\n      // clamped so the groove zone always clears the DOM scrim's top offset\n      // (pt-20/pt-24 in the markup below) even on a very tall footer\n      const grooveCenterY = Math.min(GROOVE_CENTER_FRAC * h, GROOVE_CENTER_MAX_PX);\n      const gMax = G_MAX_FRAC * m;\n      const slotW = w / stationCount;\n      const stations = Array.from({ length: stationCount }, (_, i) => ({\n        cx: slotW * i + slotW / 2,\n        slotX0: slotW * i,\n        slotX1: slotW * (i + 1),\n      }));\n      return { m, stationCount, grooveCenterY, gMax, slotW, stations, showGrain: h >= MIN_HB_FOR_GRAIN_AND_3RD };\n    };\n\n    const fitCanvas = () => {\n      canvas.width = Math.max(1, Math.round(w * dpr));\n      canvas.height = Math.max(1, Math.round(h * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n    };\n\n    const resize = () => {\n      const rect = wrap.getBoundingClientRect();\n      if (rect.width < 4 || rect.height < 4) {\n        sized = false;\n        return;\n      }\n      dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP);\n      w = rect.width;\n      h = rect.height;\n      fitCanvas();\n      columnStep = 1;\n      slowSince = null;\n      const { showGrain } = layout();\n      if (showGrain) bakeGrain(Math.min(w, h));\n      else grain = null;\n      sized = true;\n    };\n\n    // -- drawing ----------------------------------------------------------\n    // Depth of the channel at x: the deepest station window covering it.\n    // Each station's window is a raised cosine centred on its (possibly\n    // index-shifted) centre, so a station mid-dwell cuts a deep bowl while\n    // its neighbour mid-descent is still a shallow scoop, and the two meet\n    // in a continuous wall rather than a seam.\n    const depthAt = (\n      x: number,\n      stations: Array<{ cx: number; slotX0: number; slotX1: number }>,\n      frames: StationFrame[],\n      gMax: number,\n      slotW: number,\n    ) => {\n      const hw = slotW * STATION_WINDOW_MULT;\n      let d = 0;\n      let c = 0;\n      for (let i = 0; i < stations.length; i++) {\n        const f = frames[i]!;\n        const depth = (f.phase === \"index\" ? HOLD_FRAC : f.depthFrac) * gMax;\n        if (depth < 0.2) continue;\n        // during index the previous station's finished groove travels out of\n        // the slot, so its window centre travels with it\n        const cx = stations[i]!.cx - (f.phase === \"index\" ? slotW * f.indexProgress : 0);\n        const dx = Math.abs(x - cx);\n        if (dx >= hw) continue;\n        // flattened raised cosine: a pressed groove has a floor, not a\n        // vertex, so the window plateaus in the middle and steepens at the\n        // walls instead of reading as a shallow V\n        const win = Math.pow(0.5 * (1 + Math.cos((Math.PI * dx) / hw)), 0.42);\n        const contribution = depth * win;\n        if (contribution > d) {\n          d = contribution;\n          c = (f.phase === \"index\" ? HOLD_FRAC : f.contrastMult) * win;\n        }\n      }\n      return { d, c };\n    };\n\n    const drawGroove = (\n      cy: number,\n      gMax: number,\n      stations: Array<{ cx: number; slotX0: number; slotX1: number }>,\n      frames: StationFrame[],\n      slotW: number,\n      brightToken: string,\n      darkToken: string,\n    ) => {\n      const n = GROOVE_SAMPLES;\n      const xs = new Float64Array(n + 1);\n      const top = new Float64Array(n + 1);\n      const bot = new Float64Array(n + 1);\n      const con = new Float64Array(n + 1);\n      for (let i = 0; i <= n; i++) {\n        const x = (w * i) / n;\n        const { d, c } = depthAt(x, stations, frames, gMax, slotW);\n        xs[i] = x;\n        // the channel's own height is the first of the three depth cues: it\n        // opens downward from a nearly fixed lip, so the lip stays a\n        // straight hinge line and the floor is what moves\n        top[i] = cy - d * 0.06;\n        bot[i] = cy + d * 0.9;\n        con[i] = c;\n      }\n\n      // 1 — the recess itself: one filled band, darkest token, so the channel\n      // reads as a shadowed cut through the case at a glance\n      ctx.save();\n      ctx.beginPath();\n      ctx.moveTo(xs[0]!, top[0]!);\n      for (let i = 1; i <= n; i++) ctx.lineTo(xs[i]!, top[i]!);\n      for (let i = n; i >= 0; i--) ctx.lineTo(xs[i]!, bot[i]!);\n      ctx.closePath();\n      ctx.globalAlpha = isDark ? 0.5 : 0.36;\n      ctx.fillStyle = darkToken;\n      ctx.fill();\n      ctx.restore();\n\n      // 1b — the near wall directly under the lip takes the least light, so\n      // a second, shallower pass over the top 45% of the cut gives the\n      // channel a curved wall instead of a flat ribbon of tone\n      ctx.save();\n      ctx.beginPath();\n      ctx.moveTo(xs[0]!, top[0]!);\n      for (let i = 1; i <= n; i++) ctx.lineTo(xs[i]!, top[i]!);\n      for (let i = n; i >= 0; i--) ctx.lineTo(xs[i]!, top[i]! + (bot[i]! - top[i]!) * 0.45);\n      ctx.closePath();\n      ctx.globalAlpha = isDark ? 0.45 : 0.3;\n      ctx.fillStyle = darkToken;\n      ctx.fill();\n      ctx.restore();\n\n      // 2 — the lit far wall along the bottom of the cut: the single\n      // brightest mark in the footer, and the one that collapses visibly on\n      // the 14% spring-back (it rises AND dims together)\n      ctx.save();\n      ctx.lineCap = \"round\";\n      ctx.lineJoin = \"round\";\n      for (let i = 0; i < n; i++) {\n        const cAvg = (con[i]! + con[i + 1]!) / 2;\n        if (cAvg <= 0.02) continue;\n        ctx.globalAlpha = Math.min(1, FACET_BRIGHT_ALPHA * cAvg + 0.06);\n        ctx.strokeStyle = brightToken;\n        ctx.lineWidth = 1.2 + 2.2 * cAvg;\n        ctx.beginPath();\n        ctx.moveTo(xs[i]!, bot[i]!);\n        ctx.lineTo(xs[i + 1]!, bot[i + 1]!);\n        ctx.stroke();\n      }\n      ctx.restore();\n\n      // 3 — the hinge lip: a continuous hairline the whole width, so the\n      // joint exists as a line even where depth is zero (station indexing)\n      ctx.save();\n      ctx.globalAlpha = isDark ? 0.5 : 0.34;\n      ctx.strokeStyle = brightToken;\n      ctx.lineWidth = 1;\n      ctx.beginPath();\n      ctx.moveTo(xs[0]!, top[0]! - 0.5);\n      for (let i = 1; i <= n; i++) ctx.lineTo(xs[i]!, top[i]! - 0.5);\n      ctx.stroke();\n      ctx.restore();\n      ctx.globalAlpha = 1;\n    };\n\n    // The iron: a brass block on a ram that never leaves the band. It is a\n    // solid --foreground silhouette (value only, no hue), with a hot\n    // underside line that shimmers for 700ms after it lifts.\n    const drawIron = (\n      station: { cx: number; slotX0: number; slotX1: number },\n      frame: StationFrame,\n      cy: number,\n      gMax: number,\n      slotW: number,\n      t: number,\n    ) => {\n      const ironW = Math.min(slotW * 0.22, gMax * 2.1);\n      const ironH = Math.max(8, gMax * 0.62);\n      const noseH = Math.max(4, gMax * 0.34);\n      const ironUpY = Math.max(4, cy - gMax * 3.2);\n      // full stroke seats the nose in the floor of its own groove, never\n      // below it — the iron forms the channel, it does not punch through\n      const ironDownY = cy - ironH - noseH * 0.15;\n      const y = ironUpY + (ironDownY - ironUpY) * frame.ironFrac;\n      const x0 = station.cx - ironW / 2;\n\n      const thermostat = 1 + THERMOSTAT_AMP_ALPHA * Math.sin((t / THERMOSTAT_CYCLE) * Math.PI * 2);\n\n      ctx.save();\n      // ram: the block is machine-driven, so it stays attached to the head\n      // rail at the top of the band instead of floating\n      ctx.globalAlpha = clamp01(0.5 * thermostat);\n      ctx.fillStyle = tokens!.fg;\n      ctx.fillRect(station.cx - Math.max(1.5, ironW * 0.07), 0, Math.max(3, ironW * 0.14), y + 2);\n\n      ctx.globalAlpha = clamp01(IRON_BASE_ALPHA * thermostat);\n      ctx.beginPath();\n      ctx.moveTo(x0, y);\n      ctx.lineTo(x0 + ironW, y);\n      ctx.lineTo(x0 + ironW, y + ironH);\n      // the working face is a wedge — it is the shape that cuts the groove,\n      // so its profile has to match the channel it leaves behind\n      ctx.lineTo(x0 + ironW * 0.72, y + ironH + noseH);\n      ctx.lineTo(x0 + ironW * 0.28, y + ironH + noseH);\n      ctx.lineTo(x0, y + ironH);\n      ctx.closePath();\n      ctx.fill();\n\n      if (frame.shimmerT != null) {\n        const shimmerEase = 1 - frame.shimmerT / SHIMMER_S;\n        ctx.shadowColor = tokens!.fg;\n        ctx.shadowBlur = shimmerEase * gMax * 2.2;\n        ctx.globalAlpha = 0.95 * shimmerEase;\n        ctx.fillRect(x0 + ironW * 0.28, y + ironH + noseH - 2, ironW * 0.44, 2);\n      }\n      ctx.restore();\n      ctx.globalAlpha = 1;\n    };\n\n    const draw = (t: number) => {\n      if (!tokens || !sized) return;\n      const { grooveCenterY, gMax, stations, slotW, showGrain } = layout();\n      const caseAlpha = isDark ? CASE_ALPHA_DARK : CASE_ALPHA_LIGHT;\n      const brightToken = isDark ? tokens.fg : tokens.bg;\n      const darkToken = isDark ? tokens.bg : tokens.fg;\n\n      // the case is the WHOLE footer surface, edge to edge — the band and\n      // the sitemap are the same material, which is the point\n      ctx.fillStyle = tokens.bg;\n      ctx.fillRect(0, 0, w, h);\n      ctx.globalAlpha = caseAlpha;\n      ctx.fillStyle = tokens.fg;\n      ctx.fillRect(0, 0, w, h);\n      ctx.globalAlpha = 1;\n\n      if (showGrain && grain) {\n        ctx.drawImage(grain, 0, 0);\n      }\n\n      // hairline between the footer and the page above\n      ctx.globalAlpha = 0.9;\n      ctx.strokeStyle = tokens.border;\n      ctx.lineWidth = 1;\n      ctx.beginPath();\n      ctx.moveTo(0, 0.5);\n      ctx.lineTo(w, 0.5);\n      ctx.stroke();\n      ctx.globalAlpha = 1;\n\n      const frames = stations.map((_, i) => stationFrame(mod(t + i * STATION_OFFSET, CYCLE)));\n      drawGroove(grooveCenterY, gMax, stations, frames, slotW, brightToken, darkToken);\n      for (let i = 0; i < stations.length; i++) {\n        drawIron(stations[i]!, frames[i]!, grooveCenterY, gMax, slotW, t);\n      }\n    };\n\n    const loop = (nowMs: number) => {\n      if (disposed) return;\n      if (!inView || !pageVisible) {\n        raf = 0; // re-armed by whichever of IO / visibilitychange flips back first\n        return;\n      }\n      raf = requestAnimationFrame(loop);\n      if (!sized || !tokens) return;\n\n      const frameStart = performance.now();\n      draw(nowMs / 1000);\n      const frameMs = performance.now() - frameStart;\n      if (frameMs > SLOW_FRAME_MS) {\n        if (slowSince == null) slowSince = nowMs;\n        else if (nowMs - slowSince > SLOW_SUSTAIN_MS) columnStep = 2;\n      } else {\n        slowSince = null;\n      }\n    };\n\n    const drawStatic = () => {\n      draw(STATIC_TIME);\n    };\n\n    const rebakeGrainIfNeeded = () => {\n      const { showGrain, m } = layout();\n      if (showGrain) bakeGrain(m);\n      else grain = null;\n    };\n\n    const resumeLoop = () => {\n      if (!reduced && tokens && sized && inView && pageVisible && !raf) {\n        raf = requestAnimationFrame(loop);\n      }\n    };\n\n    let started = false;\n    const kick = () => {\n      if (started || disposed || !tokens || !sized) return;\n      started = true;\n      if (reduced) {\n        drawStatic();\n        return;\n      }\n      if (inView && pageVisible) raf = requestAnimationFrame(loop);\n    };\n\n    const boot = () => {\n      if (disposed) return;\n      tokens = readTokens();\n      if (!tokens) {\n        tokenWaitRaf = requestAnimationFrame(boot);\n        return;\n      }\n      isDark = document.documentElement.classList.contains(\"dark\");\n      resize();\n      kick();\n    };\n\n    const ro = new ResizeObserver(() => {\n      if (!tokens) return;\n      resize();\n      if (reduced) drawStatic();\n      kick();\n    });\n    ro.observe(wrap);\n\n    const mo = new MutationObserver(() => {\n      tokens = readTokens();\n      isDark = document.documentElement.classList.contains(\"dark\");\n      if (!tokens) return;\n      if (sized) rebakeGrainIfNeeded(); // stale-theme grain tile would otherwise persist\n      if (reduced) drawStatic();\n      else if (sized) draw(performance.now() / 1000);\n      kick();\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    const io = new IntersectionObserver((entries) => {\n      inView = entries[0]?.isIntersecting ?? true;\n      if (inView) {\n        tokens = readTokens() ?? tokens; // pick up a theme flip that happened while offscreen\n        isDark = document.documentElement.classList.contains(\"dark\");\n        resumeLoop();\n      }\n    });\n    io.observe(wrap);\n\n    const onVisibility = () => {\n      pageVisible = document.visibilityState !== \"hidden\";\n      if (pageVisible) resumeLoop();\n    };\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    boot();\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      cancelAnimationFrame(tokenWaitRaf);\n      ro.disconnect();\n      mo.disconnect();\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n    const form = e.currentTarget;\n    const input = form.elements.namedItem(\"email\") as HTMLInputElement | null;\n    onSubscribe?.(input?.value ?? \"\");\n    form.reset();\n  };\n\n  return (\n    <footer\n      data-joint-iron\n      ref={wrapRef}\n      className={`relative w-full min-h-[380px] overflow-hidden ${className}`}\n    >\n      <canvas ref={canvasRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 h-full w-full\" />\n\n      {/* content clears the forming zone (groove centre is capped at 132px)\n          and sits directly on the case — no scrim panel, or the footer reads\n          as a card floating on an unexplained grey band */}\n      <div className=\"relative z-10 mx-auto max-w-5xl px-4 pt-40 pb-10 sm:px-6 sm:pt-44\">\n        <div>\n          <div className=\"flex flex-col gap-8 sm:flex-row sm:items-start sm:justify-between\">\n            <div className=\"max-w-xs\">\n              <p className=\"font-mono text-sm font-semibold tracking-tight text-foreground\">{brand}</p>\n              <p className=\"mt-2 text-sm leading-relaxed text-ns-muted\">{tagline}</p>\n              <form onSubmit={handleSubmit} className=\"mt-4 flex max-w-xs gap-2\">\n                <label htmlFor={emailId} className=\"sr-only\">\n                  Email address\n                </label>\n                <input\n                  id={emailId}\n                  name=\"email\"\n                  type=\"email\"\n                  required\n                  placeholder={newsletterPlaceholder}\n                  className=\"w-full min-w-0 rounded-sm bg-background/60 px-2.5 py-1.5 text-sm text-foreground placeholder:text-ns-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n                />\n                <button\n                  type=\"submit\"\n                  className=\"shrink-0 rounded-sm bg-ns-accent px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-ns-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n                >\n                  Subscribe\n                </button>\n              </form>\n            </div>\n\n            <div className=\"grid grid-cols-2 gap-x-8 gap-y-6 sm:grid-cols-4\">\n              {columns.map((col, i) => (\n                // the sitemap's own column rule — one of the two places\n                // --border is allowed as a stroke (section 9)\n                <div key={col.heading} className={i === 0 ? \"\" : \"border-l border-border pl-6\"}>\n                  <h2 className=\"font-mono text-xs font-semibold uppercase tracking-[0.15em] text-foreground\">\n                    {col.heading}\n                  </h2>\n                  <ul className=\"mt-3 flex flex-col gap-2\">\n                    {col.links.map((link) => (\n                      <li key={link.href}>\n                        <a\n                          href={link.href}\n                          className=\"rounded-sm text-sm text-ns-muted transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n                        >\n                          {link.label}\n                        </a>\n                      </li>\n                    ))}\n                  </ul>\n                </div>\n              ))}\n            </div>\n          </div>\n\n          <div className=\"mt-8 border-t border-border pt-4\">\n            <p className=\"font-mono text-xs text-ns-muted\">\n              © {new Date().getFullYear()} {brand} — {legal}\n            </p>\n          </div>\n        </div>\n      </div>\n    </footer>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/joint-iron.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-ns-accent-hover": "var(--ns-accent-hover)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "ns-accent-hover": "#0059d1"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "loud",
    "tags": [
      "footer",
      "sitemap",
      "canvas",
      "groove",
      "bookbinding",
      "press",
      "newsletter"
    ],
    "instruction": "Build <JointIron brand? tagline? columns? legal? newsletterPlaceholder? onSubscribe? className?> where columns is JointIronColumn[] ({heading, links: {label, href}[]}), defaulting to a 4-column sitemap so the component needs zero props. STRUCTURE: a <footer data-joint-iron> whose canvas is the ENTIRE footer surface (absolute inset-0, not a strip above the content) — a scrim panel (bg-background/70 backdrop-blur-sm) holding the wordmark, a newsletter form, the sitemap columns and a copyright line sits in normal DOM flow starting below the forming zone, on top of the SAME case tone the groove is cut into. THE MECHANIC, real and load-bearing: the top of the footer is a book joint being formed in a building-in machine. One height field per station, g(x): a cosine bump centred on the station (bump(d) = depth * 0.5 * (1 + cos(pi*d/hw)), d = x - stationCenterX, hw = 1.44 * depth — the groove's WIDTH shrinks with its depth, not just its floor). Case surface is bg mixed with fg at alpha 0.16 (light) / 0.22 (dark), painted below a resting line at min(0.14 * canvasHeight, 56px) — clamped so the groove zone always clears the DOM scrim's top offset even on a very tall footer. The groove is carried ENTIRELY by a two-facet Lambert-style shade, never by swapping in raw --background (that reads as a stroke, not a formed material): each column's shaded rect runs from the resting line down by `bump` (so the shaded region's own height, not just a boundary line, grows with depth), and its colour is chosen from shade = clamp(-0.35 + wallFactor, -1, 1), wallFactor = -sin(pi*d/hw) — the -0.35 ambient term (builder-chosen, not in the spec) sinks the whole notch a little below the flat case so the floor, where wallFactor -> 0, still reads as a shadowed recess instead of vanishing back to case tone. NAMED REGRESSION, kept here because it is the exact failure mode that got a prior footer (footing-course) removed from the registry: the first build of this component rendered the notch by reverting the dipped region to raw --background and drawing a thin lit lip along the new boundary, which reads as 'an animated top border' — kill criterion territory, since a pressed groove is still case material, recessed and lit, not a hole cut through it. Caught in review before ship and reworked to the shade-fill approach above; if this component is ever rebuilt from this instruction alone, do NOT reintroduce a --background erase inside the notch. Positive shade composites --background (dark theme: --foreground) at up to alpha 0.34 * shade; negative shade composites the opposite token at up to alpha 0.34*(0.17/0.15) * -shade — the 0.15/0.17 asymmetry is the spec's real facet-contrast numbers, translated into canvas alpha rather than literal luminance math. THREE STATIONS, one per third of the canvas width, each independently running a 7.4s five-phase cycle offset by 2.6s from its neighbour (deliberately not an exact third, tuned so prefers-reduced-motion's frozen frame shows one station mid-release, one mid-dwell, one mid-index at once): index (0.85s, the previous cycle's finished groove — held depth = 0.86 of the dwell-end depth, i.e. the post-spring-back value — slides out of the station's clip rect while nothing new is drawn, standing in for the unformed arrival), descent (0.62s, depth follows 1 - exp(-t/0.28), reaching 0.89 of g_max = 0.055 * min(canvasWidth, canvasHeight)), dwell (3.6s, depth creeps the last 11% toward g_max on a 1.4s time constant — the longest phase, deliberately, since setting a joint is mostly waiting), release (0.45s, THE SIGNATURE MOMENT: over the first 170ms, depth drops by exactly 14% via an eased spring, and simultaneously an extra 'shoulder-rounding' factor (1 - 0.09*ease) multiplies the facet contrast on top of the depth-linear scaling, so all three channels — floor depth, facet width (hw = 1.44*depth, so it narrows 14% too), and facet contrast — move together over the same 170ms; depth alone is below the perceptual floor at card scale and must not be trusted on its own), lift (1.88s, the iron rises over its first 300ms and a 700ms heat-shimmer runs along its underside). THE IRON is a solid --foreground rectangle (never tinted, never given --ns-accent — this is the trap the spec calls out explicitly) whose fill alpha hunts between roughly 0.82 and 0.90 on an unconditional 2.1s sine (its thermostat cycling, running whether the iron is up or down) and whose base alpha (0.86, not 1.0) exists specifically to leave headroom for the shimmer: the shimmer is a shadowBlur glow using the SAME --foreground token as both fillStyle and shadowColor, which is the only token-only way to 'brighten' a silhouette that is already at the bright extreme of the palette in dark theme and the dark extreme in light theme. A static seeded board-grain texture (mulberry32, fixed seed, baked once per resize AND re-baked on a theme flip to an offscreen tile, blitted every frame, never regenerated per-frame) fills the case below the groove zone; below a 88px canvas height, station count drops from 3 to 2 and the grain is dropped, while the groove and iron are kept, because the groove is the component. Reduced motion draws exactly one frame at STATIC_TIME = 2.62s via the same pure draw(t) function used by the live loop — the iron's thermostat term is also a function of t, not performance.now(), specifically so re-renders of the frozen frame stay byte-stable rather than resampling wall-clock. Canvas: DPR capped at 2, ResizeObserver on the footer element (not window), and the rAF loop runs only when an IntersectionObserver (threshold 0) reports the footer intersecting AND document.visibilityState is 'visible' — both conditions tracked separately so a background tab that scrolls the footer into view, or a foreground tab where it's merely offscreen, cannot restart the loop on its own — plus an adaptive column-step (1px -> 2px) that only widens after 1.5s of sustained sub-30fps frames, gated on wall-clock time, never frame count. Tokens (--background, --foreground, --ns-muted, --border) read via getComputedStyle(document.documentElement) with no literal fallback and re-read on a MutationObserver watching documentElement's class; nothing paints until both resolve, guarded on the rAF-driven boot retry, the ResizeObserver callback and the IntersectionObserver resume path. --border appears only as the 1px hairline between the canvas band and the page above and as the sitemap's column rules — never as a fill, never as the groove. --ns-accent appears only on the newsletter submit button and on focus-visible rings. The DOM footer content (wordmark, newsletter form with a real onSubscribe(email) callback, sitemap columns, legal line) is real, keyboard-navigable, semantically ordered <footer> markup sitting in a bg-background/70 backdrop-blur-sm scrim over the case tone — it is never canvas text."
  },
  "type": "registry:ui"
}