{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ghost-wire",
  "title": "GhostWire",
  "description": "Dashed co-change connector wire linking a solid changed endpoint chip to a dashed untouched endpoint chip, with stroke dash density mapped from strength and 12fps on-twos draw-on motion.",
  "registryDependencies": [
    "https://frame12.dev/r/frame12-styles.json",
    "https://frame12.dev/r/use-on-twos.json",
    "https://frame12.dev/r/use-tempo.json",
    "https://frame12.dev/r/tag.json"
  ],
  "files": [
    {
      "path": "ui/ghost-wire.tsx",
      "content": "'use client';\n\nimport * as React from 'react';\nimport { useOnTwos, type MotionMode } from '@/hooks/use-on-twos';\nimport { useResolvedMotion } from '@/hooks/use-tempo';\nimport { extractTextContent } from './tag';\n\nconst useIsoLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;\nlet ghostWireMaskCounter = 0;\n\nexport type GhostWireOrientation = 'auto' | 'horizontal' | 'vertical';\n\nexport interface GhostWireProps extends React.HTMLAttributes<HTMLDivElement> {\n  from: React.ReactNode;\n  to: React.ReactNode;\n  label?: string;\n  strength?: number;\n  orientation?: GhostWireOrientation;\n  motion?: MotionMode;\n}\n\nexport const GhostWire = React.forwardRef<HTMLDivElement, GhostWireProps>(function GhostWire(\n  {\n    from,\n    to,\n    label,\n    strength = 0.5,\n    orientation = 'auto',\n    motion = 'stepped',\n    className = '',\n    ...rest\n  },\n  ref,\n) {\n  const innerRef = React.useRef<HTMLDivElement | null>(null);\n  const setRefs = React.useCallback(\n    (node: HTMLDivElement | null) => {\n      innerRef.current = node;\n      if (typeof ref === 'function') {\n        ref(node);\n      } else if (ref) {\n        (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;\n      }\n    },\n    [ref],\n  );\n\n  const clampedStrength =\n    typeof strength === 'number' && Number.isFinite(strength)\n      ? Math.max(0, Math.min(1, strength))\n      : 0.5;\n  const safeOrientation: GhostWireOrientation =\n    orientation === 'horizontal' || orientation === 'vertical' || orientation === 'auto'\n      ? orientation\n      : 'auto';\n\n  const fromText = extractTextContent(from).trim() || 'source';\n  const toText = extractTextContent(to).trim() || 'target';\n  const reactId = React.useId();\n  const baseSlug = (rest.id || `${fromText}-${toText}`)\n    .replace(/[^a-zA-Z0-9_-]/g, '-')\n    .replace(/-+/g, '-')\n    .replace(/^-|-$/g, '')\n    .slice(0, 32);\n  const baseMaskId = `f12-gw-mask-${baseSlug ? `${baseSlug}-` : ''}${reactId.replace(/[^a-zA-Z0-9_-]/g, '')}`;\n  const [maskSuffix, setMaskSuffix] = React.useState<string>('');\n  const maskId = `${baseMaskId}${maskSuffix}`;\n  const [autoVertical, setAutoVertical] = React.useState<boolean>(false);\n\n  useIsoLayoutEffect(() => {\n    const el = innerRef.current;\n    if (!el || typeof document === 'undefined') return;\n    const ownMask = el.querySelector('mask');\n    const matching = document.querySelectorAll(`mask[id=\"${baseMaskId}\"]`);\n    if (matching.length > 1 && matching[0] !== ownMask) {\n      ghostWireMaskCounter += 1;\n      setMaskSuffix(`-r${ghostWireMaskCounter}`);\n    }\n  }, [baseMaskId]);\n\n  useIsoLayoutEffect(() => {\n    if (safeOrientation !== 'auto' || typeof window === 'undefined') {\n      setAutoVertical(false);\n      return;\n    }\n    const el = innerRef.current;\n    const mql = window.matchMedia ? window.matchMedia('(max-width: 519.98px)') : null;\n    const evaluate = (inlineWidth?: number) => {\n      const measured =\n        typeof inlineWidth === 'number' && inlineWidth > 0\n          ? inlineWidth\n          : el && el.clientWidth > 0\n            ? el.clientWidth\n            : 0;\n      if (measured > 0) {\n        setAutoVertical(measured < 520);\n      } else if (mql) {\n        setAutoVertical(mql.matches);\n      }\n    };\n    evaluate();\n\n    let ro: ResizeObserver | null = null;\n    if (el && typeof ResizeObserver !== 'undefined') {\n      ro = new ResizeObserver((entries) => {\n        const w = entries[0]?.contentRect?.width;\n        evaluate(w);\n      });\n      ro.observe(el);\n    }\n\n    const onMqlChange = () => evaluate();\n    if (mql) {\n      if (mql.addEventListener) {\n        mql.addEventListener('change', onMqlChange);\n      } else {\n        mql.addListener(onMqlChange);\n      }\n    }\n\n    return () => {\n      if (ro) ro.disconnect();\n      if (mql) {\n        if (mql.removeEventListener) {\n          mql.removeEventListener('change', onMqlChange);\n        } else {\n          mql.removeListener(onMqlChange);\n        }\n      }\n    };\n  }, [safeOrientation]);\n\n  const resolvedOrientation: 'horizontal' | 'vertical' =\n    safeOrientation === 'auto' ? (autoVertical ? 'vertical' : 'horizontal') : safeOrientation;\n\n  const defaultAccessibleLabel = `${fromText} usually changes with ${toText}, which was not changed`;\n  const groupAriaLabel =\n    rest['aria-label'] ?? (label && label.trim() ? label.trim() : defaultAccessibleLabel);\n\n  const resolvedMotion = useResolvedMotion(motion);\n  const targetKey = `${fromText}|${toText}|${label ?? ''}|${clampedStrength.toFixed(3)}`;\n  const [hydratedKey, setHydratedKey] = React.useState<string>('ssr');\n\n  // The draw-on replays only when the key changes while animated. The key tracks the target even\n  // while cut (reduced motion, a `still` tempo, `motion=\"cut\"`), so a later switch to an animated\n  // mode keeps the resting state instead of replaying an old draw-on.\n  useIsoLayoutEffect(() => {\n    setHydratedKey(targetKey);\n  }, [targetKey]);\n\n  const progress = useOnTwos(hydratedKey, 500, resolvedMotion);\n  const dash = Math.round(3 + clampedStrength * 9);\n  const gap = Math.round(10 - clampedStrength * 7);\n  const dashArray = `${dash} ${gap}`;\n  const dashOffset = progress >= 1 ? 0 : Number(((1 - progress) * 100).toFixed(2));\n  const isVertical = resolvedOrientation === 'vertical';\n\n  return (\n    <div\n      {...rest}\n      ref={setRefs}\n      role=\"group\"\n      aria-label={groupAriaLabel}\n      className={`f12-ghost-wire f12-ghost-wire-${safeOrientation}${className ? ` ${className}` : ''}`}\n      data-orientation={resolvedOrientation}\n      data-strength={clampedStrength.toFixed(2)}\n    >\n      <span className=\"f12-ghost-wire-endpoint f12-ghost-wire-from\" data-endpoint=\"from\">\n        {from}\n      </span>\n      <span className=\"f12-ghost-wire-connector\">\n        <svg\n          className=\"f12-ghost-wire-svg\"\n          viewBox={isVertical ? '0 0 12 100' : '0 0 100 12'}\n          preserveAspectRatio=\"none\"\n          aria-hidden=\"true\"\n          focusable=\"false\"\n        >\n          <defs>\n            <mask\n              id={maskId}\n              maskUnits=\"userSpaceOnUse\"\n              x=\"0\"\n              y=\"0\"\n              width={isVertical ? 12 : 100}\n              height={isVertical ? 100 : 12}\n            >\n              <line\n                className=\"f12-ghost-wire-reveal\"\n                x1={isVertical ? 6 : 0}\n                y1={isVertical ? 0 : 6}\n                x2={isVertical ? 6 : 100}\n                y2={isVertical ? 100 : 6}\n                strokeDasharray=\"100 100\"\n                strokeDashoffset={dashOffset}\n              />\n            </mask>\n          </defs>\n          <line\n            className=\"f12-ghost-wire-line\"\n            x1={isVertical ? 6 : 0}\n            y1={isVertical ? 0 : 6}\n            x2={isVertical ? 6 : 100}\n            y2={isVertical ? 100 : 6}\n            vectorEffect=\"non-scaling-stroke\"\n            strokeDasharray={dashArray}\n            mask={`url(#${maskId})`}\n          />\n        </svg>\n        {label ? <span className=\"f12-ghost-wire-label\">{label}</span> : null}\n      </span>\n      <span className=\"f12-ghost-wire-endpoint f12-ghost-wire-to\" data-endpoint=\"to\">\n        {to}\n      </span>\n    </div>\n  );\n});\n",
      "type": "registry:ui",
      "target": "components/frame12/ghost-wire.tsx"
    }
  ],
  "type": "registry:ui"
}