{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "eased-code-core",
  "title": "Code core primitive",
  "description": "The engine behind Eased code components: a lazily loaded Shiki singleton (shiki/core + the JavaScript regex engine, grammars for tsx, ts, js, jsx, json, css, bash, html, md, python and diff loaded on demand), the eased-stone (dark) and eased-cream (light) themes, light/dark token lines rendered with CSS light-dark(), the line decoration model (highlight, focus, added, removed, plus the DiffHunk contract), the copy → check morph, collapse-after under a progressive blur, and a page-wide synced choice store.",
  "dependencies": [
    "motion",
    "shiki"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-text-swap.json",
    "https://eased.dev/r/eased-copy.json",
    "https://eased.dev/r/eased-auto-height.json",
    "https://eased.dev/r/progressive-blur.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/_primitives/code-core/highlighter.ts",
      "content": "import type { HighlighterCore, LanguageRegistration } from \"shiki/core\";\nimport { easedCream, easedStone } from \"./themes\";\n\n/**\n * The Shiki singleton every Eased code component shares.\n *\n * - One `HighlighterCore` per page (and per server process), created on first use. Shiki,\n *   its JavaScript regex engine and every grammar are dynamic imports, so a page that never\n *   shows code never downloads them, and one that shows only `bash` never loads TSX.\n * - The JavaScript regex engine, not Oniguruma: no WASM, so it runs in the browser, on the\n *   server and at the edge alike.\n * - Output is plain data (`CodeLine[]`), not HTML: each token carries its light and dark\n *   colour, so the renderer picks one with CSS `light-dark()` and a theme switch never\n *   re-highlights. The data is serialisable, so a server can highlight and hand the result\n *   to a client component (`tokens` prop) with no flash of plain text.\n */\n\n/** One run of text in one colour. `light`/`dark` are hex; missing means \"inherit the foreground\". */\nexport type CodeToken = { content: string; light?: string; dark?: string; italic?: boolean; bold?: boolean };\n/** One line of code as coloured runs. An empty array is a blank line. */\nexport type CodeLine = CodeToken[];\n\n/** Languages with a grammar; anything else renders as plain text. */\nexport type CodeLang = \"tsx\" | \"ts\" | \"js\" | \"jsx\" | \"json\" | \"css\" | \"bash\" | \"html\" | \"md\" | \"python\" | \"diff\";\n\ntype Loader = () => Promise<{ default: LanguageRegistration[] }>;\n\nconst GRAMMARS: Record<CodeLang, { id: string; load: Loader }> = {\n  tsx: { id: \"tsx\", load: () => import(\"shiki/langs/tsx.mjs\") },\n  ts: { id: \"typescript\", load: () => import(\"shiki/langs/typescript.mjs\") },\n  js: { id: \"javascript\", load: () => import(\"shiki/langs/javascript.mjs\") },\n  jsx: { id: \"jsx\", load: () => import(\"shiki/langs/jsx.mjs\") },\n  json: { id: \"json\", load: () => import(\"shiki/langs/json.mjs\") },\n  css: { id: \"css\", load: () => import(\"shiki/langs/css.mjs\") },\n  bash: { id: \"bash\", load: () => import(\"shiki/langs/bash.mjs\") },\n  html: { id: \"html\", load: () => import(\"shiki/langs/html.mjs\") },\n  md: { id: \"markdown\", load: () => import(\"shiki/langs/markdown.mjs\") },\n  python: { id: \"python\", load: () => import(\"shiki/langs/python.mjs\") },\n  diff: { id: \"diff\", load: () => import(\"shiki/langs/diff.mjs\") },\n};\n\nconst ALIASES: Record<string, CodeLang> = {\n  typescript: \"ts\",\n  javascript: \"js\",\n  mjs: \"js\",\n  cjs: \"js\",\n  mts: \"ts\",\n  cts: \"ts\",\n  sh: \"bash\",\n  shell: \"bash\",\n  shellscript: \"bash\",\n  zsh: \"bash\",\n  console: \"bash\",\n  markdown: \"md\",\n  mdx: \"md\",\n  py: \"python\",\n  patch: \"diff\",\n  jsonc: \"json\",\n  htm: \"html\",\n};\n\n/** Short display names for the language pill. */\nexport const LANG_LABEL: Record<CodeLang, string> = {\n  tsx: \"TSX\",\n  ts: \"TypeScript\",\n  js: \"JavaScript\",\n  jsx: \"JSX\",\n  json: \"JSON\",\n  css: \"CSS\",\n  bash: \"Shell\",\n  html: \"HTML\",\n  md: \"Markdown\",\n  python: \"Python\",\n  diff: \"Diff\",\n};\n\n/** Resolve a fence name or file extension (\"typescript\", \"sh\", \"py\") to a supported language, or null. */\nexport function resolveLang(lang: string | undefined | null): CodeLang | null {\n  if (!lang) return null;\n  const key = lang.toLowerCase().trim();\n  if (key in GRAMMARS) return key as CodeLang;\n  return ALIASES[key] ?? null;\n}\n\n/** The language a file name implies: `button.tsx` → \"tsx\", `README.md` → \"md\". */\nexport function langFromFilename(name: string | undefined): CodeLang | null {\n  const ext = name?.split(\".\").pop();\n  return ext && ext !== name ? resolveLang(ext) : null;\n}\n\nconst THEMES = { light: easedCream.name!, dark: easedStone.name! };\n\nlet core: Promise<HighlighterCore> | null = null;\n/** The resolved highlighter, so cached grammars tokenise synchronously. */\nlet ready: HighlighterCore | null = null;\nconst loading = new Map<CodeLang, Promise<void>>();\nconst loaded = new Set<CodeLang>();\n\n/** The shared highlighter, created on first call with both Eased themes and no grammars. */\nexport function getHighlighter(): Promise<HighlighterCore> {\n  core ??= Promise.all([import(\"shiki/core\"), import(\"shiki/engine/javascript\")]).then(([{ createHighlighterCore }, { createJavaScriptRegexEngine }]) =>\n    createHighlighterCore({\n      themes: [easedStone, easedCream],\n      langs: [],\n      // Forgiving: a grammar pattern the JS engine cannot compile is skipped, not fatal.\n      engine: createJavaScriptRegexEngine({ forgiving: true }),\n    }),\n  ).then((h) => (ready = h));\n  return core;\n}\n\n/** Load one grammar (once; concurrent callers share the request). */\nexport function loadLang(lang: CodeLang): Promise<void> {\n  if (loaded.has(lang)) return Promise.resolve();\n  let p = loading.get(lang);\n  if (!p) {\n    p = Promise.all([getHighlighter(), GRAMMARS[lang].load()])\n      .then(([h, mod]) => h.loadLanguage(...mod.default))\n      .then(() => {\n        loaded.add(lang);\n      })\n      .catch((error) => {\n        loading.delete(lang);\n        throw error;\n      });\n    loading.set(lang, p);\n  }\n  return p;\n}\n\n/** Plain lines, for unsupported languages and for the frame before highlighting lands. */\nexport function plainLines(code: string): CodeLine[] {\n  return splitLines(code).map((text) => (text ? [{ content: text }] : []));\n}\n\n/** Split on \\n (and \\r\\n), dropping one trailing newline so a file's last line is not a blank row. */\nexport function splitLines(code: string): string[] {\n  return code.replace(/\\r\\n?/g, \"\\n\").replace(/\\n$/, \"\").split(\"\\n\");\n}\n\n// Results by language + code, so remounts (tab switches, collapsing) never re-tokenise.\nconst CACHE_MAX = 200;\nconst cache = new Map<string, CodeLine[]>();\n\nfunction remember(key: string, lines: CodeLine[]) {\n  if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value!);\n  cache.set(key, lines);\n  return lines;\n}\n\n/**\n * Synchronously return highlighted lines if this exact code has been highlighted before, or\n * if its grammar is already loaded (tokenising is synchronous once the grammar is in).\n * Returns null when highlighting needs an async load first.\n */\nexport function peekHighlight(code: string, lang: string | undefined | null): CodeLine[] | null {\n  const resolved = resolveLang(lang);\n  if (!resolved) return plainLines(code);\n  const key = `${resolved}\\0${code}`;\n  const hit = cache.get(key);\n  if (hit) return hit;\n  if (!loaded.has(resolved) || !ready) return null;\n  return tokenize(ready, key, code, resolved);\n}\n\nfunction tokenize(h: HighlighterCore, key: string, code: string, lang: CodeLang) {\n  const source = code.replace(/\\r\\n?/g, \"\\n\").replace(/\\n$/, \"\");\n  const raw = h.codeToTokensWithThemes(source, { lang: GRAMMARS[lang].id, themes: THEMES });\n  return remember(key, raw.map(mergeLine));\n}\n\n/**\n * Highlight `code` as `lang` into light/dark token lines. Unknown languages come back as\n * plain lines. Works on the server and in the browser.\n */\nexport async function highlight(code: string, lang: string | undefined | null): Promise<CodeLine[]> {\n  const resolved = resolveLang(lang);\n  if (!resolved) return plainLines(code);\n  const key = `${resolved}\\0${code}`;\n  const hit = cache.get(key);\n  if (hit) return hit;\n  await loadLang(resolved);\n  return tokenize(await getHighlighter(), key, code, resolved);\n}\n\n// Shiki's FontStyle bit flags.\nconst ITALIC = 1;\nconst BOLD = 2;\n\n/** Convert Shiki's per-theme variants to Eased tokens and merge neighbours that look identical. */\nfunction mergeLine(line: { content: string; variants: Record<string, { color?: string; fontStyle?: number }> }[]): CodeLine {\n  const out: CodeToken[] = [];\n  for (const t of line) {\n    const l = t.variants.light ?? {};\n    const d = t.variants.dark ?? {};\n    const style = (l.fontStyle ?? 0) > 0 ? l.fontStyle! : 0;\n    const token: CodeToken = { content: t.content };\n    if (l.color) token.light = l.color.toLowerCase();\n    if (d.color) token.dark = d.color.toLowerCase();\n    if (style & ITALIC) token.italic = true;\n    if (style & BOLD) token.bold = true;\n    const prev = out[out.length - 1];\n    // Whitespace takes on any colour, so it joins the previous run.\n    const blank = !t.content.trim();\n    if (prev && (blank || (prev.light === token.light && prev.dark === token.dark && prev.italic === token.italic && prev.bold === token.bold))) {\n      prev.content += t.content;\n    } else out.push(token);\n  }\n  return out;\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/code-core/highlighter.ts"
    },
    {
      "path": "src/registry/eased/_primitives/code-core/themes.ts",
      "content": "import type { ThemeRegistration } from \"shiki/core\";\n\n/**\n * Eased's two syntax themes, in the warm stone/cream palette of `eased.css`.\n *\n * Every hue is low-chroma and sits on the neutral's warm side (clay keywords, sage strings,\n * cream-gold functions, amber numbers, one cool slate for types so the eye can find them).\n * Colours are authored in OKLCH and stored as hex because Shiki needs hex. Each token clears\n * WCAG 4.5:1 on `surface-sunken` in its theme (comments included), so nothing is decoration.\n * The theme backgrounds are only a fallback: code-core paints the block with Eased tokens.\n */\n\ntype Palette = {\n  bg: string;\n  fg: string;\n  comment: string;\n  punctuation: string;\n  keyword: string;\n  string: string;\n  fn: string;\n  number: string;\n  type: string;\n  property: string;\n  attribute: string;\n  parameter: string;\n  inserted: string;\n  deleted: string;\n};\n\n/** eased-stone (dark), on `surface-sunken` (oklch 17.8% 0.006 80). */\nconst stone: Palette = {\n  bg: \"#13110e\",\n  fg: \"#e2ded5\", // oklch(90% .012 85)   14.0:1\n  comment: \"#8a857e\", // oklch(62% .012 80)   5.2:1\n  punctuation: \"#9b9891\", // oklch(68% .01 80)   6.6:1\n  keyword: \"#daa28a\", // oklch(76% .075 45)  8.6:1\n  string: \"#afc798\", // oklch(80% .07 130)  10.3:1\n  fn: \"#ebd19c\", // oklch(87% .075 85)  12.7:1\n  number: \"#e6ad7f\", // oklch(79% .09 60)   9.6:1\n  type: \"#9dc6ce\", // oklch(80% .045 210) 10.3:1\n  property: \"#d4c9b9\", // oklch(84% .025 75)  11.5:1\n  attribute: \"#d9c099\", // oklch(82% .06 80)   10.8:1\n  parameter: \"#e0cdbe\", // oklch(86% .03 60)   12.3:1\n  inserted: \"#89d298\", // oklch(80% .11 150)  10.5:1\n  deleted: \"#ed8c84\", // oklch(74% .12 25)   7.8:1\n};\n\n/** eased-cream (light), on `surface-sunken` (oklch 98% 0.003 80). */\nconst cream: Palette = {\n  bg: \"#f9f8f6\",\n  fg: \"#2a2520\", // oklch(27% .012 70)  14.2:1\n  comment: \"#726e67\", // oklch(54% .012 80)  4.8:1\n  punctuation: \"#66635d\", // oklch(50% .01 80)   5.7:1\n  keyword: \"#9a4729\", // oklch(50% .12 40)   6.0:1\n  string: \"#476d2f\", // oklch(49% .10 135)  5.7:1\n  fn: \"#7b5117\", // oklch(47% .09 70)   6.6:1\n  number: \"#9f5618\", // oklch(53% .12 55)   5.2:1\n  type: \"#246476\", // oklch(47% .07 220)  6.3:1\n  property: \"#524536\", // oklch(40% .03 70)   8.7:1\n  attribute: \"#876125\", // oklch(52% .09 75)   5.3:1\n  parameter: \"#594233\", // oklch(40% .04 55)   8.8:1\n  inserted: \"#067132\", // oklch(48% .13 150)  5.8:1\n  deleted: \"#b02a2d\", // oklch(50% .17 25)   6.2:1\n};\n\nfunction theme(name: string, type: \"light\" | \"dark\", p: Palette): ThemeRegistration {\n  return {\n    name,\n    type,\n    colors: { \"editor.background\": p.bg, \"editor.foreground\": p.fg },\n    fg: p.fg,\n    bg: p.bg,\n    settings: [\n      { settings: { foreground: p.fg, background: p.bg } },\n      { scope: [\"comment\", \"punctuation.definition.comment\", \"string.comment\"], settings: { foreground: p.comment, fontStyle: \"italic\" } },\n      {\n        scope: [\"punctuation\", \"meta.brace\", \"meta.delimiter\", \"punctuation.separator\", \"punctuation.terminator\", \"punctuation.accessor\", \"meta.tag.punctuation\", \"punctuation.definition.tag\"],\n        settings: { foreground: p.punctuation },\n      },\n      {\n        scope: [\"keyword\", \"storage\", \"storage.type\", \"storage.modifier\", \"keyword.operator.new\", \"keyword.operator.expression\", \"keyword.control\", \"variable.language.this\", \"variable.language.super\"],\n        settings: { foreground: p.keyword },\n      },\n      { scope: [\"keyword.operator\", \"keyword.operator.assignment\"], settings: { foreground: p.punctuation } },\n      {\n        scope: [\"string\", \"string.quoted\", \"string.template\", \"punctuation.definition.string\", \"markup.inline.raw\", \"markup.fenced_code\"],\n        settings: { foreground: p.string },\n      },\n      { scope: [\"constant.character.escape\", \"string.regexp\", \"punctuation.definition.template-expression\"], settings: { foreground: p.number } },\n      {\n        scope: [\"entity.name.function\", \"support.function\", \"meta.function-call entity.name.function\", \"variable.function\", \"entity.name.method\"],\n        settings: { foreground: p.fn },\n      },\n      {\n        scope: [\"constant.numeric\", \"constant.language\", \"constant.language.boolean\", \"constant.other\", \"support.constant\", \"variable.other.constant\", \"keyword.other.unit\"],\n        settings: { foreground: p.number },\n      },\n      {\n        scope: [\"entity.name.type\", \"entity.name.class\", \"support.type\", \"support.class\", \"entity.other.inherited-class\", \"storage.type.class.jsdoc\", \"meta.type.annotation entity.name.type\", \"entity.name.namespace\"],\n        settings: { foreground: p.type },\n      },\n      {\n        scope: [\"variable.other.property\", \"variable.other.object.property\", \"meta.object-literal.key\", \"support.variable.property\", \"support.type.property-name\", \"meta.property-name\", \"entity.name.tag.yaml\"],\n        settings: { foreground: p.property },\n      },\n      { scope: [\"support.type.property-name.json\", \"support.type.property-name.json punctuation\"], settings: { foreground: p.keyword } },\n      { scope: [\"variable.parameter\", \"meta.parameters variable\"], settings: { foreground: p.parameter } },\n      { scope: [\"entity.name.tag\", \"support.class.component\", \"entity.name.tag.css\", \"entity.name.tag.html\"], settings: { foreground: p.keyword } },\n      { scope: [\"entity.other.attribute-name\", \"entity.other.attribute-name.class.css\", \"entity.other.attribute-name.id.css\"], settings: { foreground: p.attribute } },\n      { scope: [\"support.type.property-name.css\", \"support.type.vendored.property-name.css\"], settings: { foreground: p.property } },\n      { scope: [\"support.constant.property-value.css\", \"keyword.other.unit.css\", \"constant.other.color\"], settings: { foreground: p.number } },\n      { scope: [\"variable.other.readwrite\", \"variable.other.object\", \"variable\"], settings: { foreground: p.fg } },\n      // Shell: commands read as functions, flags as parameters.\n      { scope: [\"entity.name.command\", \"support.function.builtin.shell\"], settings: { foreground: p.fn } },\n      { scope: [\"variable.parameter.option\", \"constant.other.option\"], settings: { foreground: p.attribute } },\n      // Plain arguments stay in the foreground, so a command reads as command + flags, not a wall of string colour.\n      { scope: [\"string.unquoted.argument\", \"string.unquoted.argument.shell\"], settings: { foreground: p.fg } },\n      // Markdown.\n      { scope: [\"markup.heading\", \"entity.name.section\", \"punctuation.definition.heading\"], settings: { foreground: p.fn, fontStyle: \"bold\" } },\n      { scope: [\"markup.bold\"], settings: { fontStyle: \"bold\" } },\n      { scope: [\"markup.italic\"], settings: { fontStyle: \"italic\" } },\n      { scope: [\"markup.underline.link\", \"string.other.link\"], settings: { foreground: p.type } },\n      { scope: [\"punctuation.definition.list.begin.markdown\", \"beginning.punctuation.definition.list.markdown\"], settings: { foreground: p.keyword } },\n      // Python decorators.\n      { scope: [\"entity.name.function.decorator\", \"meta.decorator\", \"punctuation.definition.decorator\"], settings: { foreground: p.attribute } },\n      // Diff.\n      { scope: [\"markup.inserted\", \"punctuation.definition.inserted\"], settings: { foreground: p.inserted } },\n      { scope: [\"markup.deleted\", \"punctuation.definition.deleted\"], settings: { foreground: p.deleted } },\n      { scope: [\"meta.diff.range\", \"meta.diff.header\", \"meta.diff.index\", \"punctuation.definition.range.diff\"], settings: { foreground: p.type } },\n    ],\n  };\n}\n\nexport const easedStone = theme(\"eased-stone\", \"dark\", stone);\nexport const easedCream = theme(\"eased-cream\", \"light\", cream);\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/code-core/themes.ts"
    },
    {
      "path": "src/registry/eased/_primitives/code-core/lines.ts",
      "content": "/**\n * The line decoration model: which lines of a block are highlighted, focused, added or\n * removed. Every Eased code component reads it, so `highlight=\"3-5\"` means the same thing\n * in Code Block, Code Tabs, Streaming Code and Diff Block.\n */\n\n/**\n * 1-based line numbers. A string of numbers and inclusive ranges (\"3-5, 9, 12-14\"), a single\n * number, or an array of numbers and `[from, to]` pairs.\n */\nexport type LineRange = string | number | ReadonlyArray<number | readonly [number, number]>;\n\n/** What a line is marked as. A line can be both highlighted and added; `dim` is derived from `focus`. */\nexport type LineMark = {\n  highlight: boolean;\n  /** In the focused set: when any line is focused, the rest dim until the block is hovered. */\n  focus: boolean;\n  /** Dimmed because other lines are focused. */\n  dim: boolean;\n  diff: \"add\" | \"remove\" | null;\n};\n\nexport type LineDecorations = {\n  highlight?: LineRange;\n  focus?: LineRange;\n  added?: LineRange;\n  removed?: LineRange;\n};\n\n/** Parse a `LineRange` into a set of 1-based line numbers, clamped to `1…total` when given. */\nexport function parseLineRange(range: LineRange | undefined, total = Infinity): Set<number> {\n  const out = new Set<number>();\n  if (range === undefined || range === \"\") return out;\n  const add = (from: number, to: number) => {\n    const lo = Math.max(1, Math.min(from, to));\n    const hi = Math.min(total, Math.max(from, to));\n    for (let n = lo; n <= hi; n++) out.add(n);\n  };\n  if (typeof range === \"number\") add(range, range);\n  else if (typeof range === \"string\") {\n    for (const part of range.split(/[\\s,]+/)) {\n      const m = /^(\\d+)(?:\\s*[-–]\\s*(\\d+))?$/.exec(part.trim());\n      if (m) add(Number(m[1]), Number(m[2] ?? m[1]));\n    }\n  } else {\n    for (const item of range) {\n      if (typeof item === \"number\") add(item, item);\n      else add(item[0], item[1]);\n    }\n  }\n  return out;\n}\n\n/** One `LineMark` per line, index 0 = line 1. */\nexport function decorateLines(total: number, { highlight, focus, added, removed }: LineDecorations): LineMark[] {\n  const h = parseLineRange(highlight, total);\n  const f = parseLineRange(focus, total);\n  const a = parseLineRange(added, total);\n  const r = parseLineRange(removed, total);\n  const focusing = f.size > 0;\n  return Array.from({ length: total }, (_, i) => {\n    const n = i + 1;\n    return {\n      highlight: h.has(n),\n      focus: f.has(n),\n      dim: focusing && !f.has(n),\n      diff: a.has(n) ? \"add\" : r.has(n) ? \"remove\" : null,\n    };\n  });\n}\n\n/**\n * The cross-lane diff contract (lane 3 → lane 2: Diff Block, Approval Card, Diff Morph).\n * Shaped like one hunk of jsdiff's `structuredPatch`, but with each line's kind made explicit\n * instead of encoded in a \"+\"/\"-\"/\" \" prefix. Line numbers are 1-based.\n */\nexport type DiffLine = { kind: \"add\" | \"remove\" | \"context\"; text: string };\nexport type DiffHunk = {\n  oldStart: number;\n  oldLines: number;\n  newStart: number;\n  newLines: number;\n  lines: DiffLine[];\n};\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/code-core/lines.ts"
    },
    {
      "path": "src/registry/eased/_primitives/code-core/choice.ts",
      "content": "\"use client\";\n\nimport { useCallback, useSyncExternalStore } from \"react\";\n\n/**\n * A page-wide, persisted choice keyed by group: pick \"npm\" in one Code Tabs and every block\n * with the same `groupId` switches with it, in this tab, in other tabs (the `storage` event)\n * and on the next visit (localStorage, best effort). The server and the hydrating render see\n * no stored value, so the first paint is always the default and nothing mismatches.\n */\n\nconst values = new Map<string, string>();\nconst listeners = new Map<string, Set<() => void>>();\nconst PREFIX = \"eased-code:\";\n\nfunction read(group: string): string | undefined {\n  if (values.has(group)) return values.get(group);\n  try {\n    const stored = window.localStorage.getItem(PREFIX + group);\n    if (stored !== null) values.set(group, stored);\n    return stored ?? undefined;\n  } catch {\n    return undefined;\n  }\n}\n\nfunction emit(group: string) {\n  listeners.get(group)?.forEach((fn) => fn());\n}\n\n/** Set a group's choice everywhere on the page and remember it. */\nexport function setChoice(group: string, value: string) {\n  values.set(group, value);\n  try {\n    window.localStorage.setItem(PREFIX + group, value);\n  } catch {\n    // Storage blocked: the choice still syncs on this page.\n  }\n  emit(group);\n}\n\nlet storageBound = false;\nfunction bindStorage() {\n  if (storageBound || typeof window === \"undefined\") return;\n  storageBound = true;\n  window.addEventListener(\"storage\", (e) => {\n    if (!e.key?.startsWith(PREFIX) || e.newValue === null) return;\n    const group = e.key.slice(PREFIX.length);\n    values.set(group, e.newValue);\n    emit(group);\n  });\n}\n\n/**\n * `[value, setValue]` for a group. Without a `group` it is plain local state. `options`\n * guards against a stored value this block does not offer: it falls back to `fallback`.\n */\nexport function useChoice(group: string | undefined, fallback: string, options?: readonly string[]) {\n  const subscribe = useCallback(\n    (fn: () => void) => {\n      if (!group) return () => {};\n      bindStorage();\n      let set = listeners.get(group);\n      if (!set) listeners.set(group, (set = new Set()));\n      set.add(fn);\n      return () => set.delete(fn);\n    },\n    [group],\n  );\n  const stored = useSyncExternalStore(\n    subscribe,\n    () => (group ? read(group) : undefined),\n    () => undefined,\n  );\n  const value = stored !== undefined && (!options || options.includes(stored)) ? stored : fallback;\n  const setValue = useCallback((next: string) => group && setChoice(group, next), [group]);\n  return [value, setValue] as const;\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/code-core/choice.ts"
    },
    {
      "path": "src/registry/eased/_primitives/code-core/copy.tsx",
      "content": "// The copy → check morph lives in its own primitive (eased-copy), so blocks that only copy text\n// don't pull in Shiki. Re-exported here for the code components.\nexport * from \"../copy/copy\";\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/code-core/copy.tsx"
    },
    {
      "path": "src/registry/eased/_primitives/code-core/code-lines.tsx",
      "content": "\"use client\";\n\nimport { animate, motion, useMotionValue } from \"motion/react\";\nimport { useEffect, useId, useRef, useState, useSyncExternalStore, type ReactNode } from \"react\";\nimport { AutoHeight } from \"../auto-height/auto-height\";\nimport { TextSwap } from \"../text-swap/text-swap\";\nimport { ProgressiveBlur } from \"../../progressive-blur/progressive-blur\";\nimport { exit, exitFast, reducedFade, snappy, useSafeReducedMotion } from \"../../lib/motion\";\nimport { press } from \"../../lib/press\";\nimport { highlight, peekHighlight, plainLines, type CodeLine } from \"./highlighter\";\nimport { decorateLines, type LineDecorations } from \"./lines\";\n\nconst noop = () => () => {};\n/** False during SSR and hydration, true afterwards: lets remounts read caches the server never saw. */\nexport function useHydrated() {\n  return useSyncExternalStore(noop, () => true, () => false);\n}\n\n/**\n * Highlighted lines for `code`. Precomputed `tokens` (from `highlight()` on the server) render\n * at once with no flash. Otherwise the first paint is plain text in the foreground colour\n * (identical metrics, so nothing shifts) and the colours land when the grammar has loaded;\n * after hydration, code whose grammar is already in tokenises synchronously, so switching\n * tabs or remounting never flashes.\n */\nexport function useCodeLines(code: string, lang: string | undefined, tokens?: CodeLine[]) {\n  const hydrated = useHydrated();\n  const key = `${lang ?? \"\"}\\0${code}`;\n  const sync = tokens ?? (hydrated ? peekHighlight(code, lang) : null);\n  const [result, setResult] = useState<{ key: string; lines: CodeLine[] } | null>(null);\n  const needsAsync = !sync;\n\n  useEffect(() => {\n    if (!needsAsync) return;\n    let live = true;\n    highlight(code, lang)\n      .then((lines) => live && setResult({ key, lines }))\n      .catch(() => {\n        // A grammar failed to load: the plain text stays, which is still correct code.\n      });\n    return () => {\n      live = false;\n    };\n  }, [key, code, lang, needsAsync]);\n\n  const lines = sync ?? (result?.key === key ? result.lines : null);\n  return { lines: lines ?? plainLines(code), highlighted: lines !== null };\n}\n\n/** `light-dark()` picks the token colour from the inherited `color-scheme`, so a theme flip never re-renders. */\nfunction tokenColor(light?: string, dark?: string) {\n  if (light && dark) return light === dark ? light : `light-dark(${light}, ${dark})`;\n  return light ?? dark;\n}\n\n/**\n * The colour scheme follows Eased's dark convention (`.dark` or `data-theme=\"dark\"` on an\n * ancestor), so `light-dark()` resolves with the rest of the tokens even in hosts that never\n * set `color-scheme` themselves.\n */\nexport const CODE_SCHEME = \"scheme-light [.dark_&]:scheme-dark [[data-theme=dark]_&]:scheme-dark\";\n\nexport type CodeLinesProps = LineDecorations & {\n  lines: CodeLine[];\n  /** Show a line-number gutter. */\n  lineNumbers?: boolean;\n  /** Soft-wrap long lines instead of scrolling sideways. Default false. */\n  wrap?: boolean;\n  /** Number shown on the first line. Default 1. */\n  startLine?: number;\n  /** Accessible name for the scroll region, e.g. the file name. */\n  label?: string;\n  className?: string;\n};\n\n/**\n * Renders token lines as a `<pre>`: one full-bleed row per line (highlights span the whole\n * scroll width), an optional gutter (`tabular-nums slashed-zero`, never selected or copied),\n * and a diff marker column when any line is added or removed. Focused lines dim the others\n * to 40% with a half-pixel blur until the pointer is over the block. The scroller is a\n * keyboard-focusable region when it overflows sideways.\n *\n * Line height is fixed at 20px (a deliberate exception to rule 41): collapse heights and\n * gutters are computed from it.\n */\nexport function CodeLines({ lines, lineNumbers = false, wrap = false, startLine = 1, label, highlight: hl, focus, added, removed, className = \"\" }: CodeLinesProps) {\n  const marks = decorateLines(lines.length, { highlight: hl, focus, added, removed });\n  const hasDiff = marks.some((m) => m.diff);\n  const digits = String(startLine + lines.length - 1).length;\n  const scroller = useRef<HTMLPreElement>(null);\n  const [overflows, setOverflows] = useState(false);\n\n  useEffect(() => {\n    const el = scroller.current;\n    if (!el || wrap) return;\n    const check = () => setOverflows(el.scrollWidth > el.clientWidth + 1);\n    check();\n    const ro = new ResizeObserver(check);\n    ro.observe(el);\n    if (el.firstElementChild) ro.observe(el.firstElementChild);\n    return () => ro.disconnect();\n  }, [wrap, lines]);\n\n  return (\n    <pre\n      ref={scroller}\n      tabIndex={overflows ? 0 : undefined}\n      role={overflows ? \"region\" : undefined}\n      aria-label={overflows ? label ?? \"Code\" : undefined}\n      className={`group m-0 font-mono text-ui-sm leading-5 text-text slashed-zero ${CODE_SCHEME} ${\n        wrap ? \"whitespace-pre-wrap break-words\" : \"overflow-x-auto whitespace-pre [scrollbar-width:thin]\"\n      } py-3 outline-none focus-visible:inset-ring-2 focus-visible:inset-ring-focus ${className}`}\n    >\n      <code className={`${wrap ? \"block\" : \"inline-block min-w-full\"} [font-variant-ligatures:none]`}>\n        {lines.map((line, i) => {\n          const m = marks[i];\n          const n = startLine + i;\n          return (\n            <span\n              key={i}\n              data-line={n}\n              data-highlighted={m.highlight || undefined}\n              data-diff={m.diff ?? undefined}\n              className={`relative flex min-h-5 pr-4 pl-4 transition-[background-color,opacity,filter] duration-200 ease-standard before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:transition-colors before:duration-200 ${\n                m.diff === \"add\"\n                  ? \"bg-success-subtle before:bg-success\"\n                  : m.diff === \"remove\"\n                    ? \"bg-danger-subtle before:bg-danger\"\n                    : m.highlight\n                      ? \"bg-brand-subtle before:bg-line-strong\"\n                      : \"before:bg-transparent\"\n              } ${m.dim ? \"opacity-40 blur-[0.5px] group-hover-fine:opacity-100 group-hover-fine:blur-none group-focus-visible:opacity-100 group-focus-visible:blur-none reduced:blur-none\" : \"\"}`}\n            >\n              {lineNumbers && (\n                <span\n                  aria-hidden\n                  className={`mr-4 shrink-0 text-right tabular-nums select-none ${m.highlight || m.diff ? \"text-text-muted\" : \"text-text-disabled\"}`}\n                  style={{ minWidth: `${digits}ch` }}\n                >\n                  {n}\n                </span>\n              )}\n              {hasDiff && (\n                <span\n                  aria-hidden\n                  className={`mr-3 w-[1ch] shrink-0 select-none ${m.diff === \"add\" ? \"text-success-text\" : m.diff === \"remove\" ? \"text-danger-text\" : \"\"}`}\n                >\n                  {m.diff === \"add\" ? \"+\" : m.diff === \"remove\" ? \"−\" : \" \"}\n                </span>\n              )}\n              <span className=\"min-w-0\">\n                {line.map((t, j) => (\n                  <span\n                    key={j}\n                    style={{\n                      color: tokenColor(t.light, t.dark),\n                      fontStyle: t.italic ? \"italic\" : undefined,\n                      fontWeight: t.bold ? 500 : undefined,\n                    }}\n                  >\n                    {t.content}\n                  </span>\n                ))}\n              </span>\n              {m.diff && <span className=\"sr-only\">{m.diff === \"add\" ? \" (added)\" : \" (removed)\"}</span>}\n            </span>\n          );\n        })}\n      </code>\n    </pre>\n  );\n}\n\n/** The top of the nearest scrolling ancestor's visible area (0 for the page). */\nfunction scrollportTop(el: HTMLElement) {\n  for (let p = el.parentElement; p; p = p.parentElement) {\n    const { overflowY } = getComputedStyle(p);\n    if ((overflowY === \"auto\" || overflowY === \"scroll\") && p.scrollHeight > p.clientHeight) return Math.max(0, p.getBoundingClientRect().top);\n  }\n  return 0;\n}\n\n/** Lines are 20px; the pre adds 12px of padding at the top. */\nconst LINE = 20;\nconst PAD_TOP = 12;\n\nexport type CodeCollapseProps = {\n  /** Total number of lines in the block. */\n  lines: number;\n  /** Lines to show while collapsed. The block only collapses when at least 3 more would hide; 0 disables. */\n  collapseAfter: number;\n  /** Start expanded. Default false. */\n  defaultExpanded?: boolean;\n  /** The code (a `CodeLines`). */\n  children: ReactNode;\n  /** Classes for the blur's tint gradient, matching the code's background. Default the sunken surface. */\n  tintClassName?: string;\n};\n\n/**\n * Collapse-after: a long block shows its first `collapseAfter` lines and the next half line\n * melts under a progressive blur, with \"Show 42 more lines\" floating on it. Expanding springs\n * the height open (Auto Height, no overshoot) while the blur and tint fade away; the button\n * stays at the foot of the block and its label swaps to \"Show less\", its chevron turning on\n * the snappy spring. Collapsing a block whose top is off-screen scrolls it back into view.\n * Reduced motion: the height snaps and the blur fades at 100ms.\n */\n/** Above this many pixels of hidden code, expanding skips the height spring (see CodeCollapse). */\nconst MAX_SPRING_DISTANCE = 480;\n\nexport function CodeCollapse({ lines, collapseAfter, defaultExpanded = false, children, tintClassName = \"from-surface-sunken via-surface-sunken/70\" }: CodeCollapseProps) {\n  const collapsible = collapseAfter > 0 && lines - collapseAfter >= 3;\n  const [expanded, setExpanded] = useState(defaultExpanded);\n  const reduced = useSafeReducedMotion();\n  const strength = useMotionValue(defaultExpanded ? 0 : 1);\n  const root = useRef<HTMLDivElement>(null);\n  const id = useId();\n\n  useEffect(() => {\n    const c = animate(strength, expanded ? 0 : 1, reduced ? reducedFade : expanded ? exitFast : exit);\n    return () => c.stop();\n  }, [expanded, reduced, strength]);\n\n  if (!collapsible) return <>{children}</>;\n  const hidden = lines - collapseAfter;\n  // Show the cut-off line half-way so the blur has real code to melt.\n  const collapsedHeight = PAD_TOP + (collapseAfter + 0.5) * LINE;\n\n  const toggle = () => {\n    const next = !expanded;\n    setExpanded(next);\n    if (!next) {\n      const el = root.current;\n      if (el && el.getBoundingClientRect().top < scrollportTop(el)) el.scrollIntoView({ block: \"nearest\", behavior: reduced ? \"auto\" : \"smooth\" });\n    }\n  };\n\n  // Springing the height across hundreds of lines reads as the page lurching, so long files\n  // open in place and only the blur fades; short ones keep the height spring.\n  const springHeight = hidden * LINE <= MAX_SPRING_DISTANCE;\n  const body = (\n    <div id={id} className=\"overflow-y-clip\" style={{ maxHeight: expanded ? undefined : collapsedHeight }}>\n      {children}\n      {/* Room for the button under the last line once expanded. */}\n      {expanded && <div aria-hidden className=\"h-11\" />}\n    </div>\n  );\n\n  return (\n    <div ref={root} className=\"relative scroll-mt-4\">\n      {springHeight ? <AutoHeight fade={false}>{body}</AutoHeight> : body}\n      <motion.div aria-hidden className={`pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-linear-to-t to-transparent ${tintClassName}`} style={{ opacity: strength }} />\n      <ProgressiveBlur side=\"bottom\" size={64} maxBlur={6} layers={4} tint={false} strength={strength} />\n      <div className=\"pointer-events-none absolute inset-x-0 bottom-3 flex justify-center\">\n        <button\n          type=\"button\"\n          aria-expanded={expanded}\n          aria-controls={id}\n          onClick={toggle}\n          className={`pointer-events-auto inline-flex h-control-sm cursor-pointer items-center gap-1.5 rounded-control-sm border border-line bg-surface-raised px-3 text-ui-sm font-medium text-text-secondary shadow-resting select-none hover-fine:bg-surface-hover hover-fine:text-text active:bg-surface-active ${press}`}\n        >\n          <TextSwap swapKey={expanded ? \"less\" : \"more\"} direction={expanded ? -1 : 1}>\n            {expanded ? \"Show less\" : `Show ${hidden} more ${hidden === 1 ? \"line\" : \"lines\"}`}\n          </TextSwap>\n          <motion.svg\n            viewBox=\"0 0 16 16\"\n            fill=\"none\"\n            aria-hidden\n            className=\"size-4 shrink-0 text-text-muted\"\n            initial={false}\n            animate={{ rotate: expanded ? 180 : 0 }}\n            transition={reduced ? { duration: 0 } : snappy}\n          >\n            <path d=\"M4.5 6.5 8 10l3.5-3.5\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n          </motion.svg>\n        </button>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/code-core/code-lines.tsx"
    },
    {
      "path": "src/registry/eased/_primitives/code-core/code-chrome.tsx",
      "content": "\"use client\";\n\nimport { TextSwap } from \"../text-swap/text-swap\";\nimport { LANG_LABEL, resolveLang, type CodeLang } from \"./highlighter\";\n\n/** The shell every Eased code surface shares: 16px panel, subtle edge, sunken code well. */\nexport const CODE_FRAME =\n  \"group @container relative min-w-0 overflow-hidden rounded-panel border border-line-subtle bg-surface-sunken text-ui text-text [--pad:0px] [--radius-outer:var(--eased-radius-panel)]\";\n\n/**\n * The 40px title bar (41 with its rule, the same as a row of underline tabs). Its left inset\n * matches the code's 16px so the file name sits on the code's left edge; the right inset is\n * 8px so a 32px copy button's icon lands on the code's right edge (rules 104, 105).\n */\nexport const CODE_HEADER = \"box-content flex h-10 min-w-0 items-center gap-2 border-b border-line-subtle bg-surface pr-2 pl-4\";\n\n/** A 16px glyph for a language: a prompt for shells, a sheet for everything else. */\nexport function LangGlyph({ lang, className = \"\" }: { lang?: string | null; className?: string }) {\n  const shell = resolveLang(lang) === \"bash\";\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className={`size-4 shrink-0 text-text-muted ${className}`}>\n      {shell ? (\n        <>\n          <rect x=\"1.75\" y=\"2.75\" width=\"12.5\" height=\"10.5\" rx=\"2.25\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n          <path d=\"m4.75 6.25 1.75 1.75-1.75 1.75M8.5 9.75h2.75\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n        </>\n      ) : (\n        <>\n          <path d=\"M9.25 1.75H5a2.25 2.25 0 0 0-2.25 2.25v8A2.25 2.25 0 0 0 5 14.25h6A2.25 2.25 0 0 0 13.25 12V5.75l-4-4Z\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinejoin=\"round\" />\n          <path d=\"M9.25 1.75v2.5c0 .83.67 1.5 1.5 1.5h2.5\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinejoin=\"round\" />\n        </>\n      )}\n    </svg>\n  );\n}\n\n/**\n * The language pill. The label swaps with y + blur and the pill's width springs, so switching\n * between files of different languages reads as one pill changing, not a new one. Under\n * 320px of block width it steps aside so the file name keeps the room.\n */\nexport function LangPill({ lang }: { lang?: string | null }) {\n  const resolved = resolveLang(lang);\n  const label = resolved ? LANG_LABEL[resolved as CodeLang] : lang;\n  if (!label) return null;\n  return (\n    <span className=\"inline-flex h-5 shrink-0 items-center @max-xs:hidden rounded-control-xs bg-surface-hover px-1.5 text-ui-xs text-text-muted select-none\">\n      <TextSwap>{label}</TextSwap>\n    </span>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/code-core/code-chrome.tsx"
    }
  ],
  "type": "registry:ui"
}