{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "token-flow",
  "title": "Token flow",
  "author": "Tori Bryan",
  "description": "Walks a color token from raw value to primitive to semantic role.",
  "dependencies": [],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/token-flow.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { mergeRefs } from \"@/lib/merge-refs\"\nimport { useMediaQuery } from \"@/lib/use-media-query\"\nimport { cn } from \"@/lib/utils\"\n\ntype TokenRow = {\n  /** The raw value, as written in CSS. */\n  base: string\n  /** The primitive it is named as, e.g. a palette step. */\n  primitive: string\n  /** The semantic role that points at the primitive. */\n  semantic: string\n  /** What the role is for, shown under the semantic chip when `showUse` is set. */\n  use?: string\n  /** The value and primitive the role resolves to under the dark theme, when they differ. */\n  dark?: {\n    base: string\n    primitive: string\n  }\n}\n\nconst TIERS = [\"Base\", \"Primitive\", \"Semantic\"] as const\n\nconst SCRAMBLE_CHARS = \"_!X$0-+*#\"\n\nfunction randomChar() {\n  return SCRAMBLE_CHARS[Math.floor(Math.random() * SCRAMBLE_CHARS.length)]\n}\n\nfunction subscribeToTheme(onChange: () => void) {\n  const observer = new MutationObserver(onChange)\n  observer.observe(document.documentElement, {\n    attributes: true,\n    attributeFilter: [\"class\"],\n  })\n  return () => observer.disconnect()\n}\n\n/**\n * Whether the `dark` class is on the document, the way next-themes sets it.\n * `null` until the client has looked, so the first real value is taken as a\n * baseline rather than a change.\n */\nfunction useIsDark() {\n  return React.useSyncExternalStore<boolean | null>(\n    subscribeToTheme,\n    () => document.documentElement.classList.contains(\"dark\"),\n    () => null\n  )\n}\n\nfunction useInView(ref: React.RefObject<Element | null>) {\n  const [inView, setInView] = React.useState(false)\n  React.useEffect(() => {\n    const node = ref.current\n    if (!node) return\n    const observer = new IntersectionObserver(([entry]) =>\n      setInView(Boolean(entry?.isIntersecting))\n    )\n    observer.observe(node)\n    return () => observer.disconnect()\n  }, [ref])\n  return inView\n}\n\n/**\n * Text that holds still until it changes, then spends a moment as noise\n * before settling on the new value, resolving left to right. Until `live`\n * is set the text is only a placeholder, so no change is played.\n */\nfunction ScrambleText({\n  text,\n  live,\n  reduceMotion,\n  duration = 1000,\n}: {\n  text: string\n  /** Whether `text` is the real value rather than a server-side guess. */\n  live: boolean\n  reduceMotion: boolean\n  /** Milliseconds the noise runs for after a change. */\n  duration?: number\n}) {\n  const settled = React.useRef<string | null>(null)\n  const [noise, setNoise] = React.useState<string | null>(null)\n\n  React.useEffect(() => {\n    if (!live) return\n    const previous = settled.current\n    settled.current = text\n    if (previous === null || previous === text || reduceMotion) return\n\n    const startedAt = performance.now()\n    let frame = 0\n    let lastStep = -1\n    const loop = (now: number) => {\n      const progress = Math.min((now - startedAt) / duration, 1)\n      const step = Math.floor(progress * (duration / 40))\n      if (step !== lastStep) {\n        lastStep = step\n        const revealed = Math.floor(progress * text.length)\n        let next = text.slice(0, revealed)\n        for (let i = revealed; i < text.length; i++) {\n          next += text[i] === \" \" ? \" \" : randomChar()\n        }\n        setNoise(next)\n      }\n      if (progress < 1) {\n        frame = window.requestAnimationFrame(loop)\n      } else {\n        setNoise(null)\n      }\n    }\n    frame = window.requestAnimationFrame(loop)\n    return () => {\n      window.cancelAnimationFrame(frame)\n      // Stopped part way, by a new value or by motion being turned off: the\n      // noise must not outlive the loop that would have cleared it.\n      setNoise(null)\n    }\n  }, [text, live, duration, reduceMotion])\n\n  return (\n    <span className=\"relative inline-block whitespace-pre\">\n      <span className=\"invisible\" aria-hidden>\n        {text}\n      </span>\n      <span className=\"absolute inset-0\" aria-hidden>\n        {noise ?? text}\n      </span>\n      <span className=\"sr-only\">{text}</span>\n    </span>\n  )\n}\n\n/**\n * A hairline with a pulse traveling along it. Fills whatever cell it is\n * in; `vertical` runs it top to bottom for the stacked layout. The pulse is\n * a Web Animation on the stroke offset, so it needs no keyframes in the\n * consumer's CSS.\n */\nfunction Wire({\n  delay,\n  vertical,\n  active,\n}: {\n  /** Seconds before this wire's pulse starts, to stagger the rows. */\n  delay: number\n  vertical: boolean\n  /** Whether the pulse should be running. */\n  active: boolean\n}) {\n  const pulse = React.useRef<SVGLineElement>(null)\n\n  React.useEffect(() => {\n    const node = pulse.current\n    if (!node || !active) return\n    const animation = node.animate(\n      [{ strokeDashoffset: 1.25 }, { strokeDashoffset: -1.25 }],\n      { duration: 3500, delay: delay * 1000, iterations: Infinity }\n    )\n    return () => animation.cancel()\n  }, [active, delay])\n\n  const coords = vertical\n    ? { x1: 4, y1: 0, x2: 4, y2: 100 }\n    : { x1: 0, y1: 4, x2: 100, y2: 4 }\n\n  return (\n    <svg\n      className={vertical ? \"h-6 w-2\" : \"h-2 w-full\"}\n      viewBox={vertical ? \"0 0 8 100\" : \"0 0 100 8\"}\n      preserveAspectRatio=\"none\"\n      aria-hidden\n    >\n      <line\n        {...coords}\n        className=\"stroke-border\"\n        vectorEffect=\"non-scaling-stroke\"\n      />\n      {active ? (\n        <line\n          ref={pulse}\n          {...coords}\n          className=\"stroke-muted-foreground\"\n          strokeWidth={2}\n          strokeLinecap=\"round\"\n          vectorEffect=\"non-scaling-stroke\"\n          pathLength={1}\n          strokeDasharray=\"0.25 1\"\n          strokeDashoffset={1.25}\n        />\n      ) : null}\n    </svg>\n  )\n}\n\nfunction Chip({\n  color,\n  className,\n  children,\n}: {\n  color: string\n  className?: string\n  children: React.ReactNode\n}) {\n  return (\n    <span\n      data-slot=\"token-flow-chip\"\n      className={cn(\n        \"inline-flex h-6 max-w-fit items-center gap-1.5 justify-self-center rounded-full border border-border bg-card pr-2 pl-1.5 font-mono text-xs whitespace-nowrap text-card-foreground\",\n        className\n      )}\n    >\n      <span\n        className=\"size-2.5 shrink-0 rounded-full ring-1 ring-border transition-colors duration-1000\"\n        style={{ background: color }}\n        aria-hidden\n      />\n      {children}\n    </span>\n  )\n}\n\ntype TokenFlowProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** One color per row, traced from its raw value to the role that uses it. */\n  rows: TokenRow[]\n  /** Print each row's `use` under its semantic chip. */\n  showUse?: boolean\n  /** Force a theme instead of following the document's `dark` class. */\n  theme?: \"light\" | \"dark\"\n  /**\n   * `horizontal` wires the tiers left to right, stacking them only on narrow\n   * screens; `vertical` stacks them top to bottom at every width.\n   */\n  orientation?: \"horizontal\" | \"vertical\"\n}\n\n/**\n * How a color travels through the token tiers: a raw value, the primitive\n * that names it, and the semantic role that uses it. One row per color,\n * wired left to right across a dotted plate, with a pulse traveling along\n * each wire. Rows that carry a `dark` value swap to it when the theme\n * changes, scrambling for a moment on the way.\n */\nfunction TokenFlow({\n  rows,\n  showUse = false,\n  theme,\n  orientation = \"horizontal\",\n  className,\n  ref,\n  ...props\n}: TokenFlowProps) {\n  const documentDark = useIsDark()\n  const isDark = theme ? theme === \"dark\" : documentDark === true\n  const live = theme !== undefined || documentDark !== null\n  const narrow = useMediaQuery(\"(max-width: 639px)\")\n  const horizontal = orientation === \"horizontal\"\n  const vertical = !horizontal || narrow\n  const reduceMotion = useMediaQuery(\"(prefers-reduced-motion: reduce)\")\n  const plate = React.useRef<HTMLDivElement>(null)\n  // A caller's ref would otherwise replace the one that pauses it off screen.\n  const plateRef = React.useMemo(() => mergeRefs(plate, ref), [ref])\n  const inView = useInView(plate)\n  const pulse = inView && !reduceMotion\n\n  return (\n    <div\n      ref={plateRef}\n      data-slot=\"token-flow\"\n      className={cn(\n        \"relative overflow-hidden rounded-xl border border-border bg-muted px-6 py-4\",\n        className\n      )}\n      {...props}\n    >\n      <div\n        className=\"pointer-events-none absolute inset-0 opacity-20\"\n        style={{\n          backgroundImage:\n            \"radial-gradient(circle, var(--foreground) 1px, transparent 1px)\",\n          backgroundSize: \"24px 24px\",\n        }}\n        aria-hidden\n      />\n\n      <div\n        className={cn(\n          \"relative grid grid-cols-1 items-center gap-y-6\",\n          horizontal &&\n            \"sm:grid-cols-[auto_minmax(2rem,1fr)_auto_minmax(2rem,1fr)_auto] sm:gap-x-3 sm:gap-y-3\"\n        )}\n      >\n        <div className=\"contents\" aria-hidden>\n          {TIERS.map((tier, i) => (\n            <div key={tier} className=\"contents\">\n              {i > 0 ? (\n                <div className={cn(\"hidden\", horizontal && \"sm:block\")} />\n              ) : null}\n              <p\n                className={cn(\n                  \"hidden text-center font-mono text-[10px] tracking-wide text-muted-foreground uppercase\",\n                  horizontal && \"sm:block\"\n                )}\n              >\n                {tier}\n              </p>\n            </div>\n          ))}\n        </div>\n\n        {rows.map((row, i) => {\n          const value = isDark && row.dark ? row.dark : row\n          return (\n            <div\n              key={`${row.semantic}-${i}`}\n              className={cn(\n                \"flex flex-col items-center gap-1.5\",\n                horizontal &&\n                  \"sm:col-span-5 sm:grid sm:grid-cols-subgrid sm:gap-0\"\n              )}\n            >\n              <Chip color={value.base}>\n                <span className=\"sr-only\">{TIERS[0]} </span>\n                <ScrambleText\n                  text={value.base}\n                  live={live}\n                  reduceMotion={reduceMotion}\n                />\n              </Chip>\n              <Wire delay={i * 0.5} vertical={vertical} active={pulse} />\n              <Chip color={value.base}>\n                <span className=\"sr-only\">{TIERS[1]} </span>\n                <ScrambleText\n                  text={value.primitive}\n                  live={live}\n                  reduceMotion={reduceMotion}\n                />\n              </Chip>\n              <Wire delay={i * 0.5 + 0.8} vertical={vertical} active={pulse} />\n              <div className=\"flex flex-col items-center gap-1 justify-self-center\">\n                <Chip color={value.base}>\n                  <span className=\"sr-only\">{TIERS[2]} </span>\n                  {row.semantic}\n                </Chip>\n                {showUse && row.use ? (\n                  <span className=\"font-mono text-[9px] whitespace-nowrap text-muted-foreground\">\n                    {row.use}\n                  </span>\n                ) : null}\n              </div>\n            </div>\n          )\n        })}\n      </div>\n    </div>\n  )\n}\n\nexport { TokenFlow, type TokenRow }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/lib/merge-refs.ts",
      "content": "import * as React from \"react\"\n\n/**\n * One callback ref that sets every ref given, so a part can keep its own\n * ref on an element and still hand the element to a caller's `ref`. Undo\n * runs through React 19's ref cleanup, which calls a function ref with\n * null and empties an object ref.\n */\nexport function mergeRefs<T>(\n  ...refs: (React.Ref<T> | undefined)[]\n): React.RefCallback<T> {\n  return (node) => {\n    const cleanups = refs.map((ref) => {\n      if (typeof ref === \"function\") {\n        const cleanup = ref(node)\n        return typeof cleanup === \"function\" ? cleanup : () => ref(null)\n      }\n      if (ref) {\n        ref.current = node\n        return () => {\n          ref.current = null\n        }\n      }\n      return undefined\n    })\n    return () => {\n      for (const cleanup of cleanups) cleanup?.()\n    }\n  }\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/lib/use-media-query.ts",
      "content": "import * as React from \"react\"\n\n/**\n * Whether a media query matches, kept in step as it changes. False on the\n * server and during hydration, so server markup never depends on it.\n */\nexport function useMediaQuery(query: string) {\n  const subscribe = React.useCallback(\n    (onChange: () => void) => {\n      const list = window.matchMedia(query)\n      list.addEventListener(\"change\", onChange)\n      return () => list.removeEventListener(\"change\", onChange)\n    },\n    [query]\n  )\n  return React.useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false\n  )\n}\n",
      "type": "registry:lib"
    }
  ],
  "cssVars": {
    "theme": {
      "color-chip": "var(--chip)"
    },
    "light": {
      "chip": "var(--color-neutral-200)"
    },
    "dark": {
      "chip": "var(--color-neutral-800)"
    }
  },
  "meta": {
    "tier": "special-components",
    "group": "Diagrams",
    "docs": "https://fibo.toribryan.com/?path=/docs/special-components-token-flow--docs"
  },
  "docs": "Docs and live examples: https://fibo.toribryan.com/?path=/docs/special-components-token-flow--docs",
  "categories": [
    "special-components",
    "diagrams"
  ],
  "type": "registry:ui"
}