{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "shuffle-grid",
  "title": "动态格",
  "description": "方格里的内容随机出现、换位、点亮，支持分类胶囊切换、首字母头像、纯色像素块与离屏暂停。",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "button",
    "https://yiui.dev/r/flow-tone.json"
  ],
  "files": [
    {
      "path": "src/registry/ui/shuffle-grid/shuffle-grid.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  AnimatePresence,\n  motion,\n  useInView,\n  useReducedMotion,\n} from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useControllableState } from \"@/hooks/use-controllable-state\"\nimport { flowToneStyle, type FlowTone } from \"@/lib/flow-tone\"\nimport {\n  arrangeSlots,\n  createRandom,\n  initialSlots,\n  pickLit,\n  stepSlots,\n  type Slots,\n} from \"./engine\"\nimport { ShuffleGridPills } from \"./pills\"\n\ntype ShuffleGridItem = {\n  /** 唯一标识，分类里用它引用条目 */\n  id: string\n  /** 格子里的内容：图标、首字母头像、任意节点。不传则是一块纯色方块 */\n  content?: React.ReactNode\n  /** 这个条目点亮时的色调，不传则用 `tone` */\n  tone?: FlowTone\n}\n\ntype ShuffleGridGroup = {\n  id: string\n  /** 胶囊上的文字 */\n  label: string\n  /** 这个分类里的条目 id，数量不能超过格子数 */\n  items: string[]\n}\n\n/** `lines` 共享 1px 分隔线的整块网格；`tiles` 独立圆角格；`pixels` 紧凑的纯色像素块。 */\ntype ShuffleGridVariant = \"lines\" | \"tiles\" | \"pixels\"\n\nconst BOARD: Record<ShuffleGridVariant, string> = {\n  lines: \"gap-px overflow-hidden rounded-lg border border-edge bg-edge\",\n  tiles: \"gap-2.5\",\n  pixels: \"gap-1\",\n}\nconst CELL: Record<ShuffleGridVariant, string> = {\n  lines: \"bg-card\",\n  tiles: \"rounded-xl border border-dashed border-edge\",\n  pixels: \"rounded-[3px] bg-muted/40\",\n}\nconst TILE: Record<ShuffleGridVariant, string> = {\n  lines:\n    \"text-muted-foreground data-lit:bg-(--flow-tone)/10 data-lit:text-(--flow-tone)\",\n  tiles:\n    \"rounded-xl border border-edge bg-muted/40 text-muted-foreground shadow-xs data-lit:border-(--flow-tone)/35 data-lit:bg-(--flow-tone)/12 data-lit:text-(--flow-tone)\",\n  pixels: \"rounded-[3px] bg-(--flow-tone)/25 data-lit:bg-(--flow-tone)\",\n}\n\n/**\n * 调整用的覆盖项：子树里所有 `ShuffleGrid` 的对应属性被这里的值替换。\n * 用于调试面板一类的场景；不包 `ShuffleGridTuningContext.Provider` 时组件行为不变。\n */\ntype ShuffleGridTuning = {\n  interval?: number\n  activeCount?: number\n  litCount?: number | \"all\"\n  variant?: ShuffleGridVariant\n  paused?: boolean\n  /** 固定盘面外框的宽高比为打开时的样子，切换 `variant` 时容器大小不变。 */\n  stableFrame?: boolean\n  /** 回报示例自己的实际取值（未被覆盖前），面板据此显示当前值。 */\n  onReport?: (values: ShuffleGridReport) => void\n}\n\ntype ShuffleGridReport = {\n  interval: number\n  /** 同时在场的条目数（已按格子数与条目数截断）。 */\n  activeCount: number\n  /** 同时点亮的条目数；`\"all\"` 换算成当前格子数。 */\n  litCount: number\n  variant: ShuffleGridVariant\n  paused: boolean\n}\n\nconst ShuffleGridTuningContext = React.createContext<ShuffleGridTuning>({})\n\ntype State = { slots: Slots; lit: number[]; tick: number }\ntype Action =\n  | { type: \"tick\"; shuffle: boolean }\n  | { type: \"arrange\"; target: number[] }\n  | { type: \"retarget\" }\n\ntype ShuffleGridProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"children\" | \"defaultValue\" | \"onChange\"\n> & {\n  /** 行数 @default 3 */\n  rows?: number\n  /** 列数 @default 3 */\n  cols?: number\n  /** 条目池，数量可以比格子多，多出来的会轮流出场 */\n  items: ShuffleGridItem[]\n  /** 同时在场的条目数；默认约六成的格子 */\n  activeCount?: number\n  /**\n   * 同时点亮的条目数，`\"all\"` 表示全部点亮；默认约三成在场条目\n   * @default 在场条目的 35%\n   */\n  litCount?: number | \"all\"\n  /**\n   * 每一步的间隔（毫秒）\n   * @default 1600\n   */\n  interval?: number\n  /**\n   * 随机种子：同一个种子，服务端与浏览器的初始画面一致\n   * @default 1\n   */\n  seed?: number\n  /**\n   * 外观\n   * @default \"lines\"\n   */\n  variant?: ShuffleGridVariant\n  /**\n   * 条目的默认色调\n   * @default \"primary\"\n   */\n  tone?: FlowTone\n  /**\n   * 暂停洗牌与分类轮播\n   * @default false\n   */\n  paused?: boolean\n  /** 分类：传入后在格子下方显示胶囊，切换分类时格子里的条目随之换位 */\n  groups?: ShuffleGridGroup[]\n  /** 受控的当前分类 id */\n  value?: string\n  /** 非受控的初始分类 id，默认第一个 */\n  defaultValue?: string\n  /** 分类变化时回调（点击和自动轮播都会触发） */\n  onValueChange?: (id: string) => void\n  /**\n   * 自动轮播分类的间隔（毫秒）；设为 0 则不自动轮播\n   * @default 3600\n   */\n  groupInterval?: number\n  /** 图示说明；不传时格子作为装饰隐藏 */\n  label?: string\n  /** 胶囊行的读屏名称 @default \"分类\" */\n  groupsLabel?: string\n}\n\nfunction buildState(props: {\n  cells: number\n  items: ShuffleGridItem[]\n  active: number\n  lit: number | \"all\"\n  seed: number\n  target?: number[]\n}): State {\n  const random = createRandom(props.seed)\n  const slots = props.target\n    ? arrangeSlots(Array(props.cells).fill(null), props.target, random)\n    : initialSlots(props.cells, props.items.length, props.active, random)\n  const count = props.lit === \"all\" ? slots.length : props.lit\n  return { slots, lit: pickLit(slots, count, random), tick: 0 }\n}\n\n/** 方格里的内容随机出现、换位、点亮；也可以按分类整体换位，像一面会呼吸的能力墙或团队指派板。 */\nfunction ShuffleGrid({\n  className,\n  rows = 3,\n  cols = 3,\n  items,\n  activeCount: activeCountProp,\n  litCount: litCountProp,\n  interval: intervalProp = 1600,\n  seed = 1,\n  variant: variantProp = \"lines\",\n  tone = \"primary\",\n  paused: pausedProp = false,\n  groups,\n  value,\n  defaultValue,\n  onValueChange,\n  groupInterval = 3600,\n  label,\n  groupsLabel,\n  ...props\n}: ShuffleGridProps) {\n  const uid = React.useId()\n  const ref = React.useRef<HTMLDivElement>(null)\n  const boardRef = React.useRef<HTMLDivElement>(null)\n  const inView = useInView(ref, { amount: 0.3 })\n  const reduced = !!useReducedMotion()\n  const tuning = React.useContext(ShuffleGridTuningContext)\n  const { onReport, stableFrame } = tuning\n  const interval = tuning.interval ?? intervalProp\n  const variant = tuning.variant ?? variantProp\n  const paused = tuning.paused ?? pausedProp\n\n  const cells = Math.max(1, Math.floor(rows)) * Math.max(1, Math.floor(cols))\n  const idIndex = React.useMemo(\n    () => new Map(items.map((item, index) => [item.id, index])),\n    [items],\n  )\n  const targetOf = React.useCallback(\n    (id: string) =>\n      groups\n        ?.find((group) => group.id === id)\n        ?.items.flatMap((itemId) => idIndex.get(itemId) ?? [])\n        .slice(0, cells),\n    [groups, idIndex, cells],\n  )\n  const propActive = Math.min(\n    cells,\n    items.length,\n    activeCountProp ?? Math.round(cells * 0.6),\n  )\n  const active = Math.min(cells, items.length, tuning.activeCount ?? propActive)\n  const propLit: number | \"all\" =\n    litCountProp ?? Math.max(1, Math.round(propActive * 0.35))\n  const lit: number | \"all\" =\n    tuning.litCount ?? litCountProp ?? Math.max(1, Math.round(active * 0.35))\n  const reportedLit = propLit === \"all\" ? cells : propLit\n  React.useEffect(() => {\n    onReport?.({\n      interval: intervalProp,\n      activeCount: propActive,\n      litCount: reportedLit,\n      variant: variantProp,\n      paused: pausedProp,\n    })\n  }, [onReport, intervalProp, propActive, reportedLit, variantProp, pausedProp])\n\n  const [group, setGroup] = useControllableState({\n    prop: value,\n    defaultProp: defaultValue ?? groups?.[0]?.id ?? \"\",\n    onChange: onValueChange,\n  })\n\n  const [state, dispatch] = React.useReducer(\n    (prev: State, action: Action): State => {\n      const random = createRandom(seed * 7919 + (prev.tick + 1) * 104729)\n      let slots = prev.slots\n      if (action.type === \"arrange\") {\n        slots = arrangeSlots(slots, action.target, random)\n      } else if (action.type === \"retarget\") {\n        // 在场数量变了：一次补齐 / 拿掉，不必等一格一格地换\n        for (let n = 0; n < cells; n++) {\n          if (slots.filter((v) => v !== null).length === active) break\n          slots = stepSlots(slots, items.length, active, random)\n        }\n      } else if (action.shuffle) {\n        slots = stepSlots(slots, items.length, active, random)\n      }\n      const count = lit === \"all\" ? slots.length : lit\n      return { slots, lit: pickLit(slots, count, random), tick: prev.tick + 1 }\n    },\n    undefined,\n    () =>\n      buildState({\n        cells,\n        items,\n        active,\n        lit,\n        seed,\n        target: groups ? targetOf(group) : undefined,\n      }),\n  )\n\n  // 在场数、点亮数变了就立刻重排，不等下一个定时步；分类决定谁在场时只重新挑点亮的\n  const settled = React.useRef({ active, lit })\n  React.useEffect(() => {\n    const prev = settled.current\n    if (prev.active === active && prev.lit === lit) return\n    settled.current = { active, lit }\n    dispatch(\n      !groups && prev.active !== active\n        ? { type: \"retarget\" }\n        : { type: \"tick\", shuffle: false },\n    )\n  }, [active, lit, groups])\n\n  // 切换分类：把格子排成新分类的样子（首次渲染已经按初始分类排好，不重复）\n  const arranged = React.useRef(group)\n  React.useEffect(() => {\n    if (!groups || arranged.current === group) return\n    arranged.current = group\n    const target = targetOf(group)\n    if (target) dispatch({ type: \"arrange\", target })\n  }, [groups, group, targetOf])\n\n  const running = inView && !paused && !reduced\n  // 有分类时由分类决定谁在场，定时器只负责换一批点亮的；没有分类时才真正洗牌\n  React.useEffect(() => {\n    if (!running) return\n    const id = window.setInterval(\n      () => dispatch({ type: \"tick\", shuffle: !groups }),\n      Math.max(200, interval),\n    )\n    return () => window.clearInterval(id)\n  }, [running, interval, groups])\n\n  // 分类自动轮播；用户手动选过之后多等一轮，不抢操作\n  const holdUntil = React.useRef(0)\n  React.useEffect(() => {\n    if (!groups || groups.length < 2 || !groupInterval || !running) return\n    const wait = Math.max(groupInterval, holdUntil.current - Date.now())\n    const id = window.setTimeout(() => {\n      const index = groups.findIndex((item) => item.id === group)\n      const next = groups[(index + 1) % groups.length]\n      if (next) setGroup(next.id)\n    }, wait)\n    return () => window.clearTimeout(id)\n  }, [groups, group, groupInterval, running, setGroup])\n\n  // 固定外框：记下打开时盘面的宽高比，之后换 `variant`（间距不同）盘面大小也不变\n  const [frameRatio, setFrameRatio] = React.useState<number>()\n  React.useEffect(() => {\n    const rect = boardRef.current?.getBoundingClientRect()\n    setFrameRatio(\n      stableFrame && rect?.height ? rect.width / rect.height : undefined,\n    )\n  }, [stableFrame])\n\n  const litSet = new Set(state.lit)\n  const transition = reduced\n    ? { duration: 0 }\n    : { type: \"spring\" as const, stiffness: 380, damping: 32 }\n\n  return (\n    <div\n      {...props}\n      ref={ref}\n      data-slot=\"shuffle-grid\"\n      data-variant={variant}\n      className={cn(\"flex w-full flex-col gap-4\", className)}\n    >\n      <div\n        ref={boardRef}\n        data-slot=\"shuffle-grid-board\"\n        role={label ? \"img\" : undefined}\n        aria-label={label}\n        aria-hidden={label ? undefined : true}\n        className={cn(\"grid\", BOARD[variant])}\n        style={{\n          gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,\n          ...(frameRatio\n            ? {\n                gridTemplateRows: `repeat(${Math.max(1, Math.floor(rows))}, minmax(0, 1fr))`,\n                aspectRatio: frameRatio,\n              }\n            : null),\n        }}\n      >\n        {state.slots.map((slot, cell) => {\n          const item = slot === null ? undefined : items[slot]\n          return (\n            <div\n              key={cell}\n              data-slot=\"shuffle-grid-cell\"\n              className={cn(\n                \"relative\",\n                !frameRatio && \"aspect-square\",\n                CELL[variant],\n              )}\n            >\n              <AnimatePresence initial={false}>\n                {item ? (\n                  <motion.div\n                    key={item.id}\n                    layoutId={`${uid}-${item.id}`}\n                    initial={{ opacity: 0, scale: 0.6 }}\n                    animate={{ opacity: 1, scale: 1 }}\n                    exit={{ opacity: 0, scale: 0.6 }}\n                    transition={transition}\n                    className=\"absolute inset-0\"\n                  >\n                    <div\n                      data-slot=\"shuffle-grid-tile\"\n                      data-lit={litSet.has(slot as number) || undefined}\n                      className={cn(\n                        \"group/tile flex size-full items-center justify-center transition-colors duration-500 motion-reduce:transition-none [&_svg:not([class*='size-'])]:size-5\",\n                        TILE[variant],\n                      )}\n                      style={flowToneStyle(item.tone ?? tone)}\n                    >\n                      {item.content}\n                    </div>\n                  </motion.div>\n                ) : null}\n              </AnimatePresence>\n            </div>\n          )\n        })}\n      </div>\n      {groups ? (\n        <ShuffleGridPills\n          label={groupsLabel}\n          options={groups}\n          value={group}\n          onValueChange={(id) => {\n            holdUntil.current = Date.now() + groupInterval * 2\n            setGroup(id)\n          }}\n        />\n      ) : null}\n    </div>\n  )\n}\n\ntype ShuffleGridAvatarProps = React.ComponentProps<\"span\"> & {\n  /** 姓名，取第一个字（拉丁字母转大写）作首字母 */\n  name: string\n}\n\n/** 首字母头像：圆底加一个字，颜色随格子的色调，亮起时更深。适合放进 `content`。 */\nfunction ShuffleGridAvatar({\n  className,\n  name,\n  ...props\n}: ShuffleGridAvatarProps) {\n  const initial = Array.from(name.trim())[0]?.toUpperCase() ?? \"?\"\n  return (\n    <span\n      data-slot=\"shuffle-grid-avatar\"\n      aria-hidden=\"true\"\n      className={cn(\n        \"flex size-[58%] items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground ring-1 ring-edge transition-colors duration-500 group-data-lit/tile:bg-(--flow-tone)/15 group-data-lit/tile:text-(--flow-tone) group-data-lit/tile:ring-(--flow-tone)/40\",\n        className,\n      )}\n      {...props}\n    >\n      {initial}\n    </span>\n  )\n}\n\nexport { ShuffleGrid, ShuffleGridAvatar, ShuffleGridTuningContext }\nexport type {\n  ShuffleGridAvatarProps,\n  ShuffleGridGroup,\n  ShuffleGridItem,\n  ShuffleGridProps,\n  ShuffleGridReport,\n  ShuffleGridTuning,\n  ShuffleGridVariant,\n}\n",
      "type": "registry:ui",
      "target": "@ui/shuffle-grid/shuffle-grid.tsx"
    },
    {
      "path": "src/registry/ui/shuffle-grid/engine.ts",
      "content": "/**\n * 动态格的洗牌逻辑：全部是纯函数，随机数来自种子，所以同一个 `seed` 在服务端与浏览器得到同一张初始画面，\n * 之后每一步由「种子 + 第几步」决定，行为可复现。\n */\n\n/** 每个格子里放的是 `items` 的下标，`null` 表示空格。 */\ntype Slots = (number | null)[]\ntype Random = () => number\n\nfunction createRandom(seed: number): Random {\n  let state = (seed * 2654435761 + 1013904223) >>> 0\n  return () => {\n    state = (state + 0x6d2b79f5) >>> 0\n    let t = state\n    t = Math.imul(t ^ (t >>> 15), t | 1)\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n  }\n}\n\nfunction pick<T>(list: T[], random: Random): T {\n  const value = list[Math.floor(random() * list.length)]\n  if (value === undefined) throw new RangeError(\"不能从空列表中抽取条目\")\n  return value\n}\n\nfunction shuffled<T>(list: T[], random: Random): T[] {\n  const result = [...list]\n  for (let i = result.length - 1; i > 0; i--) {\n    const j = Math.floor(random() * (i + 1))\n    ;[result[i], result[j]] = [result[j]!, result[i]!]\n  }\n  return result\n}\n\nconst indicesWhere = (slots: Slots, empty: boolean) =>\n  slots.flatMap((value, index) => ((value === null) === empty ? [index] : []))\n\n/** 随机挑 `activeCount` 个条目，放进随机的格子。 */\nfunction initialSlots(\n  cells: number,\n  poolSize: number,\n  activeCount: number,\n  random: Random,\n): Slots {\n  const slots: Slots = Array(cells).fill(null)\n  const spots = shuffled(\n    Array.from({ length: cells }, (_, i) => i),\n    random,\n  )\n  const pool = shuffled(\n    Array.from({ length: poolSize }, (_, i) => i),\n    random,\n  )\n  for (let i = 0; i < Math.min(activeCount, cells, poolSize); i++)\n    slots[spots[i]!] = pool[i]!\n  return slots\n}\n\n/**\n * 走一步：数量不足就补一个、多了就拿掉一个；数量正好时随机在「换格」「换人」「互换」里选一种。\n * 每一步只动一两个格子，画面始终是渐变的，不会整盘重排。\n */\nfunction stepSlots(\n  slots: Slots,\n  poolSize: number,\n  activeCount: number,\n  random: Random,\n): Slots {\n  const next = [...slots]\n  const filled = indicesWhere(next, false)\n  const empty = indicesWhere(next, true)\n  const used = new Set(next.filter((v): v is number => v !== null))\n  const unused = Array.from({ length: poolSize }, (_, i) => i).filter(\n    (i) => !used.has(i),\n  )\n\n  if (filled.length > activeCount) {\n    next[pick(filled, random)] = null\n  } else if (filled.length < activeCount && empty.length && unused.length) {\n    next[pick(empty, random)] = pick(unused, random)\n  } else {\n    const roll = random()\n    if (roll < 0.4 && empty.length && filled.length) {\n      const from = pick(filled, random)\n      next[pick(empty, random)] = next[from] ?? null\n      next[from] = null\n    } else if (roll < 0.8 && empty.length && unused.length && filled.length) {\n      next[pick(filled, random)] = null\n      next[pick(empty, random)] = pick(unused, random)\n    } else if (filled.length > 1) {\n      const a = pick(filled, random)\n      const b = pick(\n        filled.filter((i) => i !== a),\n        random,\n      )\n      ;[next[a], next[b]] = [next[b] ?? null, next[a] ?? null]\n    }\n  }\n  return next\n}\n\n/**\n * 切换到某个分类：不在目标里的条目消失，已经在场的有一半挪到别的空格，缺的补进随机空格。\n * 已经在场的条目保持同一个下标，这样切换时能看到它们「换位」而不是重新出现。\n */\nfunction arrangeSlots(slots: Slots, target: number[], random: Random): Slots {\n  const wanted = new Set(target)\n  const next: Slots = slots.map((v) => (v !== null && wanted.has(v) ? v : null))\n  for (const from of shuffled(indicesWhere(next, false), random)) {\n    const empty = indicesWhere(next, true)\n    if (empty.length && random() < 0.5) {\n      next[pick(empty, random)] = next[from] ?? null\n      next[from] = null\n    }\n  }\n  const present = new Set(next.filter((v): v is number => v !== null))\n  for (const id of target) {\n    if (present.has(id)) continue\n    const empty = indicesWhere(next, true)\n    if (!empty.length) break\n    next[pick(empty, random)] = id\n  }\n  return next\n}\n\n/** 从在场的条目里挑 `count` 个点亮，返回它们的下标。 */\nfunction pickLit(slots: Slots, count: number, random: Random): number[] {\n  const present = slots.filter((v): v is number => v !== null)\n  return shuffled(present, random).slice(0, count)\n}\n\nexport { arrangeSlots, createRandom, initialSlots, pickLit, stepSlots }\nexport type { Random, Slots }\n",
      "type": "registry:ui",
      "target": "@ui/shuffle-grid/engine.ts"
    },
    {
      "path": "src/registry/ui/shuffle-grid/pills.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\n\ntype ShuffleGridPillsProps = Omit<React.ComponentProps<\"div\">, \"onChange\"> & {\n  /** 分类标签，顺序即显示顺序 */\n  options: { id: string; label: string }[]\n  /** 当前选中的分类 id */\n  value: string\n  onValueChange: (id: string) => void\n  /** 读屏名称 */\n  label?: string\n}\n\n/** 底部分类胶囊：一行可横向滚动的按钮，选中项自动滚到中间，两端渐隐。 */\nfunction ShuffleGridPills({\n  className,\n  options,\n  value,\n  onValueChange,\n  label = \"分类\",\n  ...props\n}: ShuffleGridPillsProps) {\n  const ref = React.useRef<HTMLDivElement>(null)\n  const reduced = useReducedMotion()\n  // 只在选中项变化时把它滚到中间；用 scrollTo 只动这一行，不会带着整个页面滚\n  React.useEffect(() => {\n    const row = ref.current\n    const active = row?.querySelector<HTMLElement>(\"[aria-pressed=true]\")\n    if (!row || !active || row.scrollWidth <= row.clientWidth) return\n    row.scrollTo({\n      left: active.offsetLeft - (row.clientWidth - active.offsetWidth) / 2,\n      behavior: reduced ? \"auto\" : \"smooth\",\n    })\n  }, [value, reduced])\n  return (\n    <div\n      ref={ref}\n      role=\"group\"\n      aria-label={label}\n      data-slot=\"shuffle-grid-pills\"\n      className={cn(\n        // 首尾子项的 auto 外边距：放得下时整行居中，放不下时从左滚动，不会把左端挤出可视范围\n        \"flex [scrollbar-width:none] gap-2 overflow-x-auto [&::-webkit-scrollbar]:hidden [&>:first-child]:ml-auto [&>:last-child]:mr-auto\",\n        className,\n      )}\n      {...props}\n    >\n      {options.map((option) => (\n        <Button\n          key={option.id}\n          type=\"button\"\n          size=\"sm\"\n          variant=\"ghost\"\n          aria-pressed={option.id === value}\n          onClick={() => onValueChange(option.id)}\n          className=\"shrink-0 rounded-full text-muted-foreground aria-pressed:bg-primary/10 aria-pressed:text-primary\"\n        >\n          {option.label}\n        </Button>\n      ))}\n    </div>\n  )\n}\n\nexport { ShuffleGridPills }\nexport type { ShuffleGridPillsProps }\n",
      "type": "registry:ui",
      "target": "@ui/shuffle-grid/pills.tsx"
    },
    {
      "path": "src/registry/hooks/use-controllable-state.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\ntype UseControllableStateParams<T> = {\n  /** 受控值；传入后组件不再维护内部状态 */\n  prop?: T\n  /** 非受控时的初始值 */\n  defaultProp: T\n  /** 值变化时回调（受控与非受控都会触发） */\n  onChange?: (value: T) => void\n}\n\n/**\n * 同时支持受控与非受控的状态 hook。\n * 约定见 component-conventions 技能第 4 节：open/defaultOpen/onOpenChange 等成对 props 都用它实现。\n */\nfunction useControllableState<T>({\n  prop,\n  defaultProp,\n  onChange,\n}: UseControllableStateParams<T>) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultProp)\n  const isControlled = prop !== undefined\n  const value = isControlled ? prop : uncontrolled\n\n  // 用 ref 保存最新的回调和值，保证 setValue 引用稳定\n  const onChangeRef = React.useRef(onChange)\n  const valueRef = React.useRef(value)\n  React.useEffect(() => {\n    onChangeRef.current = onChange\n    valueRef.current = value\n  })\n\n  const setValue = React.useCallback(\n    (next: React.SetStateAction<T>) => {\n      const resolved =\n        typeof next === \"function\"\n          ? (next as (prev: T) => T)(valueRef.current)\n          : next\n      if (Object.is(resolved, valueRef.current)) return\n      valueRef.current = resolved\n      if (!isControlled) setUncontrolled(resolved)\n      onChangeRef.current?.(resolved)\n    },\n    [isControlled],\n  )\n\n  return [value, setValue] as const\n}\n\nexport { useControllableState }\n",
      "type": "registry:ui",
      "target": "hooks/use-controllable-state.ts"
    }
  ],
  "cssVars": {
    "theme": {
      "color-edge": "var(--edge)"
    },
    "light": {
      "edge": "color-mix(in oklch, var(--foreground) 12%, transparent)"
    },
    "dark": {
      "edge": "color-mix(in oklch, var(--foreground) 18%, transparent)"
    }
  },
  "type": "registry:ui"
}
