{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "mailbag-hook-exchange",
  "title": "Mailbag Hook Exchange",
  "description": "A full-bleed ambient background reproducing the Railway Post Office catcher-crane mail exchange: a trackside crane holds an outgoing mailbag on a hook, swaying between passes, while a train sweeps through on a fixed cadence, its catcher arm snagging the bag and kicking a replacement onto the trackside net without ever stopping.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/mailbag-hook-exchange/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport type { ReactNode } from \"react\";\n\n// ---------------------------------------------------------------------------\n// MailbagHookExchange — a full-bleed ambient background reproducing the\n// Railway Post Office (RPO) catcher-crane mail exchange (US mail trains,\n// roughly 1864-1977): a trackside crane holds an outgoing mailbag on an iron\n// hook; a passing train's catcher arm snags it and kicks a replacement bag\n// onto the trackside net in the same motion, without the train ever\n// stopping. Real engagement was well under a second and real headway\n// between exchanges was minutes to hours — both are DECOUPLED here for\n// legibility, not animated 1:1 (see round 9's meter-matrix-scan lesson):\n// the exchange gesture is stretched to a scripted ~900ms sequence and the\n// wait between exchanges is compressed to a fixed 7.0s cadence so the wait\n// itself reads as anticipation rather than as a broken/idle component.\n//\n// ONE GOVERNING CLOCK. Everything derives from a single elapsed-seconds\n// scalar `t`. Train position, arm gesture phase, and completed-exchange\n// count are all pure functions of `t % CYCLE` (or, for the exchange count,\n// how many CONTACT instants have passed) — there is no separate per-element\n// timer to drift out of sync.\n//\n// ALIVE AT REST, between exchanges: the crane arm is a damped pendulum that\n// never fully stops — each exchange's kick leaves it swinging at PEAK_DEG,\n// decaying (tau ~0.9s) toward a PENDULUM_FLOOR_DEG resting tremor it never\n// drops below, at PENDULUM_PERIOD (1.6s). Independently, a signal lamp on\n// the crane post blinks at 0.5Hz (2s period) continuously, on its own\n// clock — with CYCLE=7s and LAMP_PERIOD=2s the two motions' phase pairing\n// at any given t0 only repeats every lcm(7,2)=14s, so back-to-back 5s\n// screenshots essentially never look identical. Rail ties scroll left at a\n// constant rate at all times, whether or not a train is on screen.\n//\n// THE EXCHANGE. CONTACT_T (derived below from the crane's x-fraction, the\n// train's length fraction, and the crossing span/duration, not hardcoded)\n// is the instant the train's marked engagement point reaches the crane.\n// The gesture around it: 280ms swing-out (rest angle -> extended angle,\n// ease-in-out), 120ms hold (hook engaged — this is the freeze-frame\n// moment), 500ms swing-back (extended -> rest, ease-in-out). At the hold's\n// midpoint the held-bag pattern flips (a fixed alternation, not per-frame\n// randomness) and a small kicked-bag glyph streaks from the engagement\n// point down into the trackside net, fading over the swing-back window —\n// the visible \"something left, something arrived\" beat.\n// ---------------------------------------------------------------------------\n\nconst CYCLE = 7.0; // seconds between exchanges — fixed cadence, not jitter\nconst CROSS_DURATION = 2.6; // seconds the train spends fully sweeping the frame\nconst ENTER_FRAC = -0.2; // train's leading edge starts here (fraction of width)\nconst SPAN_FRAC = 1.4; // total leading-edge travel across the crossing (fraction of width)\nconst CRANE_X_FRAC = 0.62; // crane position, fraction of width\nconst TRAIN_LEN_FRAC = 0.26; // train body length, fraction of width\nconst ENGAGE_OFFSET_FRAC = 0.4; // engagement marker's offset back from the train's leading edge, as a fraction of train length\n\n// the instant the train's engagement marker aligns with the crane, derived\n// (not guessed) from the geometry constants above so gesture timing and\n// visual alignment can never drift apart.\nconst CONTACT_T =\n  (CROSS_DURATION *\n    (CRANE_X_FRAC + TRAIN_LEN_FRAC * ENGAGE_OFFSET_FRAC - ENTER_FRAC)) /\n  SPAN_FRAC;\n\nconst SWING_OUT = 0.28;\nconst HOLD = 0.12;\nconst SWING_BACK = 0.5;\nconst GESTURE_START = CONTACT_T - SWING_OUT;\nconst GESTURE_END = GESTURE_START + SWING_OUT + HOLD + SWING_BACK;\n\nconst REST_DEG = 0; // arm hangs straight down at rest\nconst EXTENDED_DEG = 34; // arm swung out toward the oncoming train\nconst PENDULUM_PERIOD = 1.6; // seconds, matches the spec's idle-sway rate\nconst PENDULUM_OMEGA = (2 * Math.PI) / PENDULUM_PERIOD;\nconst PENDULUM_FLOOR_DEG = 2; // resting tremor amplitude, never fully still\nconst PENDULUM_PEAK_DEG = 18; // amplitude right after a kick\nconst PENDULUM_TAU = 0.9; // decay time constant, seconds\n\nconst LAMP_PERIOD = 2.0; // seconds, independent clock from CYCLE\n\nconst TIE_SPEED = 40; // px/s, constant regardless of train visibility\n\n// the freeze frame prefers-reduced-motion lands on: mid-HOLD, arm fully\n// extended, hook engaged, train mid-crossing — the single most structured\n// instant the loop produces (crane, train, and both bags legible at once).\nconst CONTACT_FRAME = GESTURE_START + SWING_OUT + HOLD / 2;\n\nfunction easeInOut(x: number): number {\n  const c = x < 0 ? 0 : x > 1 ? 1 : x;\n  return c * c * (3 - 2 * c);\n}\n\n/** Arm angle in degrees at gesture-relative time `g` (seconds since GESTURE_START, may be negative). */\nfunction gestureAngle(g: number): number {\n  if (g < 0 || g > SWING_OUT + HOLD + SWING_BACK) return REST_DEG;\n  if (g < SWING_OUT) return REST_DEG + (EXTENDED_DEG - REST_DEG) * easeInOut(g / SWING_OUT);\n  if (g < SWING_OUT + HOLD) return EXTENDED_DEG;\n  const back = g - SWING_OUT - HOLD;\n  return EXTENDED_DEG + (REST_DEG - EXTENDED_DEG) * easeInOut(back / SWING_BACK);\n}\n\n/** Idle pendulum tremor (degrees) added on top of the gesture angle, given seconds since the last gesture ended. */\nfunction pendulumTremor(sincePass: number): number {\n  const s = Math.max(0, sincePass);\n  const amp = PENDULUM_FLOOR_DEG + (PENDULUM_PEAK_DEG - PENDULUM_FLOOR_DEG) * Math.exp(-s / PENDULUM_TAU);\n  return amp * Math.sin(PENDULUM_OMEGA * s);\n}\n\nexport interface MailbagHookExchangeProps {\n  /** headline / CTA rendered above the field */\n  children?: ReactNode;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n  /** inline styles merged onto the root element */\n  style?: React.CSSProperties;\n}\n\nexport function MailbagHookExchange({ children, className = \"\", style }: MailbagHookExchangeProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    if (!root || !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    // token fields start empty; draw() below refuses to paint until\n    // readTokens() has run at least once — closes rAF, resize, and the\n    // reduced-motion branch as paint-before-read paths.\n    let fgColor = \"\";\n    let mutedColor = \"\";\n    let bgColor = \"\";\n    const readTokens = () => {\n      const cs = getComputedStyle(document.documentElement);\n      fgColor = cs.getPropertyValue(\"--foreground\").trim();\n      mutedColor = cs.getPropertyValue(\"--ns-muted\").trim();\n      bgColor = cs.getPropertyValue(\"--background\").trim();\n    };\n\n    let w = 0;\n    let h = 0;\n    let sized = false;\n    let visible = true;\n    let raf = 0;\n    let t = reduced ? CONTACT_FRAME : 0;\n    let last = 0;\n\n    const resize = () => {\n      const rect = root.getBoundingClientRect();\n      if (rect.width < 2 || rect.height < 2) {\n        sized = false;\n        return;\n      }\n      const isCard = !!canvas.closest(\"[data-autoplay-root]\");\n      const dpr = isCard\n        ? Math.min(0.6, window.devicePixelRatio || 1)\n        : Math.min(window.devicePixelRatio || 1, 1.5);\n      w = rect.width;\n      h = rect.height;\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      sized = true;\n    };\n\n    const draw = () => {\n      if (!sized || !fgColor) return;\n      ctx.clearRect(0, 0, w, h);\n\n      const trackY = h * 0.74;\n      const craneX = w * CRANE_X_FRAC;\n      const craneBaseY = trackY;\n      const craneHeight = Math.min(w, h) * 0.22;\n      const craneTopY = craneBaseY - craneHeight;\n      const armLength = Math.min(w, h) * 0.13;\n      const bagSize = Math.min(w, h) * 0.045;\n      const trainLength = w * TRAIN_LEN_FRAC;\n      const trainHeight = Math.min(w, h) * 0.09;\n      const tieSpacing = Math.max(10, Math.min(w, h) * 0.05);\n\n      // --- rail + scrolling ties (structure, low emphasis, drawn always) ---\n      ctx.strokeStyle = mutedColor;\n      ctx.lineWidth = 1;\n      ctx.beginPath();\n      ctx.moveTo(0, trackY);\n      ctx.lineTo(w, trackY);\n      ctx.stroke();\n\n      const tieOffset = (t * TIE_SPEED) % tieSpacing;\n      ctx.lineWidth = 2;\n      for (let x = -tieOffset; x < w + tieSpacing; x += tieSpacing) {\n        ctx.beginPath();\n        ctx.moveTo(x, trackY - 3);\n        ctx.lineTo(x, trackY + 6);\n        ctx.stroke();\n      }\n\n      // --- crane post + net (structure) ---\n      ctx.strokeStyle = mutedColor;\n      ctx.lineWidth = 2;\n      ctx.beginPath();\n      ctx.moveTo(craneX, craneBaseY);\n      ctx.lineTo(craneX, craneTopY);\n      ctx.stroke();\n\n      const netY = craneBaseY - craneHeight * 0.12;\n      const netX = craneX - armLength * 0.55;\n      ctx.beginPath();\n      ctx.arc(netX, netY, bagSize * 0.9, Math.PI * 0.15, Math.PI * 0.85);\n      ctx.stroke();\n\n      // --- signal lamp (independent 0.5Hz clock, luminance only) ---\n      const lampOn = Math.floor(t / (LAMP_PERIOD / 2)) % 2 === 0;\n      ctx.fillStyle = lampOn ? fgColor : mutedColor;\n      ctx.beginPath();\n      ctx.arc(craneX, craneTopY - bagSize * 0.6, bagSize * 0.35, 0, Math.PI * 2);\n      ctx.fill();\n\n      // --- arm + hook + held bag (subject, brighter emphasis) ---\n      const g = t - GESTURE_START - Math.floor((t - GESTURE_START) / CYCLE) * CYCLE;\n      // g is \"seconds since this cycle's GESTURE_START\", wrapped into [0, CYCLE)\n      const inGesture = g >= 0 && g <= SWING_OUT + HOLD + SWING_BACK;\n      const armDeg = inGesture ? gestureAngle(g) : REST_DEG;\n      const sincePass = inGesture ? 0 : g > SWING_OUT + HOLD + SWING_BACK ? g - (SWING_OUT + HOLD + SWING_BACK) : g + CYCLE - (SWING_OUT + HOLD + SWING_BACK);\n      const tremor = inGesture ? 0 : pendulumTremor(sincePass);\n      const angleRad = ((armDeg + tremor) * Math.PI) / 180;\n\n      // arm pivots at the crane top; hooked bag hangs `armLength` from the\n      // pivot at angle `angleRad` from straight-down (angle 0 = REST_DEG)\n      const hookX = craneX + Math.sin(angleRad) * armLength;\n      const hookY = craneTopY + Math.cos(angleRad) * armLength;\n\n      ctx.strokeStyle = fgColor;\n      ctx.lineWidth = 2;\n      ctx.beginPath();\n      ctx.moveTo(craneX, craneTopY);\n      ctx.lineTo(hookX, hookY);\n      ctx.stroke();\n\n      // completed-exchange count -> which bag pattern is currently held\n      const completed = t >= CONTACT_T ? Math.floor((t - CONTACT_T) / CYCLE) + 1 : 0;\n      const variant = completed % 2;\n\n      ctx.fillStyle = fgColor;\n      ctx.beginPath();\n      ctx.roundRect(hookX - bagSize / 2, hookY, bagSize, bagSize * 1.15, bagSize * 0.2);\n      ctx.fill();\n      if (variant === 1) {\n        // alternate fill pattern: a couple of thin horizontal bands, drawn\n        // in the background color so it reads as a different sack, never a\n        // hue change\n        ctx.strokeStyle = bgColor;\n        ctx.lineWidth = 1;\n        for (let i = 1; i <= 2; i++) {\n          const by = hookY + (bagSize * 1.15 * i) / 3;\n          ctx.beginPath();\n          ctx.moveTo(hookX - bagSize / 2 + 1, by);\n          ctx.lineTo(hookX + bagSize / 2 - 1, by);\n          ctx.stroke();\n        }\n      }\n\n      // --- kicked bag streak toward the net, only during hold + swing-back ---\n      if (inGesture && g >= SWING_OUT) {\n        const kickG = g - SWING_OUT; // 0 at hold start, through HOLD+SWING_BACK\n        const kickDur = HOLD + SWING_BACK;\n        const kp = easeInOut(kickG / kickDur);\n        const kx = hookX + (netX - hookX) * kp;\n        const ky = hookY + (netY - hookY) * kp;\n        ctx.globalAlpha = 1 - kp * 0.85;\n        ctx.fillStyle = fgColor;\n        ctx.beginPath();\n        ctx.roundRect(kx - bagSize / 2.6, ky - bagSize * 0.5, bagSize * 0.77, bagSize * 0.9, bagSize * 0.15);\n        ctx.fill();\n        ctx.globalAlpha = 1;\n      }\n\n      // --- train silhouette, visible only within its crossing window ---\n      const phase = t - Math.floor(t / CYCLE) * CYCLE;\n      if (phase < CROSS_DURATION) {\n        const progress = phase / CROSS_DURATION;\n        const frontX = w * ENTER_FRAC + progress * w * SPAN_FRAC;\n        const rearX = frontX - trainLength;\n        const topY = trackY - trainHeight;\n\n        ctx.fillStyle = fgColor;\n        ctx.beginPath();\n        ctx.moveTo(rearX, trackY);\n        ctx.lineTo(rearX, topY);\n        ctx.lineTo(frontX - trainLength * 0.12, topY);\n        ctx.lineTo(frontX, trackY - trainHeight * 0.35);\n        ctx.lineTo(frontX, trackY);\n        ctx.closePath();\n        ctx.fill();\n      }\n    };\n\n    const loop = (now: number) => {\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;\n      last = now;\n      t += dt;\n      draw();\n      if (visible && !document.hidden) raf = requestAnimationFrame(loop);\n    };\n\n    let resizeTimer: ReturnType<typeof setTimeout> | null = null;\n    const ro = new ResizeObserver(() => {\n      if (resizeTimer) clearTimeout(resizeTimer);\n      resizeTimer = setTimeout(() => {\n        resizeTimer = null;\n        resize();\n        if (reduced) draw();\n      }, 120);\n    });\n    ro.observe(root);\n\n    const io = new IntersectionObserver(\n      (entries) => {\n        visible = entries[0]?.isIntersecting ?? true;\n        if (visible && !reduced && !raf) {\n          last = 0;\n          raf = requestAnimationFrame(loop);\n        }\n      },\n      { threshold: 0 }\n    );\n    io.observe(root);\n\n    const onVis = () => {\n      if (!document.hidden && visible && !reduced && !raf) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    const mo = new MutationObserver(() => {\n      readTokens();\n      if (reduced || !raf) draw();\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    readTokens();\n    resize();\n\n    if (reduced) {\n      draw();\n    } else {\n      raf = requestAnimationFrame(loop);\n    }\n\n    return () => {\n      cancelAnimationFrame(raf);\n      if (resizeTimer) clearTimeout(resizeTimer);\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n    };\n  }, []);\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative isolate min-h-screen w-full overflow-hidden bg-background ${className}`}\n      style={style}\n    >\n      <canvas\n        ref={canvasRef}\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 block h-full w-full\"\n      />\n      {children ? (\n        <div className=\"relative z-10 flex min-h-screen w-full flex-col items-center justify-center gap-4 px-6 text-center\">\n          {children}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/mailbag-hook-exchange.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "loud",
    "tags": [
      "background",
      "hero",
      "canvas",
      "rail",
      "postal",
      "exchange",
      "ambient"
    ],
    "instruction": "Build <MailbagHookExchange className? style?><...headline/CTA/></MailbagHookExchange> as a full-bleed Canvas 2D background reproducing the Railway Post Office (RPO) catcher-crane mail exchange used on US mail trains roughly 1864-1977: a trackside crane holds an outgoing mailbag on an iron hook at a fixed point along the line; a passing train's catcher arm swings out, snags the bag, and in the same motion kicks a replacement bag onto the trackside net, all without the train stopping. ONE GOVERNING CLOCK: every visual (train position, arm gesture phase, held-bag pattern, pendulum tremor, lamp blink, tie scroll) is a pure function of a single elapsed-seconds scalar `t`, no independent per-element timers. CYCLE = 7.0s between exchanges (a fixed cadence, not random jitter). CROSS_DURATION = 2.6s is how long the train is visible, sweeping its leading edge linearly from ENTER_FRAC=-0.2 to ENTER_FRAC+SPAN_FRAC=1.2 (fractions of container width) over that window, train body length TRAIN_LEN_FRAC=0.26 of width. CONTACT_T, the instant the train's engagement marker (ENGAGE_OFFSET_FRAC=0.4 of train length back from the leading edge) reaches the crane at CRANE_X_FRAC=0.62 of width, is DERIVED algebraically from those fractions and CROSS_DURATION (CONTACT_T = CROSS_DURATION*(CRANE_X_FRAC + TRAIN_LEN_FRAC*ENGAGE_OFFSET_FRAC - ENTER_FRAC)/SPAN_FRAC, works out to ~1.716s) rather than hardcoded, so gesture timing and visual alignment cannot drift apart if any fraction is tuned later. THE GESTURE (real engagement is 0.15-0.3s, undrawable at 60Hz — this is a documented, deliberate stretch to 900ms for legibility, not a 1:1 animation of the real rate): starting at GESTURE_START = CONTACT_T - 0.28s, the arm swings from REST_DEG=0 (hanging straight down) to EXTENDED_DEG=34 over 280ms (ease-in-out), holds at EXTENDED_DEG for 120ms (hook engaged — this is the CONTACT_FRAME reduced-motion freezes on, at the hold's midpoint), then swings back to REST_DEG over 500ms (ease-in-out). Arm pivots at the crane top; the hooked-bag position is hookX = craneX + sin(angleRad)*armLength, hookY = craneTopY + cos(angleRad)*armLength, angleRad including a pendulum tremor added on top (see below) even during rest frames. HELD-BAG ALTERNATION: a monotonic completed-exchange counter (floor((t-CONTACT_T)/CYCLE)+1 once t >= CONTACT_T, else 0) modulo 2 selects which of two bag renders is on the hook — variant 1 adds two thin --background-colored bands across the bag so a swap is visibly a DIFFERENT bag, never the same glyph re-drawn. KICKED-BAG STREAK: during the hold+swing-back window (620ms), a second small bag glyph eases (easeInOut) from the hook position to a net glyph (a small arc) near the crane's base, fading via globalAlpha from 1 to ~0.15 as it travels — the visible 'something left, something arrived' beat the round 9 legibility rule requires, not a blink-cut. IDLE PENDULUM (proves Filter 2 between exchanges): outside any gesture window, the arm angle gets an added tremor = (PENDULUM_FLOOR_DEG=2 + (PENDULUM_PEAK_DEG=18 - 2)*exp(-sincePass/0.9)) * sin(2*PI/1.6 * sincePass), where sincePass is seconds since the last gesture ended (wrapped through the 7s cycle) — amplitude decays from a hard kick down to, and never below, a 2deg resting tremor at a 1.6s period. SIGNAL LAMP: independent clock, `Math.floor(t / (LAMP_PERIOD/2)) % 2 === 0` with LAMP_PERIOD=2.0s toggles a small circle at the crane top between --foreground (lit) and --ns-muted (unlit) — luminance only, this is the standing accent-tint check: never mix --ns-accent into the lamp or the kicked-bag streak. Because CYCLE=7 and LAMP_PERIOD=2 share no common factor smaller than lcm(7,2)=14s, the lamp/pendulum phase pairing at any t0 essentially never repeats within a normal viewing window. RAIL + TIES: a horizontal --ns-muted line at trackY = 0.74*height across the full width, with perpendicular tie marks spaced tieSpacing = max(10, min(width,height)*0.05) apart, scrolling continuously at TIE_SPEED=40px/s via `(t*40) % tieSpacing`, drawn regardless of whether a train is currently visible — this is what keeps the frame alive even mid-idle-cycle. GEOMETRY: craneHeight = min(w,h)*0.22, armLength = min(w,h)*0.13, bagSize = min(w,h)*0.045, trainHeight = min(w,h)*0.09 — all derived from the container's smaller dimension so the scene reads correctly at both hero and card scale; trainLength stays a fraction of WIDTH (0.26) since it's a horizontal object whose crossing needs to scale with how wide the frame actually is. TOKENS: --foreground for the train silhouette, the hooked/kicked bag fills, and the lit lamp state; --ns-muted for the rail, ties, crane post, net, and unlit lamp state; --background used only as the bag's alternate-pattern stroke color (so the bands read as cutouts, not a hue). All three read via getComputedStyle(document.documentElement) at mount and re-read on a MutationObserver watching documentElement's class; nothing paints before that first read (guarded in draw()). DPR-capped canvas backing store (1.5 full-bleed, 0.6 inside an autoplay preview card via the same `canvas.closest('[data-autoplay-root]')` check other loud backgrounds use), ResizeObserver-driven resize, IntersectionObserver (threshold 0) plus visibilitychange pausing the rAF loop off-viewport or tab-hidden. prefers-reduced-motion freezes the clock at CONTACT_FRAME (GESTURE_START + 0.28 + 0.06s, i.e. mid-hold) rather than t=0, because that instant is the single most structured frame the loop produces: train mid-crossing, arm fully extended, hook visibly engaged, kicked bag mid-flight toward the net. Decorative canvas is aria-hidden and pointer-events-none; there is no pointer interaction anywhere in this component — `children` render as real DOM in normal focus order above it. Props: children, className, style."
  },
  "type": "registry:ui"
}