{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ink-stroke",
  "title": "InkStroke (line art on twos)",
  "description": "One SVG <path pathLength=\"1\"> inked on twos by CSS alone: stroke-dashoffset 1 → 0 in steps(poses) at 83.33ms per pose, finished and dash-free at rest, never on load. Includes the seeded inkPencil wobble helper.",
  "registryDependencies": [
    "https://frame12.dev/r/frame12-styles.json",
    "https://frame12.dev/r/use-tempo.json"
  ],
  "files": [
    {
      "path": "ui/ink.tsx",
      "content": "'use client';\n\nimport * as React from 'react';\nimport { useResolvedMotion } from '@/hooks/use-tempo';\n\n// The pencil helper is pure (no hooks), so it lives in a module without 'use client' that server\n// components can call too. Re-exported here so `ink.tsx` stays the one entry point.\nexport { inkPencil, type InkSegment } from './ink-pencil';\n\n// Same union as `MotionMode` in use-on-twos.ts. Declared locally so this file only needs use-tempo.\ntype MotionMode = 'stepped' | 'smooth' | 'cut';\n\n/**\n * Stroke weight. Each maps to a width token and an ink token:\n *   - `pencil` → `--f-stroke-pencil` (1px) in `--f-ink-2`. Decorative construction lines.\n *   - `detail` → `--f-stroke-detail` (1.5px) in `--f-ink`. Lines inside a frame.\n *   - `frame`  → `--f-stroke` (2.5px) in `--f-rule`, mitered. Panel frames.\n */\nexport type InkWeight = 'pencil' | 'detail' | 'frame';\n\nexport interface InkStrokeProps\n  extends Omit<React.SVGProps<SVGPathElement>, 'ref' | 'pathLength' | 'stroke' | 'fill' | 'd'> {\n  /** Path data. Paths only: convert basic shapes to `d` at author time. */\n  d: string;\n  /** Stroke weight (width and ink token). */\n  weight?: InkWeight;\n  /** Poses the stroke takes to draw (integer 1–24). Each pose is held for `--f-pose` (83.33ms). */\n  poses?: number;\n  /** Pose offset at which this stroke starts drawing inside an `InkBeat` (or on its own). */\n  at?: number;\n  /**\n   * Draws the stroke when this changes to `true` after mount. A value that is `true` at mount\n   * never draws (rule 5: nothing plays on load; the `ink-autoplay` lint rule enforces it).\n   * Ignored inside an `InkSequence`, which drives every stroke it contains.\n   */\n  play?: boolean;\n  /** The stroke's own motion, resolved against the viewer's tempo and OS reduced motion. */\n  motion?: MotionMode;\n  /** Called once when the stroke finishes drawing (`animationend`); not when motion cancels it. */\n  onEnd?: () => void;\n}\n\n/** True inside an `InkSequence`: the sequence drives play state and motion for every stroke. */\nexport const InkDrivenContext = React.createContext<boolean>(false);\nInkDrivenContext.displayName = 'InkDrivenContext';\n\n/**\n * True when an animation event belongs to play `cycle`. Plays alternate between two identical\n * keyframe names (…A on odd cycles, …B on even), so an `animationend` that arrives after `play`\n * has changed still says which play it finished. The browser delivers `animationend`\n * asynchronously, so it can land after the consumer has stopped or replayed: a stopped play still\n * reports its completion, and a superseded play's late event never ends the newer one.\n */\nexport function isInkCycleEvent(animationName: string, prefix: string, cycle: number): boolean {\n  return cycle > 0 && animationName === `${prefix}${cycle % 2 ? 'A' : 'B'}`;\n}\n\n/** Clamps a pose count to the legal 1–24 range (integers). */\nexport function clampPoses(poses: number): number {\n  if (!Number.isFinite(poses)) return 6;\n  return Math.min(24, Math.max(1, Math.round(poses)));\n}\n\n/**\n * Tracks a `play` flag the way every ink primitive needs it: `cycle` counts the times `play`\n * turned true after mount (0 means it never did, so the primitive stays at rest), and its parity\n * selects one of two identical keyframe names so a replay always restarts the animation.\n */\nexport function useInkCycle(play: boolean): number {\n  const [prev, setPrev] = React.useState<boolean>(play);\n  const [cycle, setCycle] = React.useState<number>(0);\n  if (play !== prev) {\n    setPrev(play);\n    if (play) setCycle((c) => c + 1);\n  }\n  return cycle;\n}\n\n/** One inked line drawn on twos by CSS alone (`stroke-dashoffset` 1 → 0 over `poses` poses). */\nexport const InkStroke = React.forwardRef<SVGPathElement, InkStrokeProps>(function InkStroke(\n  {\n    d,\n    weight = 'detail',\n    poses,\n    at,\n    play = false,\n    motion = 'stepped',\n    onEnd,\n    className = '',\n    style,\n    onAnimationEnd,\n    ...rest\n  },\n  ref,\n) {\n  const driven = React.useContext(InkDrivenContext);\n  const resolved = useResolvedMotion(motion);\n  const cycle = useInkCycle(play);\n  // A cycle is consumed when it finishes drawing or when motion resolves to cut mid-draw. Only a\n  // new false → true change of `play` draws again; re-enabling motion never replays it.\n  const [ended, setEnded] = React.useState<number>(0);\n  const endedRef = React.useRef<number>(0);\n  if (!driven && resolved === 'cut' && play && cycle > 0 && ended !== cycle) setEnded(cycle);\n  const playing = !driven && play && cycle > 0 && ended !== cycle && resolved !== 'cut';\n\n  const vars: Record<string, string | number> = {};\n  if (poses !== undefined) vars['--f12-ink-poses'] = clampPoses(poses);\n  if (at !== undefined) vars['--f12-ink-at'] = Math.max(0, Math.round(at));\n\n  return (\n    <path\n      {...rest}\n      ref={ref}\n      d={d}\n      pathLength={1}\n      fill=\"none\"\n      stroke=\"currentColor\"\n      className={`f12-ink f12-ink-${weight}${driven ? '' : ` f12-m-${resolved}`}${className ? ` ${className}` : ''}`}\n      data-ink={playing ? (cycle % 2 ? 'a' : 'b') : undefined}\n      aria-hidden={weight === 'pencil' ? true : rest['aria-hidden']}\n      style={{ ...(vars as React.CSSProperties), ...style }}\n      onAnimationEnd={(e) => {\n        onAnimationEnd?.(e);\n        if (\n          !driven &&\n          e.target === e.currentTarget &&\n          isInkCycleEvent(e.animationName, 'f12InkDraw', cycle) &&\n          ended !== cycle &&\n          endedRef.current !== cycle\n        ) {\n          endedRef.current = cycle;\n          setEnded(cycle);\n          onEnd?.();\n        }\n      }}\n    />\n  );\n});\n",
      "type": "registry:ui",
      "target": "components/frame12/ink.tsx"
    },
    {
      "path": "ui/ink-pencil.ts",
      "content": "// Deterministic pencil wobble for FRAME/12 line art (InkStroke weight=\"pencil\").\n// Pure: no React, no hooks, no 'use client', so server components and build scripts can call it.\n// The wobble is a pure function of the geometry and a fixed seed: identical on the server, the\n// client and in every screenshot. No Math.random.\n\n/** mulberry32: a small, fast, seeded PRNG. Same seed → same sequence on server, client and CI. */\nfunction mulberry32(seed: number): () => number {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\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\n/** FNV-1a over the rounded geometry, so the wobble is a pure function of geometry + seed. */\nfunction hashGeometry(values: readonly number[]): number {\n  let h = 0x811c9dc5;\n  for (const v of values) {\n    const s = String(Math.round(v * 100));\n    for (let i = 0; i < s.length; i++) {\n      h ^= s.charCodeAt(i);\n      h = Math.imul(h, 0x01000193) >>> 0;\n    }\n    h ^= 0x2c;\n    h = Math.imul(h, 0x01000193) >>> 0;\n  }\n  return h >>> 0;\n}\n\nconst r2 = (n: number): string => String(Math.round(n * 100) / 100);\n\n/** A straight segment `[x1, y1, x2, y2]` in user units. */\nexport type InkSegment = readonly [number, number, number, number];\n\n/**\n * Pencil construction lines: each segment becomes one slightly bowed, slightly overshot stroke.\n * Segments that chain (one ends where the next starts) join into one continuous subpath with no\n * overshoot at the joint. Use chains for line art that draws over poses: SVG restarts the dash\n * pattern on every subpath, so separate subpaths of one path all draw at the same time.\n * The wobble is a pure function of the geometry and `seed` (no `Math.random`), so server, client\n * and every screenshot produce the identical `d` string.\n */\nexport function inkPencil(segments: readonly InkSegment[], seed = 12, wobble = 0.8): string {\n  const rnd = mulberry32(hashGeometry(segments.flat()) ^ Math.imul(seed | 0, 0x9e3779b1));\n  const j = () => (rnd() * 2 - 1) * wobble;\n  const joins = (a: InkSegment | undefined, b: InkSegment | undefined): boolean =>\n    Boolean(a && b && Math.abs(a[2] - b[0]) < 1e-6 && Math.abs(a[3] - b[1]) < 1e-6);\n  let out = '';\n  segments.forEach(([x1, y1, x2, y2], i) => {\n    const fromPrev = joins(segments[i - 1], segments[i]);\n    const toNext = joins(segments[i], segments[i + 1]);\n    const dx = x2 - x1;\n    const dy = y2 - y1;\n    const len = Math.hypot(dx, dy) || 1;\n    const ux = dx / len;\n    const uy = dy / len;\n    // Free ends overshoot a little and drift sideways; joints stay exact. The middle bows.\n    const o1 = fromPrev ? 0 : 1 + rnd() * 2;\n    const o2 = toNext ? 0 : 1 + rnd() * 2;\n    const bow = j();\n    const d1 = fromPrev ? 0 : j() * 0.5;\n    const d2 = toNext ? 0 : j() * 0.5;\n    const sx = x1 - ux * o1 - uy * d1;\n    const sy = y1 - uy * o1 + ux * d1;\n    const ex = x2 + ux * o2 - uy * d2;\n    const ey = y2 + uy * o2 + ux * d2;\n    const cx = (x1 + x2) / 2 - uy * bow;\n    const cy = (y1 + y2) / 2 + ux * bow;\n    out += `${fromPrev ? '' : `M${r2(sx)} ${r2(sy)}`}Q${r2(cx)} ${r2(cy)} ${r2(ex)} ${r2(ey)}`;\n  });\n  return out;\n}\n",
      "type": "registry:ui",
      "target": "components/frame12/ink-pencil.ts"
    }
  ],
  "type": "registry:ui"
}