{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "checklist",
  "title": "手写清单",
  "description": "勾选时文字被手写感划线划掉的清单，支持受控与非受控、进度汇总、自动播放与紧凑尺寸。",
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://yiui.dev/r/flow-tone.json"
  ],
  "files": [
    {
      "path": "src/registry/ui/checklist/checklist.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Checkbox as CheckboxPrimitive } from \"@base-ui/react/checkbox\"\nimport { useInView, useReducedMotion } 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  ChecklistContext,\n  ChecklistRowContext,\n  initialStep,\n  type ChecklistSize,\n} from \"./context\"\nimport { Scribble } from \"./scribble\"\n\ntype ChecklistProps = React.ComponentProps<\"div\"> & {\n  /**\n   * 自动播放：按顺序逐条划掉，全部完成后停一会儿再重置，循环往复。\n   * 播放期间条目只读；进入视口才播放，离屏、`paused`、减少动态效果时停在静态快照\n   * @default false\n   */\n  autoplay?: boolean\n  /**\n   * 自动播放时两条之间的间隔（毫秒）\n   * @default 1400\n   */\n  interval?: number\n  /**\n   * 全部划完后停留多久再重置（毫秒）\n   * @default 2400\n   */\n  resetDelay?: number\n  /**\n   * 暂停自动播放\n   * @default false\n   */\n  paused?: boolean\n  /**\n   * 尺寸：`sm` 更紧凑，适合侧栏、小卡片\n   * @default \"default\"\n   */\n  size?: ChecklistSize\n  /**\n   * 划线与进度条的默认色调\n   * @default \"foreground\"\n   */\n  tone?: FlowTone\n}\n\n/** 清单容器：提供尺寸、色调、自动播放和进度汇总，条目放在 `ChecklistList` 里。 */\nfunction Checklist({\n  className,\n  autoplay = false,\n  interval = 1400,\n  resetDelay = 2400,\n  paused = false,\n  size = \"default\",\n  tone = \"foreground\",\n  ...props\n}: ChecklistProps) {\n  const ref = React.useRef<HTMLDivElement>(null)\n  const inView = useInView(ref, { amount: 0.3 })\n  const reduced = useReducedMotion()\n  const [total, setTotal] = React.useState(0)\n  const [step, setStep] = React.useState<number | null>(null)\n  const [reported, setReported] = React.useState<Record<number, boolean>>({})\n\n  const report = React.useCallback((index: number, checked: boolean) => {\n    setReported((prev) =>\n      prev[index] === checked ? prev : { ...prev, [index]: checked },\n    )\n  }, [])\n\n  // 每走完一步重新排下一步：划到最后一条后停 resetDelay，再清空重来\n  React.useEffect(() => {\n    if (!autoplay || paused || reduced || !inView || total === 0) return\n    const current = step ?? initialStep(total)\n    const id = window.setTimeout(\n      () => setStep(current >= total ? 0 : current + 1),\n      current >= total ? resetDelay : interval,\n    )\n    return () => window.clearTimeout(id)\n  }, [autoplay, paused, reduced, inView, total, step, interval, resetDelay])\n\n  const value = React.useMemo(\n    () => ({\n      size,\n      tone,\n      autoplay,\n      step,\n      total,\n      checked: Object.values(reported).filter(Boolean).length,\n      setTotal,\n      report,\n    }),\n    [size, tone, autoplay, step, total, reported, report],\n  )\n\n  return (\n    <ChecklistContext.Provider value={value}>\n      <div\n        ref={ref}\n        data-slot=\"checklist\"\n        data-size={size}\n        data-autoplay={autoplay || undefined}\n        className={cn(\"group/checklist flex flex-col\", className)}\n        {...props}\n      />\n    </ChecklistContext.Provider>\n  )\n}\n\n/** 条目列表：给每个子级编号（自动播放按编号排序），并把条目数回报给 `Checklist`。 */\nfunction ChecklistList({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<\"ul\">) {\n  const ctx = React.useContext(ChecklistContext)\n  const items = React.Children.toArray(children)\n  const count = items.length\n  const setTotal = ctx?.setTotal\n  React.useEffect(() => setTotal?.(count), [setTotal, count])\n  return (\n    <ul\n      data-slot=\"checklist-list\"\n      className={cn(\"flex flex-col\", className)}\n      {...props}\n    >\n      {items.map((child, index) => (\n        <ChecklistRowContext.Provider\n          key={React.isValidElement(child) ? (child.key ?? index) : index}\n          value={{ index, count }}\n        >\n          {child}\n        </ChecklistRowContext.Provider>\n      ))}\n    </ul>\n  )\n}\n\ntype ChecklistItemProps = Omit<\n  React.ComponentProps<\"li\">,\n  \"onChange\" | \"defaultChecked\"\n> & {\n  /** 受控的勾选状态 */\n  checked?: boolean\n  /**\n   * 非受控的初始勾选状态\n   * @default false\n   */\n  defaultChecked?: boolean\n  /** 勾选状态变化时回调 */\n  onCheckedChange?: (checked: boolean) => void\n  /**\n   * 禁用\n   * @default false\n   */\n  disabled?: boolean\n  /**\n   * 只读：能看到状态，但不能点击切换。自动播放时条目自动只读\n   * @default false\n   */\n  readOnly?: boolean\n  /** 这一条划线的色调，不传则用 `Checklist` 的 `tone` */\n  tone?: FlowTone\n  /** 行尾的辅助信息（耗时、负责人等），等宽小字、靠右对齐，不参与划线 */\n  meta?: React.ReactNode\n}\n\n/** 清单条目：Base UI 复选框配手写划线，勾选时文字被划掉并变淡。 */\nfunction ChecklistItem({\n  className,\n  checked: checkedProp,\n  defaultChecked = false,\n  onCheckedChange,\n  disabled = false,\n  readOnly = false,\n  tone,\n  meta,\n  children,\n  style,\n  ...props\n}: ChecklistItemProps) {\n  const ctx = React.useContext(ChecklistContext)\n  const row = React.useContext(ChecklistRowContext)\n  const [own, setOwn] = useControllableState({\n    prop: checkedProp,\n    defaultProp: defaultChecked,\n    onChange: onCheckedChange,\n  })\n  // 自动播放时由步数决定；服务端和首次渲染用静态快照，水合后不会闪烁\n  const driven = ctx?.autoplay && row\n  const checked = driven\n    ? row.index < (ctx.step ?? initialStep(row.count))\n    : own\n\n  const report = ctx?.report\n  const index = row?.index\n  React.useEffect(() => {\n    if (!report || index === undefined) return\n    report(index, checked)\n    return () => report(index, false)\n  }, [report, index, checked])\n\n  const locked = readOnly || !!driven\n  const itemTone = tone ?? ctx?.tone ?? \"foreground\"\n  return (\n    <li\n      data-slot=\"checklist-item\"\n      data-checked={checked || undefined}\n      data-readonly={locked || undefined}\n      data-disabled={disabled || undefined}\n      className={cn(\"group/item\", className)}\n      // 默认色调下复选框沿用主题强调色；指定了色调时，勾选后的填充也跟着划线变色\n      style={{\n        ...flowToneStyle(itemTone),\n        ...(itemTone !== \"foreground\" && {\n          \"--checklist-fill\": \"var(--flow-tone)\",\n        }),\n        ...style,\n      }}\n      {...props}\n    >\n      <label\n        className={cn(\n          \"flex items-center gap-3 py-2 text-sm group-data-[size=sm]/checklist:gap-2.5 group-data-[size=sm]/checklist:py-1.5 group-data-[size=sm]/checklist:text-[13px]\",\n          locked || disabled ? \"cursor-default\" : \"cursor-pointer\",\n          disabled && \"opacity-50\",\n        )}\n      >\n        <CheckboxPrimitive.Root\n          checked={checked}\n          onCheckedChange={(next) => setOwn(next)}\n          disabled={disabled}\n          readOnly={locked}\n          data-slot=\"checklist-checkbox\"\n          className=\"group/box relative flex size-5 shrink-0 items-center justify-center rounded-md border border-input bg-background text-primary-foreground transition-colors outline-none group-data-[size=sm]/checklist:size-4 group-data-[size=sm]/checklist:rounded-[5px] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none data-checked:border-[var(--checklist-fill,var(--primary))] data-checked:bg-[var(--checklist-fill,var(--primary))]\"\n        >\n          <CheckboxPrimitive.Indicator\n            keepMounted\n            data-slot=\"checklist-indicator\"\n            className=\"grid place-content-center\"\n          >\n            <svg\n              viewBox=\"0 0 16 16\"\n              aria-hidden=\"true\"\n              className=\"size-3.5 group-data-[size=sm]/checklist:size-3\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={2.4}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            >\n              <path\n                d=\"M3.5 8.5l3 3 6-7\"\n                pathLength={1}\n                className=\"[stroke-dasharray:1_2] [stroke-dashoffset:1] [transition:stroke-dashoffset_0.25s_ease-out] group-data-checked/box:[transition-delay:0.12s] group-data-checked/box:[stroke-dashoffset:0] motion-reduce:[transition:none]\"\n              />\n            </svg>\n          </CheckboxPrimitive.Indicator>\n        </CheckboxPrimitive.Root>\n        <span className=\"relative inline-block min-w-0 whitespace-nowrap\">\n          <span\n            className={cn(\n              \"transition-colors duration-500 group-data-checked/item:text-muted-foreground motion-reduce:transition-none\",\n            )}\n          >\n            {children}\n          </span>\n          <Scribble checked={checked} seed={index ?? 0} />\n        </span>\n        {meta ? (\n          <span\n            data-slot=\"checklist-meta\"\n            className=\"ml-auto shrink-0 pl-3 font-mono text-[11px] text-muted-foreground/70\"\n          >\n            {meta}\n          </span>\n        ) : null}\n      </label>\n    </li>\n  )\n}\n\ntype ChecklistProgressProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** 总条数；服务端输出时也想显示准确计数就传它，不传则等条目挂载后自动统计 */\n  total?: number\n  /**\n   * 进度条填充的色调\n   * @default \"primary\"\n   */\n  tone?: FlowTone\n  /**\n   * 读屏名称\n   * @default \"完成进度\"\n   */\n  label?: string\n}\n\n/** 进度汇总：「已完成 / 总数」与进度条，手动勾选和自动播放都会实时更新。 */\nfunction ChecklistProgress({\n  className,\n  total: totalProp,\n  tone = \"primary\",\n  label = \"完成进度\",\n  style,\n  ...props\n}: ChecklistProgressProps) {\n  const ctx = React.useContext(ChecklistContext)\n  const total = totalProp ?? ctx?.total ?? 0\n  const checked = ctx?.autoplay\n    ? Math.min(total, ctx.step ?? initialStep(total))\n    : (ctx?.checked ?? 0)\n  const percent = total ? (checked / total) * 100 : 0\n  return (\n    <div\n      data-slot=\"checklist-progress\"\n      className={cn(\"flex items-center gap-3\", className)}\n      style={{ ...flowToneStyle(tone), ...style }}\n      {...props}\n    >\n      <div\n        role=\"progressbar\"\n        aria-label={label}\n        aria-valuemin={0}\n        aria-valuemax={total}\n        aria-valuenow={checked}\n        className=\"h-1.5 flex-1 overflow-hidden rounded-full bg-muted\"\n      >\n        <div\n          data-slot=\"checklist-progress-fill\"\n          className=\"h-full rounded-full bg-(--flow-tone) transition-[width] duration-500 ease-out motion-reduce:transition-none\"\n          style={{ width: `${percent}%` }}\n        />\n      </div>\n      <span className=\"min-w-[2.5ch] text-right font-mono text-xs text-muted-foreground tabular-nums\">\n        {total ? `${checked}/${total}` : null}\n      </span>\n    </div>\n  )\n}\n\nexport { Checklist, ChecklistItem, ChecklistList, ChecklistProgress }\nexport type {\n  ChecklistItemProps,\n  ChecklistProgressProps,\n  ChecklistProps,\n  ChecklistSize,\n}\n",
      "type": "registry:ui",
      "target": "@ui/checklist/checklist.tsx"
    },
    {
      "path": "src/registry/ui/checklist/scribble.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n// 只用整数运算的伪随机数：服务端与浏览器生成同一条路径，水合时不会出现差异\nfunction createRandom(seed: number) {\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\nconst HEIGHT = 16\n// 量到真实宽度之前（服务端输出、首次渲染）用的占位宽度，随后会被量到的值替换\nconst FALLBACK_WIDTH = 120\n\n/** 把折线点平滑成二次曲线：每个中间点作控制点，相邻点的中点作端点。 */\nfunction smooth(points: [number, number][]) {\n  const fmt = (n: number) => n.toFixed(1)\n  const [first, ...rest] = points\n  if (!first) return \"\"\n  let d = `M${fmt(first[0])} ${fmt(first[1])}`\n  rest.forEach((point, i) => {\n    const next = rest[i + 1]\n    d += next\n      ? ` Q${fmt(point[0])} ${fmt(point[1])} ${fmt((point[0] + next[0]) / 2)} ${fmt((point[1] + next[1]) / 2)}`\n      : ` L${fmt(point[0])} ${fmt(point[1])}`\n  })\n  return d\n}\n\n/**\n * 生成「手写划掉」的路径：一道略有起伏的长划线，右端折回，再往回补半截。\n * 起伏来自 seed，同一个 seed 路径固定，不同条目的划线各不相同。\n */\nfunction scribblePath(width: number, seed: number) {\n  const random = createRandom(seed)\n  const jitter = (amount: number) => (random() - 0.5) * 2 * amount\n  const mid = HEIGHT / 2\n  const steps = Math.max(3, Math.round(width / 40))\n  const forward: [number, number][] = Array.from(\n    { length: steps + 1 },\n    (_, i) => [-3 + ((width + 6) * i) / steps, mid - 1 + jitter(1.8)],\n  )\n  const back: [number, number][] = [\n    [width + 3, mid + 2.5 + jitter(0.8)],\n    [width * 0.72, mid + 2 + jitter(1.4)],\n    [width * 0.5, mid + 3 + jitter(1)],\n  ]\n  return smooth([...forward, ...back])\n}\n\n/**\n * 盖在文字上的手写划线：宽度量自文字本身，所以中文、英文、数字都能完整划过。\n * 用 `pathLength` 归一化后以 dash 偏移做描绘动画，系统开启「减少动态效果」时直接显示结果。\n */\nfunction Scribble({\n  checked,\n  seed = 0,\n  className,\n  ...props\n}: Omit<React.ComponentProps<\"svg\">, \"children\"> & {\n  /** 是否已划掉 */\n  checked: boolean\n  /** 路径种子，决定起伏 */\n  seed?: number\n}) {\n  const ref = React.useRef<SVGSVGElement>(null)\n  const [width, setWidth] = React.useState(FALLBACK_WIDTH)\n  React.useLayoutEffect(() => {\n    const svg = ref.current\n    if (!svg) return\n    const measure = () => {\n      const next = Math.round(svg.getBoundingClientRect().width)\n      if (next > 0) setWidth(next)\n    }\n    measure()\n    const observer = new ResizeObserver(measure)\n    observer.observe(svg)\n    return () => observer.disconnect()\n  }, [])\n  return (\n    <svg\n      ref={ref}\n      data-slot=\"checklist-scribble\"\n      data-checked={checked || undefined}\n      aria-hidden=\"true\"\n      viewBox={`0 0 ${width} ${HEIGHT}`}\n      preserveAspectRatio=\"none\"\n      className={cn(\n        \"pointer-events-none absolute inset-x-0 top-1/2 h-4 w-full -translate-y-1/2 overflow-visible\",\n        className,\n      )}\n      {...props}\n    >\n      <path\n        d={scribblePath(width, seed)}\n        pathLength={1}\n        fill=\"none\"\n        strokeWidth={1.6}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        className={cn(\n          \"stroke-(--flow-tone)/80 [stroke-dasharray:1_2] motion-reduce:[transition:none]\",\n          checked\n            ? \"opacity-100 [stroke-dashoffset:0] [transition:stroke-dashoffset_0.7s_cubic-bezier(0.4,0,0.2,1),opacity_0s]\"\n            : \"opacity-0 [stroke-dashoffset:1] [transition:stroke-dashoffset_0.4s_ease-in,opacity_0s_0.4s]\",\n        )}\n      />\n    </svg>\n  )\n}\n\nexport { Scribble, scribblePath }\n",
      "type": "registry:ui",
      "target": "@ui/checklist/scribble.tsx"
    },
    {
      "path": "src/registry/ui/checklist/context.ts",
      "content": "import * as React from \"react\"\n\nimport type { FlowTone } from \"@/lib/flow-tone\"\n\ntype ChecklistSize = \"default\" | \"sm\"\n\ntype ChecklistContextValue = {\n  size: ChecklistSize\n  tone: FlowTone\n  autoplay: boolean\n  /** 自动播放时已划掉的条数；还没开始走时为 null，用 `initialStep` 兜底 */\n  step: number | null\n  /** 列表里的条目数，由 `ChecklistList` 回报 */\n  total: number\n  /** 手动模式下已勾选的条数 */\n  checked: number\n  setTotal: (total: number) => void\n  report: (index: number, checked: boolean) => void\n}\n\nconst ChecklistContext = React.createContext<ChecklistContextValue | null>(null)\n/** 条目在列表里的位置，由 `ChecklistList` 逐个提供，自动播放按它排序。 */\nconst ChecklistRowContext = React.createContext<{\n  index: number\n  count: number\n} | null>(null)\n\n/** 自动播放开始前（服务端输出、减少动态效果）显示的静态快照：划掉约六成。 */\nfunction initialStep(count: number) {\n  return Math.ceil(count * 0.6)\n}\n\nexport { ChecklistContext, ChecklistRowContext, initialStep }\nexport type { ChecklistContextValue, ChecklistSize }\n",
      "type": "registry:ui",
      "target": "@ui/checklist/context.ts"
    },
    {
      "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": {
    "light": {
      "flow-blue": "oklch(0.56 0.2 258)",
      "flow-green": "oklch(0.62 0.16 150)",
      "flow-amber": "oklch(0.7 0.16 70)",
      "flow-rose": "oklch(0.62 0.21 5)",
      "flow-violet": "oklch(0.56 0.22 292)",
      "flow-cyan": "oklch(0.66 0.12 215)"
    },
    "dark": {
      "flow-blue": "oklch(0.72 0.15 255)",
      "flow-green": "oklch(0.77 0.17 152)",
      "flow-amber": "oklch(0.82 0.15 82)",
      "flow-rose": "oklch(0.74 0.16 8)",
      "flow-violet": "oklch(0.74 0.15 295)",
      "flow-cyan": "oklch(0.82 0.11 208)"
    }
  },
  "type": "registry:ui"
}
