{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "press",
  "type": "registry:ui",
  "title": "Press",
  "description": "A variable-font pressure headline — the line behaves as a press bed under the cursor: glyph weight swells with a gaussian falloff while nearby letters rise and letter-spacing opens around the pressure point. Idle, the weight axis breathes almost imperceptibly. Pure rAF over font-variation-settings; real text in the DOM, static mid-weight under reduced motion; a ghost pressure point sweeps the line when untouched.",
  "dependencies": [],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json",
    "https://crucibleui.com/r/use-visibility-pause.json"
  ],
  "files": [
    {
      "path": "registry/default/text/press/press.tsx",
      "type": "registry:ui",
      "target": "components/crucible/press.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { useVisibilityPause } from \"@/hooks/use-visibility-pause\";\n\nexport interface PressProps extends Omit<React.ComponentPropsWithoutRef<\"span\">, \"children\"> {\n  /** The headline text. Server-renders as plain text; the effect attaches after hydration. */\n  children: string;\n  /** Resting font weight for untouched glyphs. @default 320 */\n  minWeight?: number;\n  /** Font weight a glyph reaches directly under the cursor. @default 900 */\n  maxWeight?: number;\n  /** Radius in px of the pressure field around the cursor. @default 160 */\n  radius?: number;\n  /** How far, in px, glyphs near the cursor rise off the baseline. @default 6 */\n  lift?: number;\n  /** How far, in em, letter-spacing opens around the pressure point. @default 0.06 */\n  spread?: number;\n  /**\n   * Font stack. Must expose a variable `wght` axis for the swell to render;\n   * the default system stack is variable on every modern platform.\n   * @default variable system-ui stack\n   */\n  fontFamily?: string;\n  /** Freeze the pressure field (and the idle breathing). @default false */\n  paused?: boolean;\n  /**\n   * Idle autoplay: when the cursor hasn't moved for 1.5s (or isn't over the\n   * page) and isn't pressing the line, a ghost pressure point sweeps slowly\n   * back and forth along the headline at reduced strength. A real pointer\n   * takes over instantly. Off under reduced motion / coarse pointers. @default true\n   */\n  idle?: boolean;\n}\n\nconst IDLE_MS = 1500;\n/** Ghost pressure relative to a real cursor — deliberate, never full weight. */\nconst GHOST_GAIN = 0.7;\n\nconst DEFAULT_FONT =\n  \"system-ui, -apple-system, 'Segoe UI Variable Display', 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif\";\n\n/** True on touch-first devices, where cursor pressure is meaningless. SSR-safe: false on the server. */\nfunction useCoarsePointer(): boolean {\n  return React.useSyncExternalStore(\n    subscribeCoarse,\n    () => window.matchMedia(\"(pointer: coarse)\").matches,\n    () => false\n  );\n}\n\nfunction subscribeCoarse(callback: () => void): () => void {\n  const mql = window.matchMedia(\"(pointer: coarse)\");\n  mql.addEventListener(\"change\", callback);\n  return () => mql.removeEventListener(\"change\", callback);\n}\n\n/**\n * Press — a variable-font pressure headline. The whole line behaves as a\n * press bed under the cursor: glyph weight swells with a gaussian proximity\n * falloff while nearby letters rise off the baseline and letter-spacing opens\n * around the pressure point, so the line visibly deforms as a field rather\n * than one glyph at a time. Idle, the weight axis breathes almost\n * imperceptibly so the headline reads as alive before first hover.\n *\n * Styles are written imperatively per frame (rAF lerp — no CSS transitions on\n * `font-variation-settings`, which are janky) and the loop pauses offscreen\n * and on hidden tabs. Server-side it renders the intact string, splitting\n * into per-glyph spans only after mount, so hydration always matches and\n * crawlers see real text. Coarse pointers and reduced motion get a static\n * mid-weight treatment. Untouched, a ghost pressure point sweeps the line\n * (`idle`) until the real cursor takes over.\n */\nexport function Press({\n  children,\n  minWeight = 320,\n  maxWeight = 900,\n  radius = 160,\n  lift = 6,\n  spread = 0.06,\n  fontFamily = DEFAULT_FONT,\n  paused = false,\n  idle = true,\n  className,\n  style,\n  ...props\n}: PressProps) {\n  const reducedMotion = useReducedMotion();\n  const coarse = useCoarsePointer();\n  const [mounted, setMounted] = React.useState(false);\n  React.useEffect(() => setMounted(true), []);\n\n  const isStatic = reducedMotion || coarse;\n  const midWeight = (minWeight + maxWeight) / 2;\n\n  // Split into words (trailing spaces attached) so line-wrapping matches the\n  // plain server-rendered text, then into characters within each word.\n  const wordData = React.useMemo(() => {\n    let offset = 0;\n    return children.split(/(?<=\\s)/).map((word) => {\n      const chars = Array.from(word);\n      const start = offset;\n      offset += chars.length;\n      return { chars, start };\n    });\n  }, [children]);\n  const totalChars = React.useMemo(\n    () => wordData.reduce((sum, w) => sum + w.chars.length, 0),\n    [wordData]\n  );\n\n  const charRefs = React.useRef<(HTMLSpanElement | null)[]>([]);\n  const influences = React.useRef<number[]>([]);\n  const pointer = React.useRef({ x: 0, y: 0, has: false });\n  const lastMoveRef = React.useRef(0);\n  const ghostRef = React.useRef({ presence: 0, t: 0, last: 0 });\n\n  React.useEffect(() => {\n    if (isStatic || !mounted) return;\n    const onMove = (event: PointerEvent) => {\n      if (event.pointerType === \"touch\") return;\n      pointer.current.x = event.clientX;\n      pointer.current.y = event.clientY;\n      pointer.current.has = true;\n      lastMoveRef.current = performance.now();\n    };\n    const onLeave = () => {\n      pointer.current.has = false;\n      lastMoveRef.current = performance.now();\n    };\n    lastMoveRef.current = performance.now();\n    window.addEventListener(\"pointermove\", onMove, { passive: true });\n    document.documentElement.addEventListener(\"pointerleave\", onLeave);\n    return () => {\n      window.removeEventListener(\"pointermove\", onMove);\n      document.documentElement.removeEventListener(\"pointerleave\", onLeave);\n    };\n  }, [isStatic, mounted]);\n\n  // Reset imperative state whenever the text or endpoints change.\n  React.useEffect(() => {\n    charRefs.current.length = totalChars;\n    influences.current = new Array<number>(totalChars).fill(0);\n    for (const el of charRefs.current) {\n      if (!el) continue;\n      el.style.fontVariationSettings = `'wght' ${minWeight}`;\n      el.style.transform = \"translateY(0px)\";\n      el.style.letterSpacing = \"0em\";\n    }\n  }, [totalChars, minWeight, maxWeight]);\n\n  // The observed element, mirrored for the rAF tick (set after commit).\n  const elRef = React.useRef<HTMLElement | null>(null);\n  const wrapperRef = useVisibilityPause<HTMLSpanElement>(\n    (elapsed) => {\n      const spans = charRefs.current;\n      const p = pointer.current;\n      const sigma = radius / 2;\n      const g = ghostRef.current;\n      if (elapsed < g.last) g.last = elapsed;\n      const dt = Math.min(elapsed - g.last, 0.05);\n      g.last = elapsed;\n\n      // Read pass first (rects), write pass second — one forced layout per frame.\n      const targets = new Array<number>(spans.length).fill(0);\n      const centers = new Array<number>(spans.length * 2).fill(0);\n      let realPeak = 0;\n      for (let i = 0; i < spans.length; i++) {\n        const el = spans[i];\n        if (!el) continue;\n        const rect = el.getBoundingClientRect();\n        const cx = rect.left + rect.width / 2;\n        const cy = rect.top + rect.height / 2;\n        centers[i * 2] = cx;\n        centers[i * 2 + 1] = cy;\n        if (p.has) {\n          const dx = cx - p.x;\n          const dy = cy - p.y;\n          targets[i] = Math.exp(-(dx * dx + dy * dy) / (2 * sigma * sigma));\n          if (targets[i] > realPeak) realPeak = targets[i];\n        }\n      }\n\n      // Idle ghost: a pressure point sweeping the line, eased in and out. It\n      // yields the moment the real cursor moves or presses on the text.\n      const wantGhost =\n        idle && realPeak < 0.05 && performance.now() - lastMoveRef.current > IDLE_MS;\n      g.presence += ((wantGhost ? 1 : 0) - g.presence) * Math.min(1, dt * (wantGhost ? 1.2 : 6));\n      if (g.presence > 0.005 && spans.length > 0) {\n        g.t += dt;\n        const wrap = elRef.current?.getBoundingClientRect();\n        if (wrap) {\n          // Eased ping-pong along the measured line width, with a gentle bob.\n          const s = 0.5 - 0.5 * Math.cos(g.t * 0.55);\n          const gx = wrap.left + wrap.width * (0.04 + 0.92 * s);\n          const gy = wrap.top + wrap.height * (0.5 + 0.18 * Math.sin(g.t * 1.3));\n          const k = g.presence * GHOST_GAIN;\n          for (let i = 0; i < spans.length; i++) {\n            const dx = centers[i * 2] - gx;\n            const dy = centers[i * 2 + 1] - gy;\n            const ghostT = Math.exp(-(dx * dx + dy * dy) / (2 * sigma * sigma)) * k;\n            if (ghostT > (targets[i] ?? 0)) targets[i] = ghostT;\n          }\n        }\n      }\n\n      const weightSpan = maxWeight - minWeight;\n      for (let i = 0; i < spans.length; i++) {\n        const el = spans[i];\n        if (!el) continue;\n        const cur = influences.current[i] ?? 0;\n        const next = cur + ((targets[i] ?? 0) - cur) * 0.16;\n        influences.current[i] = next;\n        // Idle breathing: a barely-perceptible traveling swell of the weight\n        // axis, fading out wherever real cursor pressure takes over.\n        const breath = 0.05 * (0.5 + 0.5 * Math.sin(elapsed * 0.9 + i * 0.42)) * (1 - next);\n        const t = Math.min(1, next + breath);\n        el.style.fontVariationSettings = `'wght' ${(minWeight + weightSpan * t).toFixed(1)}`;\n        el.style.transform = `translateY(${(-lift * next).toFixed(2)}px)`;\n        el.style.letterSpacing = next > 0.005 ? `${(spread * next).toFixed(4)}em` : \"0em\";\n      }\n    },\n    { paused: paused || isStatic || !mounted }\n  );\n\n  React.useEffect(() => {\n    elRef.current = wrapperRef.current;\n  });\n\n  const baseStyle: React.CSSProperties = {\n    fontFamily,\n    fontVariationSettings: `'wght' ${isStatic ? midWeight : minWeight}`,\n    ...style,\n  };\n\n  // Server render + reduced motion / coarse pointer: the intact string, no\n  // per-glyph spans — identical markup server and client, real text for\n  // crawlers, static mid-weight when the effect can't run.\n  if (!mounted || isStatic) {\n    return (\n      <span\n        ref={wrapperRef}\n        data-crucible=\"press\"\n        className={cn(\"inline-block\", className)}\n        style={baseStyle}\n        {...props}\n      >\n        {children}\n      </span>\n    );\n  }\n\n  return (\n    <span\n      ref={wrapperRef}\n      data-crucible=\"press\"\n      aria-label={children}\n      className={cn(\"inline-block\", className)}\n      style={baseStyle}\n      {...props}\n    >\n      {wordData.map((word, wi) => (\n        <span key={wi} aria-hidden className=\"inline-block whitespace-pre\">\n          {word.chars.map((ch, ci) => {\n            const index = word.start + ci;\n            return (\n              <span\n                key={ci}\n                ref={(el) => {\n                  charRefs.current[index] = el;\n                }}\n                className=\"inline-block whitespace-pre will-change-transform\"\n                style={{ fontVariationSettings: `'wght' ${minWeight}` }}\n              >\n                {ch}\n              </span>\n            );\n          })}\n        </span>\n      ))}\n    </span>\n  );\n}\n"
    }
  ]
}