{
  "name": "ink",
  "type": "registry:ui",
  "title": "Ink",
  "description": "The editing feel: there is no edit mode. One ProseMirror-backed primitive for every text seat — line/text/prose — with continuous save, real undo, Esc burst-revert, and [[ mentions.",
  "dependencies": [
    "@candlelight-ds/tokens",
    "@tiptap/react",
    "@tiptap/starter-kit",
    "@tiptap/core",
    "@tiptap/extension-document",
    "@tiptap/extension-paragraph",
    "@tiptap/extension-text",
    "@tiptap/extensions",
    "tiptap-markdown",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "menu",
    "utils"
  ],
  "files": [
    {
      "path": "components/ui/Ink.tsx",
      "type": "registry:ui",
      "content": "'use client'\n\n/**\n * Ink — the one editing feel (spec §6). Text is simply always warm: the\n * rendered text IS the editor, the caret is the only mode. No border, no\n * fill, no padding delta — reading and writing are the same visual state.\n *\n * Three kinds, one contract:\n *   line  — single block, no marks (titles, names, fact values).\n *           Enter commits and blurs (Enter stays sacred to the commit key).\n *   text  — multi-paragraph plain text.\n *   prose — markdown blocks (h2/h3, lists, quotes, bold/italic;\n *           shortcuts and ⌘B/⌘I free).\n *\n * The save contract:\n *   - Continuous: onSave fires debounced (~600ms idle) while typing and\n *     flushes on blur, on tab-hide (the mid-session close is the common\n *     exit), and on unmount.\n *   - Esc reverts the CURRENT BURST (since the last save), then blurs\n *     line seats — cancel without holding words hostage.\n *   - ⌘Z is real: ProseMirror history, alive across saves because the\n *     editor never unmounts.\n *\n * ProseMirror owns the contenteditable (hand-rolled contentEditable is\n * banned). Typing is uncontrolled — zero React renders per keystroke.\n *\n * Use when: any text the user owns.\n * Don't use when: genuine forms (URLs, keys — forms stay forms) or search\n * fields (those are queries, not documents).\n */\n\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react'\nimport { EditorContent, useEditor, type Editor } from '@tiptap/react'\nimport StarterKit from '@tiptap/starter-kit'\nimport Document from '@tiptap/extension-document'\nimport Paragraph from '@tiptap/extension-paragraph'\nimport Text from '@tiptap/extension-text'\nimport { UndoRedo } from '@tiptap/extensions'\nimport { Extension } from '@tiptap/core'\nimport { Markdown, type MarkdownStorage } from 'tiptap-markdown'\n// The law number lives in ONE home (spec §6) — import, never re-declare.\nimport { INK_SAVE_DEBOUNCE_MS } from '@candlelight-ds/tokens/metrics'\nimport { ContextMenu } from '@/components/ui/Menu'\nimport { cn } from '@/lib/utils'\n\nexport type InkKind = 'line' | 'text' | 'prose'\n\ninterface InkProps {\n  kind: InkKind\n  /** Markdown for prose; plain text for line/text. */\n  value: string\n  /** The continuous save — debounced while typing, flushed on blur and\n   *  tab-hide. Callers patch their cache optimistically (idempotent). */\n  onSave: (next: string) => void\n  placeholder?: string\n  /** Merged over the kind's base type so a seat keeps its own voice. */\n  textClassName?: string\n  className?: string\n  /** Right-click verbs (the Menu engine) — every seat gets a menu. */\n  menu?: React.ReactNode\n  /** line seats refuse to save empty (renames); text/prose clear freely. */\n  allowEmpty?: boolean\n  /** Read-only face: same look, zero write paths. */\n  readOnly?: boolean\n  ariaLabel?: string\n  /** Debounce for the continuous save (test seam). */\n  saveDelayMs?: number\n  /** Controlled entry (the R-key contract, G8): flipping true focuses\n   *  the seat and selects all — a rename wants replacement. Blur and\n   *  Esc report false. Uncontrolled seats just take the caret. */\n  editing?: boolean\n  onEditingChange?: (editing: boolean) => void\n  /** `[[` mentions (prose seats): the product's object index, queried as\n   *  the user types. Picking inserts `[[Name]]` — the caller extracts\n   *  and resolves mentions on save. */\n  mentionSource?: (query: string) => Array<{ id: string; name: string }>\n  /** Accessible name for the mention popup — supply it from your copy\n   *  module (spec §9/§12). */\n  mentionListLabel?: string\n}\n\n/* The prose typography — one voice for every prose seat. */\nconst PROSE_CLASS = [\n  '[&>*+*]:mt-2',\n  '[&_h2]:text-eyebrow [&_h2]:font-normal [&_h2]:uppercase [&_h2]:tracking-label [&_h2]:text-ink-faint',\n  '[&_h3]:text-eyebrow [&_h3]:font-normal [&_h3]:uppercase [&_h3]:tracking-label [&_h3]:text-ink-faint',\n  '[&>h2:not(:first-child)]:mt-4 [&>h3:not(:first-child)]:mt-4',\n  '[&_ul]:list-disc [&_ul]:ps-4 [&_ol]:list-decimal [&_ol]:ps-4',\n  '[&_li]:my-1 [&_li]:marker:text-ink-faint',\n  '[&_blockquote]:border-s-2 [&_blockquote]:border-hairline [&_blockquote]:ps-3 [&_blockquote]:text-ink-faint',\n  '[&_strong]:font-semibold [&_strong]:text-ink',\n].join(' ')\n\n/** Serialize by kind: markdown for prose, plain text otherwise. */\nfunction readValue(editor: Editor, kind: InkKind): string {\n  if (kind === 'prose') {\n    return (editor.storage as unknown as { markdown: MarkdownStorage }).markdown.getMarkdown()\n  }\n  return editor.getText({ blockSeparator: '\\n' })\n}\n\nexport function Ink({\n  kind,\n  value,\n  onSave,\n  placeholder,\n  textClassName,\n  className,\n  menu,\n  allowEmpty = kind !== 'line',\n  readOnly = false,\n  ariaLabel,\n  saveDelayMs = INK_SAVE_DEBOUNCE_MS,\n  editing,\n  onEditingChange,\n  mentionSource,\n  mentionListLabel = 'Mentions',\n}: InkProps) {\n  const [empty, setEmpty] = useState(() => value.trim() === '')\n  /** The last value the outside holds — saves and reverts compare here. */\n  const knownRef = useRef(value)\n  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n  const onSaveRef = useRef(onSave)\n  const allowEmptyRef = useRef(allowEmpty)\n  /** The keymap extension closes over renders — reach flush by ref. */\n  const flushRef = useRef<((target: Editor) => void) | null>(null)\n  const onEditingChangeRef = useRef(onEditingChange)\n  const mentionSourceRef = useRef(mentionSource)\n\n  /* The `[[` mention state: the open query, its doc range, and the\n   * highlighted pick. Null when no `[[…` sits before the caret. */\n  const [mention, setMention] = useState<{\n    query: string\n    from: number\n    to: number\n    highlight: number\n  } | null>(null)\n  const mentionRef = useRef(mention)\n\n  /** `[[quer` before the caret opens the popup; anything else closes it.\n   *  Focus reads the live view (activeElement), not the focus plugin's\n   *  tracked state — the plugin can lag programmatic focus and the\n   *  popup must answer to where the caret actually is. */\n  function trackMention(target: Editor) {\n    if (!mentionSourceRef.current || kind !== 'prose') return\n    const { $from, empty: caretOnly } = target.state.selection\n    if (!caretOnly || !target.view.hasFocus()) {\n      setMention(null)\n      return\n    }\n    const before = $from.parent.textBetween(0, $from.parentOffset, undefined, '￼')\n    const match = /\\[\\[([^\\][\\n]*)$/.exec(before)\n    if (!match) {\n      setMention(null)\n      return\n    }\n    const from = $from.pos - match[1].length - 2\n    setMention((current) => ({\n      query: match[1],\n      from,\n      to: $from.pos,\n      highlight: current?.query === match[1] ? current.highlight : 0,\n    }))\n  }\n\n  /** Complete the mention: `[[Name]] ` replaces the open `[[query`. */\n  function pickMention(name: string) {\n    const target = editorRef.current\n    const open = mentionRef.current\n    if (!target || !open) return\n    target\n      .chain()\n      .focus()\n      .insertContentAt({ from: open.from, to: open.to }, `[[${name}]] `)\n      .run()\n    setMention(null)\n  }\n\n  function scheduleSave(target: Editor) {\n    if (timerRef.current) clearTimeout(timerRef.current)\n    timerRef.current = setTimeout(() => {\n      timerRef.current = null\n      persist(target)\n    }, saveDelayMs)\n  }\n\n  /** Write if changed; empty line values revert instead of saving. */\n  function persist(target: Editor) {\n    const raw = readValue(target, kind)\n    const next = kind === 'prose' ? raw : raw.trim()\n    if (next === knownRef.current) return\n    if (!next && !allowEmptyRef.current) return\n    knownRef.current = next\n    onSaveRef.current(next)\n  }\n\n  function flush(target: Editor) {\n    if (timerRef.current) {\n      clearTimeout(timerRef.current)\n      timerRef.current = null\n    }\n    persist(target)\n    // An empty line seat snaps back to its kept value on leave.\n    const raw = readValue(target, kind)\n    if (kind !== 'prose' && !raw.trim() && !allowEmptyRef.current && knownRef.current) {\n      target.commands.setContent(plainToDoc(knownRef.current, kind), { emitUpdate: false })\n      setEmpty(false)\n    }\n  }\n\n  const extensions =\n    kind === 'prose'\n      ? [\n          StarterKit.configure({\n            heading: { levels: [2, 3] },\n            code: false,\n            codeBlock: false,\n            horizontalRule: false,\n            link: false,\n            strike: false,\n            underline: false,\n            trailingNode: false,\n          }),\n          Markdown.configure({ html: false }),\n        ]\n      : [\n          // Plain schemas: no marks, no shortcuts. `line` is exactly one\n          // paragraph — Enter has nothing to split.\n          Document.extend(kind === 'line' ? { content: 'paragraph' } : {}),\n          Paragraph,\n          Text,\n          UndoRedo,\n          Extension.create({\n            name: 'inkKeys',\n            addKeyboardShortcuts() {\n              return {\n                // line: Enter commits and blurs — the field never\n                // \"dismisses\", the caret just leaves. The flush runs\n                // directly; blur's own flush is an idempotent echo.\n                // text: Enter splits paragraphs as usual.\n                Enter: ({ editor }) => {\n                  if (kind !== 'line') return false\n                  flushRef.current?.(editor as Editor)\n                  editor.commands.blur()\n                  return true\n                },\n              }\n            },\n          }),\n        ]\n\n  const editor = useEditor({\n    immediatelyRender: false,\n    editable: !readOnly,\n    extensions,\n    content: kind === 'prose' ? value : plainToDoc(value, kind),\n    editorProps: {\n      attributes: {\n        class: cn(\n          'focus:outline-none',\n          kind === 'prose' && PROSE_CLASS,\n          kind === 'text' && 'whitespace-pre-wrap [&>*+*]:mt-2',\n          // Line seats truncate at rest (row names); the caret unwraps.\n          kind === 'line' && '[&:not(:focus)>p]:truncate [&:not(:focus)>p]:overflow-hidden'\n        ),\n        role: 'textbox',\n        'aria-multiline': kind === 'line' ? 'false' : 'true',\n        ...(ariaLabel ? { 'aria-label': ariaLabel } : {}),\n      },\n      transformPastedText: (pasted) =>\n        kind === 'line' ? pasted.replace(/\\s*\\n+\\s*/g, ' ') : pasted,\n      // ⚠️ TRAP (shipped once — do not re-learn). The mention popup's keys\n      // live HERE, not in the wrapper's React onKeyDown: view props run\n      // before every keymap plugin, while React's root-delegated handler\n      // fires only after ProseMirror's own DOM listener has already split\n      // the paragraph on Enter or walked the caret on the boundary arrows.\n      // Everything mutable is reached through refs; this closure outlives\n      // every render. stopPropagation keeps the handled key from reaching\n      // the wrapper's Esc-revert below.\n      handleKeyDown: (view, event) => {\n        if (kind !== 'prose') return false\n        const open = mentionRef.current\n        if (!open || !mentionSourceRef.current) return false\n        const items = mentionSourceRef.current(open.query).slice(0, 6)\n        if (items.length === 0) return false\n        if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {\n          event.preventDefault()\n          event.stopPropagation()\n          const delta = event.key === 'ArrowDown' ? 1 : -1\n          setMention({\n            ...open,\n            highlight: Math.max(0, Math.min(items.length - 1, open.highlight + delta)),\n          })\n          return true\n        }\n        if (event.key === 'Enter' || event.key === 'Tab') {\n          event.preventDefault()\n          event.stopPropagation()\n          pickMention(items[Math.min(open.highlight, items.length - 1)].name)\n          return true\n        }\n        if (event.key === 'Escape') {\n          event.preventDefault()\n          event.stopPropagation()\n          setMention(null)\n          return true\n        }\n        return false\n      },\n    },\n    onCreate: ({ editor: created }) => setEmpty(created.isEmpty),\n    onUpdate: ({ editor: updated }) => {\n      setEmpty(updated.isEmpty)\n      scheduleSave(updated)\n      trackMention(updated)\n    },\n    onSelectionUpdate: ({ editor: moved }) => trackMention(moved),\n    onBlur: ({ editor: blurred }) => {\n      flush(blurred)\n      setMention(null)\n      onEditingChangeRef.current?.(false)\n    },\n  })\n\n  const editorRef = useRef(editor)\n\n  // Latest-ref mirrors, written in a layout effect rather than during\n  // render (concurrent renders may be discarded; the editor's DOM-event\n  // closures must only ever see committed values). Runs every render on\n  // purpose — these refs ARE the freshness mechanism.\n  useLayoutEffect(() => {\n    onSaveRef.current = onSave\n    allowEmptyRef.current = allowEmpty\n    onEditingChangeRef.current = onEditingChange\n    mentionSourceRef.current = mentionSource\n    mentionRef.current = mention\n    editorRef.current = editor\n    flushRef.current = flush\n  })\n\n  /** Esc — revert the current burst (since the last save), line blurs. */\n  function revertBurst() {\n    const target = editorRef.current\n    if (!target) return\n    if (timerRef.current) {\n      clearTimeout(timerRef.current)\n      timerRef.current = null\n    }\n    target.commands.setContent(\n      kind === 'prose' ? knownRef.current : plainToDoc(knownRef.current, kind),\n      { emitUpdate: false }\n    )\n    setEmpty(knownRef.current.trim() === '')\n    if (kind === 'line') target.commands.blur()\n  }\n\n  // Tab-hide flushes the pending burst — the mid-session close loses ~0 words.\n  useEffect(() => {\n    const onHide = () => {\n      const target = editorRef.current\n      if (target && document.visibilityState === 'hidden') flush(target)\n    }\n    document.addEventListener('visibilitychange', onHide)\n    return () => {\n      document.removeEventListener('visibilitychange', onHide)\n      // Unmount flushes too.\n      const target = editorRef.current\n      if (target && !target.isDestroyed) flush(target)\n      if (timerRef.current) clearTimeout(timerRef.current)\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  // Outside writes (optimistic round-trips, section moves) land while\n  // the editor is idle; typing is never clobbered.\n  useEffect(() => {\n    if (!editor || editor.isFocused) return\n    if (value === knownRef.current) return\n    knownRef.current = value\n    editor.commands.setContent(\n      kind === 'prose' ? value : plainToDoc(value, kind),\n      { emitUpdate: false }\n    )\n    setEmpty(editor.isEmpty)\n  }, [editor, value, kind])\n\n  useEffect(() => {\n    editor?.setEditable(!readOnly)\n  }, [editor, readOnly])\n\n  // Controlled entry (G8): editing=true focuses and selects all — a\n  // rename wants replacement. The blur handler reports false.\n  useEffect(() => {\n    if (!editor || !editing) return\n    // Focus after paint; the editor may have just mounted.\n    requestAnimationFrame(() => {\n      if (!editor.isDestroyed) editor.chain().focus('end').selectAll().run()\n    })\n  }, [editor, editing])\n\n  const mentionItems =\n    mention && mentionSource ? mentionSource(mention.query).slice(0, 6) : []\n\n  const seat = (\n    <div\n      className={cn('relative min-w-0', className)}\n      onKeyDown={(event) => {\n        // Keys stay in the seat — surface shortcuts never fire mid-word.\n        // The mention popup's keys never arrive here: the editor's\n        // handleKeyDown owns them (and stops propagation) because React's\n        // delegated handler fires too late to beat PM's keymaps.\n        event.stopPropagation()\n        if (event.key === 'Escape') {\n          event.preventDefault()\n          revertBurst()\n          onEditingChangeRef.current?.(false)\n        }\n      }}\n    >\n      {empty && placeholder ? (\n        <span\n          aria-hidden\n          className={cn(\n            'pointer-events-none absolute start-0 top-0 text-ink-faint',\n            textClassName\n          )}\n        >\n          {placeholder}\n        </span>\n      ) : null}\n      <EditorContent editor={editor} className={cn('min-w-0', textClassName)} />\n      {mention && mentionItems.length > 0 ? (\n        /* The `[[` popup — the product's index, in miniature (one menu\n           grammar): quiet rows, accent-armed highlight, Enter picks. */\n        <div\n          role=\"listbox\"\n          aria-label={mentionListLabel}\n          className=\"absolute start-0 top-full z-float mt-1 w-[260px] overflow-hidden rounded-sm border border-hairline bg-surface-2 py-1 shadow-float\"\n        >\n          {mentionItems.map((item, index) => (\n            <button\n              key={item.id}\n              type=\"button\"\n              role=\"option\"\n              aria-selected={index === mention.highlight}\n              // Mousedown, not click — the editor must not blur first.\n              onMouseDown={(event) => {\n                event.preventDefault()\n                pickMention(item.name)\n              }}\n              className={cn(\n                'flex h-8 w-full items-center px-3 text-start text-body text-ink transition-colors',\n                index === mention.highlight ? 'bg-fill-active' : 'hover:bg-fill-hover'\n              )}\n            >\n              <span className=\"truncate\">{item.name}</span>\n            </button>\n          ))}\n        </div>\n      ) : null}\n    </div>\n  )\n\n  if (!menu || readOnly) return seat\n  return <ContextMenu content={menu}>{seat}</ContextMenu>\n}\n\n/** Plain text → a PM doc: one paragraph for line, split for text. */\nfunction plainToDoc(plain: string, kind: InkKind) {\n  const lines = kind === 'line' ? [plain] : plain.split('\\n')\n  return {\n    type: 'doc',\n    content: lines.map((lineText) => ({\n      type: 'paragraph',\n      ...(lineText ? { content: [{ type: 'text', text: lineText }] } : {}),\n    })),\n  }\n}\n",
      "target": "components/ui/Ink.tsx"
    }
  ],
  "meta": {
    "candlelight": {
      "category": "primitive",
      "gRules": [
        "G8"
      ],
      "useWhen": "Any text the user owns — titles, names, descriptions, documents.",
      "dontUseWhen": "Genuine configuration (URLs, keys — forms stay forms) or search fields (queries, not documents). Create-inputs and renames under navigating links (InlineEdit).",
      "states": [
        "reading (identical to writing)",
        "writing (caret only)",
        "empty (placeholder)",
        "read-only (same face)",
        "mention popup open"
      ],
      "keyboard": [
        {
          "keys": "Enter (line)",
          "does": "commits and blurs — Enter stays sacred to the commit key"
        },
        {
          "keys": "Esc",
          "does": "reverts the current burst since the last save"
        },
        {
          "keys": "⌘Z",
          "does": "real undo, alive across saves"
        },
        {
          "keys": "⌘B/⌘I (prose)",
          "does": "bold/italic; markdown shortcuts free"
        },
        {
          "keys": "[[ (prose)",
          "does": "opens the mention popup; ↑↓ walk, Enter picks, Esc closes"
        }
      ],
      "traps": [
        "Suggestion-popup key priority: popup keys MUST be handled in editorProps.handleKeyDown — a React wrapper's onKeyDown runs after ProseMirror's keymaps, so Enter splits the paragraph instead of picking."
      ],
      "acceptanceTests": [
        "The squint test: a screenshot of editing vs reading is identical but the caret.",
        "The keystroke test: typing is indistinguishable from a bare textarea (zero React renders per keystroke).",
        "The crash test: kill the tab mid-word, lose at most one debounce window of words.",
        "The undo test: ⌘Z works across saves because the editor never unmounts.",
        "The menu test: no seat without a right-click menu.",
        "The one-feel test: a stranger can't tell any two seats were different components."
      ]
    }
  }
}