@usetheo/ui 0.1.0-next.0 → 0.3.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +116 -9
  3. package/dist/index.d.ts +242 -16
  4. package/dist/index.js +436 -131
  5. package/dist/index.js.map +1 -1
  6. package/dist/plugin-Atb0VKtr.d.ts +172 -0
  7. package/dist/slide/index.d.ts +212 -0
  8. package/dist/slide/index.js +714 -0
  9. package/dist/slide/index.js.map +1 -0
  10. package/dist/slide/plugins/emoji/index.d.ts +29 -0
  11. package/dist/slide/plugins/emoji/index.js +157 -0
  12. package/dist/slide/plugins/emoji/index.js.map +1 -0
  13. package/dist/slide/plugins/math/index.d.ts +13 -0
  14. package/dist/slide/plugins/math/index.js +145 -0
  15. package/dist/slide/plugins/math/index.js.map +1 -0
  16. package/dist/slide/plugins/mermaid/index.d.ts +55 -0
  17. package/dist/slide/plugins/mermaid/index.js +218 -0
  18. package/dist/slide/plugins/mermaid/index.js.map +1 -0
  19. package/dist/slide/plugins/shiki/index.d.ts +18 -0
  20. package/dist/slide/plugins/shiki/index.js +87 -0
  21. package/dist/slide/plugins/shiki/index.js.map +1 -0
  22. package/dist/slide/themes/default.css +256 -0
  23. package/dist/slide/themes/layouts.css +143 -0
  24. package/dist/slide/themes/violet-forge.css +256 -0
  25. package/dist/slide-deck/index.css +52 -0
  26. package/dist/slide-deck/index.css.map +1 -0
  27. package/dist/slide-deck/index.d.ts +377 -0
  28. package/dist/slide-deck/index.js +1797 -0
  29. package/dist/slide-deck/index.js.map +1 -0
  30. package/dist/whiteboard/index.d.ts +258 -0
  31. package/dist/whiteboard/index.js +738 -0
  32. package/dist/whiteboard/index.js.map +1 -0
  33. package/package.json +126 -6
  34. package/registry/index.json +42 -0
  35. package/registry/r/agent-composer.json +4 -4
  36. package/registry/r/agent-editor.json +9 -9
  37. package/registry/r/agent-error-card.json +2 -2
  38. package/registry/r/agent-event.json +4 -4
  39. package/registry/r/agent-handoff.json +2 -2
  40. package/registry/r/agent-profile.json +2 -2
  41. package/registry/r/agent-starting-state.json +2 -2
  42. package/registry/r/agent-stream.json +9 -9
  43. package/registry/r/agent-streaming.json +2 -2
  44. package/registry/r/agent-timeline.json +4 -4
  45. package/registry/r/approval-card.json +4 -4
  46. package/registry/r/artifact-preview.json +2 -2
  47. package/registry/r/attachment-chip.json +4 -4
  48. package/registry/r/audit-log-entry.json +3 -3
  49. package/registry/r/auto-compact-notice.json +2 -2
  50. package/registry/r/avatar.json +2 -2
  51. package/registry/r/badge.json +3 -3
  52. package/registry/r/browser-controls.json +2 -2
  53. package/registry/r/build-log-stream.json +2 -2
  54. package/registry/r/button.json +3 -3
  55. package/registry/r/capability-indicator.json +3 -3
  56. package/registry/r/card.json +3 -3
  57. package/registry/r/chat-composer.json +3 -3
  58. package/registry/r/chat-message.json +3 -3
  59. package/registry/r/chat-thread.json +2 -2
  60. package/registry/r/checkbox.json +4 -3
  61. package/registry/r/cn.json +1 -1
  62. package/registry/r/command-palette.json +4 -4
  63. package/registry/r/context-card.json +3 -3
  64. package/registry/r/context-window-bar.json +2 -2
  65. package/registry/r/cost-meter.json +2 -2
  66. package/registry/r/created-files-card.json +3 -3
  67. package/registry/r/cron-job-card.json +2 -2
  68. package/registry/r/cron-jobs-list.json +3 -3
  69. package/registry/r/deployment-row.json +3 -3
  70. package/registry/r/dialog.json +2 -2
  71. package/registry/r/diff-viewer.json +2 -2
  72. package/registry/r/domain-config.json +6 -6
  73. package/registry/r/empty-state.json +3 -3
  74. package/registry/r/env-var-editor.json +5 -5
  75. package/registry/r/folder-context-card.json +3 -3
  76. package/registry/r/folder-selector.json +2 -2
  77. package/registry/r/form-field.json +3 -3
  78. package/registry/r/hook-config.json +2 -2
  79. package/registry/r/hook-event-log.json +2 -2
  80. package/registry/r/input.json +6 -3
  81. package/registry/r/intent-selector.json +3 -3
  82. package/registry/r/label.json +2 -2
  83. package/registry/r/lane-board.json +2 -2
  84. package/registry/r/login-split.json +2 -2
  85. package/registry/r/mcp-server-card.json +2 -2
  86. package/registry/r/mcp-server-list.json +3 -3
  87. package/registry/r/memory-editor.json +3 -3
  88. package/registry/r/mention-menu.json +3 -3
  89. package/registry/r/metrics-panel.json +2 -2
  90. package/registry/r/model-card.json +3 -3
  91. package/registry/r/model-selector.json +2 -2
  92. package/registry/r/permission-matrix.json +2 -2
  93. package/registry/r/permission-modal.json +4 -4
  94. package/registry/r/preview-env-card.json +5 -5
  95. package/registry/r/preview-panel.json +3 -3
  96. package/registry/r/progress-checklist.json +3 -3
  97. package/registry/r/project-card.json +5 -5
  98. package/registry/r/project-switcher.json +2 -2
  99. package/registry/r/quick-action-chips.json +3 -3
  100. package/registry/r/radio-group.json +2 -2
  101. package/registry/r/recent-folders-list.json +2 -2
  102. package/registry/r/rollback-ui.json +4 -4
  103. package/registry/r/rule-card.json +3 -3
  104. package/registry/r/rule-editor.json +10 -10
  105. package/registry/r/rule-types.json +1 -1
  106. package/registry/r/run-stats.json +2 -2
  107. package/registry/r/running-tasks-panel.json +2 -2
  108. package/registry/r/scroll-area.json +2 -2
  109. package/registry/r/select.json +4 -3
  110. package/registry/r/session-list-item.json +2 -2
  111. package/registry/r/session-timeline.json +2 -2
  112. package/registry/r/sheet.json +2 -2
  113. package/registry/r/sidebar.json +2 -2
  114. package/registry/r/skeleton.json +2 -2
  115. package/registry/r/skill-card.json +4 -4
  116. package/registry/r/skill-editor.json +10 -10
  117. package/registry/r/skills-list.json +3 -3
  118. package/registry/r/slide-deck.json +130 -0
  119. package/registry/r/slide-plugin-emoji.json +28 -0
  120. package/registry/r/slide-plugin-math.json +24 -0
  121. package/registry/r/slide-plugin-mermaid.json +23 -0
  122. package/registry/r/slide-plugin-shiki.json +23 -0
  123. package/registry/r/slide.json +123 -0
  124. package/registry/r/social-auth-row.json +3 -3
  125. package/registry/r/steps-rail.json +2 -2
  126. package/registry/r/sub-agent-dispatch.json +2 -2
  127. package/registry/r/switch.json +5 -4
  128. package/registry/r/system-prompt-editor.json +2 -2
  129. package/registry/r/tabs.json +2 -2
  130. package/registry/r/tailwind-preset.json +1 -1
  131. package/registry/r/task-header.json +4 -4
  132. package/registry/r/task-plan.json +2 -2
  133. package/registry/r/terminal-panel.json +2 -2
  134. package/registry/r/textarea.json +6 -3
  135. package/registry/r/theme-provider.json +4 -4
  136. package/registry/r/theme-script.json +1 -1
  137. package/registry/r/theo-ui-provider.json +2 -2
  138. package/registry/r/toast.json +3 -3
  139. package/registry/r/token-usage-chart.json +2 -2
  140. package/registry/r/tool-call-card.json +3 -3
  141. package/registry/r/tool-call.json +2 -2
  142. package/registry/r/tool-result.json +2 -2
  143. package/registry/r/tools-list.json +3 -3
  144. package/registry/r/tooltip.json +2 -2
  145. package/registry/r/topnav.json +2 -2
  146. package/registry/r/whiteboard.json +101 -0
@@ -9,8 +9,8 @@
9
9
  "lucide-react"
10
10
  ],
11
11
  "registryDependencies": [
12
- "cn",
13
- "tokens"
12
+ "https://usetheodev.github.io/theo-ui/r/cn.json",
13
+ "https://usetheodev.github.io/theo-ui/r/tokens.json"
14
14
  ],
15
15
  "files": [
16
16
  {
@@ -41,7 +41,7 @@
41
41
  "path": "themes/theme-provider.tsx",
42
42
  "type": "registry:lib",
43
43
  "target": "themes/theme-provider.tsx",
44
- "content": "import { createContext, useCallback, useContext, useEffect, useMemo, useState } from \"react\";\nimport type { JSX, ReactNode } from \"react\";\nimport { safeHref } from \"@/lib/safe-href\";\nimport type { ColorScale, Theme, ThemeMode } from \"@/themes/types\";\n\ninterface ThemeContextValue {\n /** Active theme (full descriptor). */\n theme: Theme;\n /** Active mode: light or dark. */\n mode: ThemeMode;\n /** All available themes. */\n themes: Theme[];\n /** Swap the active theme by name. */\n setTheme: (name: string) => void;\n /** Set light/dark explicitly. */\n setMode: (mode: ThemeMode) => void;\n /** Toggle light <> dark. */\n toggleMode: () => void;\n /** Register an additional theme at runtime. */\n registerTheme: (theme: Theme) => void;\n}\n\nconst ThemeContext = createContext<ThemeContextValue | undefined>(undefined);\n\nconst STYLE_ELEMENT_ID = \"theo-ui-theme-vars\";\n\n// T3.2 (SEC-001): allowlist validators for theme values. injectThemeCss\n// interpolates theme name + color values + font families into a <style>\n// textContent. Without validation, a theme object from an untrusted source\n// (e.g., a feature-flag service, a CMS) could inject arbitrary CSS via\n// closing the declaration with `}` or smuggling `url(...)` for exfiltration.\n// We reject rather than escape: themes are code, not user input. Invalid\n// values cause a dev-time throw (caller sees the problem); production\n// silently substitutes a safe fallback so a misconfigured theme can't\n// crash the app.\n\n// Color values. Multiple accepted shapes:\n// 1. Hex: `#fff`, `#0a0a0a`, `#0a0a0aff`.\n// 2. Fully-parenthesized CSS color functions: `oklch(...)`, `rgb(...)`,\n// `hsl(...)`, etc. Inner content restricted to digits/dots/spaces/\n// percent/slash/comma/dash/plus — no semicolons, no braces, no `url(`.\n// 3. HSL-component split (shadcn-ui convention used by the built-in\n// themes): `\"0 0% 100%\"`, `\"262 83% 58%\"` — space-separated numeric\n// components consumed via `hsl(var(--token))` in stylesheets.\n// 4. `var(--token)` references, optionally with a fallback value that\n// contains no parens/braces/semicolons.\n// 5. CSS keywords: `transparent`, `currentColor`, `inherit`, `initial`,\n// `unset`.\nconst COLOR_VALUE_PATTERN =\n /^(#[0-9a-fA-F]{3,8}|(?:oklch|oklab|rgb|rgba|hsl|hsla|lab|lch|color)\\(\\s*[\\d.\\s%,/+\\-]+\\s*\\)|-?\\d+(?:\\.\\d+)?%?(?:\\s+-?\\d+(?:\\.\\d+)?%?){1,3}|var\\(--[a-zA-Z0-9-]+(?:\\s*,\\s*[^();{}]+)?\\)|transparent|currentColor|inherit|initial|unset)$/;\n\n// Font family: word chars, spaces, commas, hyphens, dots, quotes. Excludes\n// parens (blocks `url(...)`) and semicolons (blocks declaration breakouts).\nconst FONT_FAMILY_PATTERN = /^[\\w\\s,\"'\\-.]+$/;\n\n// Theme name: kebab-case identifier. Excludes anything that could break out\n// of an attribute selector or inject additional rules.\nconst THEME_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;\n\nconst IS_DEV = typeof process === \"undefined\" || process.env.NODE_ENV !== \"production\";\n\nfunction rejectOrFallback(scope: string, value: string, fallback: string): string {\n if (IS_DEV) {\n throw new Error(\n `[@usetheo/ui] invalid ${scope} value: ${JSON.stringify(value)}. Theme values must match the allowlist (see src/themes/theme-provider.tsx). Refusing to inject potentially unsafe CSS.`,\n );\n }\n return fallback;\n}\n\nfunction validatedColor(token: string, value: string): string {\n if (COLOR_VALUE_PATTERN.test(value)) return value;\n return rejectOrFallback(`color \"${token}\"`, value, \"transparent\");\n}\n\nfunction validatedFontFamily(slot: string, value: string): string {\n if (FONT_FAMILY_PATTERN.test(value)) return value;\n return rejectOrFallback(`fontFamily \"${slot}\"`, value, \"inherit\");\n}\n\nfunction validatedThemeName(value: string): string {\n if (THEME_NAME_PATTERN.test(value)) return value;\n return rejectOrFallback(\"theme.name\", value, \"invalid-theme\");\n}\n\nfunction colorScaleToCss(name: string, mode: ThemeMode, colors: ColorScale): string {\n const safeName = validatedThemeName(name);\n const selector =\n mode === \"light\"\n ? `[data-theme=\"${safeName}\"]`\n : `[data-theme=\"${safeName}\"].dark, [data-theme=\"${safeName}\"][data-mode=\"dark\"]`;\n const decls = Object.entries(colors)\n .map(([token, value]) => ` --${token}: ${validatedColor(token, value)};`)\n .join(\"\\n\");\n return `${selector} {\\n${decls}\\n}`;\n}\n\nfunction fontsToCss(name: string, fonts: Theme[\"fonts\"]): string {\n const safeName = validatedThemeName(name);\n const display = validatedFontFamily(\"display\", fonts.display);\n const body = validatedFontFamily(\"body\", fonts.body);\n const mono = validatedFontFamily(\"mono\", fonts.mono);\n return `[data-theme=\"${safeName}\"] {\\n --font-display: ${display};\\n --font-body: ${body};\\n --font-mono: ${mono};\\n}`;\n}\n\nfunction injectThemeCss(themes: Theme[]): void {\n if (typeof document === \"undefined\") return;\n let style = document.getElementById(STYLE_ELEMENT_ID) as HTMLStyleElement | null;\n if (!style) {\n style = document.createElement(\"style\");\n style.id = STYLE_ELEMENT_ID;\n document.head.appendChild(style);\n }\n const blocks: string[] = [];\n for (const theme of themes) {\n blocks.push(fontsToCss(theme.name, theme.fonts));\n blocks.push(colorScaleToCss(theme.name, \"light\", theme.light));\n blocks.push(colorScaleToCss(theme.name, \"dark\", theme.dark));\n }\n style.textContent = blocks.join(\"\\n\\n\");\n}\n\n/**\n * loadThemeFonts — idempotently inject `<link rel=\"stylesheet\">` for each\n * font URL declared by the theme.\n *\n * T4.2: the previous implementation kept a module-level `Set` to track\n * already-injected URLs. That singleton broke test isolation (state\n * leaked across renders) and silently skipped injection in micro-frontend\n * setups with multiple `<ThemeProvider>` mounts. Replaced with a DOM\n * check: we query `document.head` for an existing link with the same\n * `href` before appending a new one. The DOM is the single source of\n * truth; no shared state across instances.\n */\nfunction loadThemeFonts(theme: Theme): void {\n if (typeof document === \"undefined\") return;\n if (!theme.fontUrls) return;\n for (const url of theme.fontUrls) {\n // Re-audit NEW-001 (SSRF, LOW): defang dangerous protocols on\n // consumer-provided font URLs. Built-in themes use\n // fonts.googleapis.com/gstatic.com — safe. registerTheme accepts\n // arbitrary objects at runtime; a malicious theme could try to inject\n // javascript:/data:text/html via fontUrls. safeHref returns undefined\n // for dangerous protocols, which we skip silently.\n const safe = safeHref(url);\n if (!safe) continue;\n if (document.head.querySelector(`link[rel=\"stylesheet\"][href=\"${CSS.escape(safe)}\"]`)) {\n continue;\n }\n const link = document.createElement(\"link\");\n link.rel = \"stylesheet\";\n link.href = safe;\n document.head.appendChild(link);\n }\n}\n\ninterface ThemeProviderProps {\n children: ReactNode;\n /**\n * Theme to start with. Must match the `name` of an entry in `themes`.\n * Defaults to `\"violet-forge\"` for backward compat — if you don't pass\n * `violet-forge` in `themes`, set this prop explicitly.\n */\n defaultTheme?: string;\n /** Mode to start with. Defaults to `\"dark\"` (library is dark-first). */\n defaultMode?: ThemeMode;\n /**\n * Available themes. **Required**: ThemeProvider does not auto-include any\n * built-in theme since v0.1.0-next.0 — pass `builtinThemes` for all three\n * Violet Forge defaults, or your own array for a slimmer bundle.\n *\n * Migration: consumers previously calling `<ThemeProvider>` without this\n * prop now must pass `themes={builtinThemes}` (or use `<TheoUIProvider>`\n * which defaults to `builtinThemes` for you).\n */\n themes: Theme[];\n /**\n * Persist selection in localStorage under this key. Pass `null` to disable.\n * Default: \"theo-ui:theme\".\n */\n storageKey?: string | null;\n}\n\n/**\n * Storage failure diagnostic — dev-only one-line warn so engineers see\n * something when localStorage throws (Safari private mode, blocked\n * third-party cookies, sandboxed iframes). In production we stay silent;\n * runtime behavior is fail-safe (state still lives in memory).\n *\n * Per HIGH-006: silent catches diverge from the \"fail loud\" principle\n * declared in the global CLAUDE.md. We accept silence in prod because the\n * fallback is correct, but we surface a single warn per call site in dev.\n */\nfunction warnStorageFailure(scope: string, err: unknown): void {\n if (typeof process === \"undefined\" || process.env.NODE_ENV === \"production\") return;\n // biome-ignore lint/suspicious/noConsole: dev-only diagnostic for storage failures (HIGH-006)\n console.warn(`[@usetheo/ui] theme storage failure (${scope}):`, err);\n}\n\n/**\n * ThemeProvider — central registry + runtime switcher for Theo themes.\n *\n * Behavior:\n * 1. On mount, injects a `<style id=\"theo-ui-theme-vars\">` element with\n * one CSS block per theme (`[data-theme=\"<name>\"] { --token: ... }`).\n * 2. Sets `data-theme` and `data-mode` on `<html>` so any element nested\n * below inherits the right tokens (the Tailwind config consumes them).\n * 3. Lazy-loads theme font URLs by injecting `<link rel=\"stylesheet\">`.\n * 4. Optionally persists choice in localStorage.\n */\nfunction ThemeProvider({\n children,\n defaultTheme = \"violet-forge\",\n defaultMode = \"dark\",\n themes: themesProp,\n storageKey = \"theo-ui:theme\",\n}: ThemeProviderProps): JSX.Element {\n // Themes prop is required since v0.1.0-next.0 — see migration note in\n // the JSDoc on ThemeProviderProps. Pass `builtinThemes` for the legacy\n // default behavior (violet-forge + classic-paper + aurora-terminal), or\n // an array of your own. Empty array is rejected: ThemeProvider has no\n // valid state without at least one registered theme.\n if (!themesProp || themesProp.length === 0) {\n throw new Error(\n \"<ThemeProvider> requires the `themes` prop with at least one Theme. \" +\n \"Pass `themes={builtinThemes}` for the Violet Forge defaults (importable \" +\n \"via the package barrel), or use <TheoUIProvider> which sets this for you.\",\n );\n }\n\n // T3.2 (SEC-001): eager validation. Calling validatedColor/FontFamily/\n // ThemeName here ensures CSS-injection attempts throw at construction\n // time rather than inside the deferred useEffect that injects the\n // <style>. Production-mode fallbacks keep the app rendering even if a\n // theme has bad values.\n //\n // Re-audit NEW-3: wrapped in useMemo so the validation cost (O(themes *\n // tokens), ~60 ops per built-in theme) only runs when themesProp's\n // reference changes — not on every parent re-render. Consumers passing\n // inline array literals (`themes={[violetForge, classicPaper]}`) would\n // otherwise pay this on every parent update.\n const mergedThemes = useMemo<Theme[]>(() => {\n for (const t of themesProp) {\n validatedThemeName(t.name);\n validatedFontFamily(\"display\", t.fonts.display);\n validatedFontFamily(\"body\", t.fonts.body);\n validatedFontFamily(\"mono\", t.fonts.mono);\n for (const [token, value] of Object.entries(t.light)) {\n validatedColor(token, value);\n }\n for (const [token, value] of Object.entries(t.dark)) {\n validatedColor(token, value);\n }\n }\n // Dedup by theme name; last writer wins (allows registerTheme override).\n const map = new Map<string, Theme>();\n for (const t of themesProp) map.set(t.name, t);\n return Array.from(map.values());\n }, [themesProp]);\n\n const [themes, setThemes] = useState<Theme[]>(mergedThemes);\n\n // Re-sync state when the `themes` prop changes between renders. Avoids the\n // common pitfall where the user passes a different array later and the\n // initial-state-only seed silently ignores the change.\n useEffect(() => {\n setThemes(mergedThemes);\n }, [mergedThemes]);\n\n const [themeName, setThemeName] = useState<string>(() => {\n if (typeof window === \"undefined\" || !storageKey) return defaultTheme;\n try {\n return window.localStorage.getItem(`${storageKey}:name`) ?? defaultTheme;\n } catch (err) {\n warnStorageFailure(\"read theme name\", err);\n return defaultTheme;\n }\n });\n\n const [mode, setModeState] = useState<ThemeMode>(() => {\n if (typeof window === \"undefined\" || !storageKey) return defaultMode;\n try {\n const stored = window.localStorage.getItem(`${storageKey}:mode`);\n return stored === \"dark\" || stored === \"light\" ? stored : defaultMode;\n } catch (err) {\n warnStorageFailure(\"read theme mode\", err);\n return defaultMode;\n }\n });\n\n // Inject CSS vars whenever the themes list changes.\n useEffect(() => {\n injectThemeCss(themes);\n }, [themes]);\n\n // Apply data-theme + data-mode to <html>, load fonts.\n useEffect(() => {\n if (typeof document === \"undefined\") return;\n const active = themes.find((t) => t.name === themeName) ?? themes[0];\n if (!active) return;\n document.documentElement.setAttribute(\"data-theme\", active.name);\n document.documentElement.setAttribute(\"data-mode\", mode);\n document.documentElement.classList.toggle(\"dark\", mode === \"dark\");\n loadThemeFonts(active);\n }, [themeName, mode, themes]);\n\n // Persist on change.\n useEffect(() => {\n if (typeof window === \"undefined\" || !storageKey) return;\n try {\n window.localStorage.setItem(`${storageKey}:name`, themeName);\n window.localStorage.setItem(`${storageKey}:mode`, mode);\n } catch (err) {\n // Storage may fail in private mode; behavior remains correct (state\n // lives in memory). Per HIGH-006 we surface a one-time dev warning so\n // the engineer sees something instead of complete silence.\n warnStorageFailure(\"persist theme + mode\", err);\n }\n }, [themeName, mode, storageKey]);\n\n const setTheme = useCallback((name: string) => setThemeName(name), []);\n const setMode = useCallback((next: ThemeMode) => setModeState(next), []);\n const toggleMode = useCallback(\n () => setModeState((cur) => (cur === \"light\" ? \"dark\" : \"light\")),\n [],\n );\n const registerTheme = useCallback((theme: Theme) => {\n setThemes((cur) => {\n const idx = cur.findIndex((t) => t.name === theme.name);\n if (idx >= 0) {\n const next = cur.slice();\n next[idx] = theme;\n return next;\n }\n return [...cur, theme];\n });\n }, []);\n\n // themes[0] is guaranteed non-undefined by the constructor-time check\n // above (themesProp is non-empty); the non-null assert encodes that\n // invariant for TypeScript, which can't trace it through useState.\n // biome-ignore lint/style/noNonNullAssertion: T2.5 runtime invariant — themesProp non-empty validated at top of function\n const active = themes.find((t) => t.name === themeName) ?? themes[0]!;\n\n const value = useMemo<ThemeContextValue>(\n () => ({\n theme: active,\n mode,\n themes,\n setTheme,\n setMode,\n toggleMode,\n registerTheme,\n }),\n [active, mode, themes, setTheme, setMode, toggleMode, registerTheme],\n );\n\n return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;\n}\n\n/**\n * useTheme — access theme state from any component inside <ThemeProvider>.\n * Throws if used outside the provider — fail-fast.\n */\nfunction useTheme(): ThemeContextValue {\n const ctx = useContext(ThemeContext);\n if (!ctx) {\n throw new Error(\"useTheme must be used inside <ThemeProvider>.\");\n }\n return ctx;\n}\n\nexport { ThemeProvider, useTheme };\n"
44
+ "content": "import { createContext, useCallback, useContext, useEffect, useMemo, useState } from \"react\";\nimport type { JSX, ReactNode } from \"react\";\nimport { safeHref } from \"@/lib/safe-href\";\nimport { type Density, DensityContext, injectDensityCss } from \"./density\";\nimport type { ColorScale, Theme, ThemeMode } from \"@/themes/types\";\n\ninterface ThemeContextValue {\n /** Active theme (full descriptor). */\n theme: Theme;\n /** Active mode: light or dark. */\n mode: ThemeMode;\n /** All available themes. */\n themes: Theme[];\n /** Swap the active theme by name. */\n setTheme: (name: string) => void;\n /** Set light/dark explicitly. */\n setMode: (mode: ThemeMode) => void;\n /** Toggle light <> dark. */\n toggleMode: () => void;\n /** Register an additional theme at runtime. */\n registerTheme: (theme: Theme) => void;\n}\n\nconst ThemeContext = createContext<ThemeContextValue | undefined>(undefined);\n\nconst STYLE_ELEMENT_ID = \"theo-ui-theme-vars\";\n\n// T3.2 (SEC-001): allowlist validators for theme values. injectThemeCss\n// interpolates theme name + color values + font families into a <style>\n// textContent. Without validation, a theme object from an untrusted source\n// (e.g., a feature-flag service, a CMS) could inject arbitrary CSS via\n// closing the declaration with `}` or smuggling `url(...)` for exfiltration.\n// We reject rather than escape: themes are code, not user input. Invalid\n// values cause a dev-time throw (caller sees the problem); production\n// silently substitutes a safe fallback so a misconfigured theme can't\n// crash the app.\n\n// Color values. Multiple accepted shapes:\n// 1. Hex: `#fff`, `#0a0a0a`, `#0a0a0aff`.\n// 2. Fully-parenthesized CSS color functions: `oklch(...)`, `rgb(...)`,\n// `hsl(...)`, etc. Inner content restricted to digits/dots/spaces/\n// percent/slash/comma/dash/plus — no semicolons, no braces, no `url(`.\n// 3. HSL-component split (shadcn-ui convention used by the built-in\n// themes): `\"0 0% 100%\"`, `\"262 83% 58%\"` — space-separated numeric\n// components consumed via `hsl(var(--token))` in stylesheets.\n// 4. `var(--token)` references, optionally with a fallback value that\n// contains no parens/braces/semicolons.\n// 5. CSS keywords: `transparent`, `currentColor`, `inherit`, `initial`,\n// `unset`.\nconst COLOR_VALUE_PATTERN =\n /^(#[0-9a-fA-F]{3,8}|(?:oklch|oklab|rgb|rgba|hsl|hsla|lab|lch|color)\\(\\s*[\\d.\\s%,/+\\-]+\\s*\\)|-?\\d+(?:\\.\\d+)?%?(?:\\s+-?\\d+(?:\\.\\d+)?%?){1,3}|var\\(--[a-zA-Z0-9-]+(?:\\s*,\\s*[^();{}]+)?\\)|transparent|currentColor|inherit|initial|unset)$/;\n\n// Font family: word chars, spaces, commas, hyphens, dots, quotes. Excludes\n// parens (blocks `url(...)`) and semicolons (blocks declaration breakouts).\nconst FONT_FAMILY_PATTERN = /^[\\w\\s,\"'\\-.]+$/;\n\n// Theme name: kebab-case identifier. Excludes anything that could break out\n// of an attribute selector or inject additional rules.\nconst THEME_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;\n\nconst IS_DEV = typeof process === \"undefined\" || process.env.NODE_ENV !== \"production\";\n\nfunction rejectOrFallback(scope: string, value: string, fallback: string): string {\n if (IS_DEV) {\n throw new Error(\n `[@usetheo/ui] invalid ${scope} value: ${JSON.stringify(value)}. Theme values must match the allowlist (see src/themes/theme-provider.tsx). Refusing to inject potentially unsafe CSS.`,\n );\n }\n return fallback;\n}\n\nfunction validatedColor(token: string, value: string): string {\n if (COLOR_VALUE_PATTERN.test(value)) return value;\n return rejectOrFallback(`color \"${token}\"`, value, \"transparent\");\n}\n\nfunction validatedFontFamily(slot: string, value: string): string {\n if (FONT_FAMILY_PATTERN.test(value)) return value;\n return rejectOrFallback(`fontFamily \"${slot}\"`, value, \"inherit\");\n}\n\nfunction validatedThemeName(value: string): string {\n if (THEME_NAME_PATTERN.test(value)) return value;\n return rejectOrFallback(\"theme.name\", value, \"invalid-theme\");\n}\n\nfunction colorScaleToCss(name: string, mode: ThemeMode, colors: ColorScale): string {\n const safeName = validatedThemeName(name);\n const selector =\n mode === \"light\"\n ? `[data-theme=\"${safeName}\"]`\n : `[data-theme=\"${safeName}\"].dark, [data-theme=\"${safeName}\"][data-mode=\"dark\"]`;\n const decls = Object.entries(colors)\n .map(([token, value]) => ` --${token}: ${validatedColor(token, value)};`)\n .join(\"\\n\");\n return `${selector} {\\n${decls}\\n}`;\n}\n\nfunction fontsToCss(name: string, fonts: Theme[\"fonts\"]): string {\n const safeName = validatedThemeName(name);\n const display = validatedFontFamily(\"display\", fonts.display);\n const body = validatedFontFamily(\"body\", fonts.body);\n const mono = validatedFontFamily(\"mono\", fonts.mono);\n return `[data-theme=\"${safeName}\"] {\\n --font-display: ${display};\\n --font-body: ${body};\\n --font-mono: ${mono};\\n}`;\n}\n\nfunction injectThemeCss(themes: Theme[]): void {\n if (typeof document === \"undefined\") return;\n let style = document.getElementById(STYLE_ELEMENT_ID) as HTMLStyleElement | null;\n if (!style) {\n style = document.createElement(\"style\");\n style.id = STYLE_ELEMENT_ID;\n document.head.appendChild(style);\n }\n const blocks: string[] = [];\n for (const theme of themes) {\n blocks.push(fontsToCss(theme.name, theme.fonts));\n blocks.push(colorScaleToCss(theme.name, \"light\", theme.light));\n blocks.push(colorScaleToCss(theme.name, \"dark\", theme.dark));\n }\n style.textContent = blocks.join(\"\\n\\n\");\n}\n\n/**\n * loadThemeFonts — idempotently inject `<link rel=\"stylesheet\">` for each\n * font URL declared by the theme.\n *\n * T4.2: the previous implementation kept a module-level `Set` to track\n * already-injected URLs. That singleton broke test isolation (state\n * leaked across renders) and silently skipped injection in micro-frontend\n * setups with multiple `<ThemeProvider>` mounts. Replaced with a DOM\n * check: we query `document.head` for an existing link with the same\n * `href` before appending a new one. The DOM is the single source of\n * truth; no shared state across instances.\n */\nfunction loadThemeFonts(theme: Theme): void {\n if (typeof document === \"undefined\") return;\n if (!theme.fontUrls) return;\n for (const url of theme.fontUrls) {\n // Re-audit NEW-001 (SSRF, LOW): defang dangerous protocols on\n // consumer-provided font URLs. Built-in themes use\n // fonts.googleapis.com/gstatic.com — safe. registerTheme accepts\n // arbitrary objects at runtime; a malicious theme could try to inject\n // javascript:/data:text/html via fontUrls. safeHref returns undefined\n // for dangerous protocols, which we skip silently.\n const safe = safeHref(url);\n if (!safe) continue;\n if (document.head.querySelector(`link[rel=\"stylesheet\"][href=\"${CSS.escape(safe)}\"]`)) {\n continue;\n }\n const link = document.createElement(\"link\");\n link.rel = \"stylesheet\";\n link.href = safe;\n document.head.appendChild(link);\n }\n}\n\ninterface ThemeProviderProps {\n children: ReactNode;\n /**\n * Theme to start with. Must match the `name` of an entry in `themes`.\n * Defaults to `\"violet-forge\"` for backward compat — if you don't pass\n * `violet-forge` in `themes`, set this prop explicitly.\n */\n defaultTheme?: string;\n /** Mode to start with. Defaults to `\"dark\"` (library is dark-first). */\n defaultMode?: ThemeMode;\n /**\n * Available themes. **Required**: ThemeProvider does not auto-include any\n * built-in theme since v0.1.0-next.0 — pass `builtinThemes` for all three\n * Violet Forge defaults, or your own array for a slimmer bundle.\n *\n * Migration: consumers previously calling `<ThemeProvider>` without this\n * prop now must pass `themes={builtinThemes}` (or use `<TheoUIProvider>`\n * which defaults to `builtinThemes` for you).\n */\n themes: Theme[];\n /**\n * Persist selection in localStorage under this key. Pass `null` to disable.\n * Default: \"theo-ui:theme\".\n */\n storageKey?: string | null;\n /**\n * Initial density. Drives `data-density` on `<html>` and the `--theo-control-h`\n * / `--theo-control-px` CSS vars consumed by form-control `md` variants.\n * Defaults to `\"comfortable\"` (36px controls — FAANG-tier modern density).\n * Plan: faang-density-tightening (D3).\n */\n defaultDensity?: Density;\n}\n\n/**\n * Storage failure diagnostic — dev-only one-line warn so engineers see\n * something when localStorage throws (Safari private mode, blocked\n * third-party cookies, sandboxed iframes). In production we stay silent;\n * runtime behavior is fail-safe (state still lives in memory).\n *\n * Per HIGH-006: silent catches diverge from the \"fail loud\" principle\n * declared in the global CLAUDE.md. We accept silence in prod because the\n * fallback is correct, but we surface a single warn per call site in dev.\n */\nfunction warnStorageFailure(scope: string, err: unknown): void {\n if (typeof process === \"undefined\" || process.env.NODE_ENV === \"production\") return;\n // biome-ignore lint/suspicious/noConsole: dev-only diagnostic for storage failures (HIGH-006)\n console.warn(`[@usetheo/ui] theme storage failure (${scope}):`, err);\n}\n\n/**\n * ThemeProvider — central registry + runtime switcher for Theo themes.\n *\n * Behavior:\n * 1. On mount, injects a `<style id=\"theo-ui-theme-vars\">` element with\n * one CSS block per theme (`[data-theme=\"<name>\"] { --token: ... }`).\n * 2. Sets `data-theme` and `data-mode` on `<html>` so any element nested\n * below inherits the right tokens (the Tailwind config consumes them).\n * 3. Lazy-loads theme font URLs by injecting `<link rel=\"stylesheet\">`.\n * 4. Optionally persists choice in localStorage.\n */\nfunction ThemeProvider({\n children,\n defaultTheme = \"violet-forge\",\n defaultMode = \"dark\",\n themes: themesProp,\n storageKey = \"theo-ui:theme\",\n defaultDensity = \"comfortable\",\n}: ThemeProviderProps): JSX.Element {\n // Themes prop is required since v0.1.0-next.0 — see migration note in\n // the JSDoc on ThemeProviderProps. Pass `builtinThemes` for the legacy\n // default behavior (violet-forge + classic-paper + aurora-terminal), or\n // an array of your own. Empty array is rejected: ThemeProvider has no\n // valid state without at least one registered theme.\n if (!themesProp || themesProp.length === 0) {\n throw new Error(\n \"<ThemeProvider> requires the `themes` prop with at least one Theme. \" +\n \"Pass `themes={builtinThemes}` for the Violet Forge defaults (importable \" +\n \"via the package barrel), or use <TheoUIProvider> which sets this for you.\",\n );\n }\n\n // T3.2 (SEC-001): eager validation. Calling validatedColor/FontFamily/\n // ThemeName here ensures CSS-injection attempts throw at construction\n // time rather than inside the deferred useEffect that injects the\n // <style>. Production-mode fallbacks keep the app rendering even if a\n // theme has bad values.\n //\n // Re-audit NEW-3: wrapped in useMemo so the validation cost (O(themes *\n // tokens), ~60 ops per built-in theme) only runs when themesProp's\n // reference changes — not on every parent re-render. Consumers passing\n // inline array literals (`themes={[violetForge, classicPaper]}`) would\n // otherwise pay this on every parent update.\n const mergedThemes = useMemo<Theme[]>(() => {\n for (const t of themesProp) {\n validatedThemeName(t.name);\n validatedFontFamily(\"display\", t.fonts.display);\n validatedFontFamily(\"body\", t.fonts.body);\n validatedFontFamily(\"mono\", t.fonts.mono);\n for (const [token, value] of Object.entries(t.light)) {\n validatedColor(token, value);\n }\n for (const [token, value] of Object.entries(t.dark)) {\n validatedColor(token, value);\n }\n }\n // Dedup by theme name; last writer wins (allows registerTheme override).\n const map = new Map<string, Theme>();\n for (const t of themesProp) map.set(t.name, t);\n return Array.from(map.values());\n }, [themesProp]);\n\n const [themes, setThemes] = useState<Theme[]>(mergedThemes);\n\n // Re-sync state when the `themes` prop changes between renders. Avoids the\n // common pitfall where the user passes a different array later and the\n // initial-state-only seed silently ignores the change.\n useEffect(() => {\n setThemes(mergedThemes);\n }, [mergedThemes]);\n\n const [themeName, setThemeName] = useState<string>(() => {\n if (typeof window === \"undefined\" || !storageKey) return defaultTheme;\n try {\n return window.localStorage.getItem(`${storageKey}:name`) ?? defaultTheme;\n } catch (err) {\n warnStorageFailure(\"read theme name\", err);\n return defaultTheme;\n }\n });\n\n const [mode, setModeState] = useState<ThemeMode>(() => {\n if (typeof window === \"undefined\" || !storageKey) return defaultMode;\n try {\n const stored = window.localStorage.getItem(`${storageKey}:mode`);\n return stored === \"dark\" || stored === \"light\" ? stored : defaultMode;\n } catch (err) {\n warnStorageFailure(\"read theme mode\", err);\n return defaultMode;\n }\n });\n\n const [density, setDensityState] = useState<Density>(() => {\n if (typeof window === \"undefined\" || !storageKey) return defaultDensity;\n try {\n const stored = window.localStorage.getItem(`${storageKey}:density`);\n return stored === \"compact\" || stored === \"comfortable\" || stored === \"spacious\"\n ? stored\n : defaultDensity;\n } catch (err) {\n warnStorageFailure(\"read density\", err);\n return defaultDensity;\n }\n });\n\n // Inject CSS vars whenever the themes list changes.\n useEffect(() => {\n injectThemeCss(themes);\n }, [themes]);\n\n // Apply data-theme + data-mode + data-density to <html>, load fonts,\n // inject density CSS vars.\n useEffect(() => {\n if (typeof document === \"undefined\") return;\n const active = themes.find((t) => t.name === themeName) ?? themes[0];\n if (!active) return;\n document.documentElement.setAttribute(\"data-theme\", active.name);\n document.documentElement.setAttribute(\"data-mode\", mode);\n document.documentElement.setAttribute(\"data-density\", density);\n document.documentElement.classList.toggle(\"dark\", mode === \"dark\");\n loadThemeFonts(active);\n injectDensityCss();\n }, [themeName, mode, density, themes]);\n\n // Persist on change.\n useEffect(() => {\n if (typeof window === \"undefined\" || !storageKey) return;\n try {\n window.localStorage.setItem(`${storageKey}:name`, themeName);\n window.localStorage.setItem(`${storageKey}:mode`, mode);\n window.localStorage.setItem(`${storageKey}:density`, density);\n } catch (err) {\n // Storage may fail in private mode; behavior remains correct (state\n // lives in memory). Per HIGH-006 we surface a one-time dev warning so\n // the engineer sees something instead of complete silence.\n warnStorageFailure(\"persist theme + mode + density\", err);\n }\n }, [themeName, mode, density, storageKey]);\n\n const setTheme = useCallback((name: string) => setThemeName(name), []);\n const setMode = useCallback((next: ThemeMode) => setModeState(next), []);\n const toggleMode = useCallback(\n () => setModeState((cur) => (cur === \"light\" ? \"dark\" : \"light\")),\n [],\n );\n const setDensity = useCallback((next: Density) => setDensityState(next), []);\n const registerTheme = useCallback((theme: Theme) => {\n setThemes((cur) => {\n const idx = cur.findIndex((t) => t.name === theme.name);\n if (idx >= 0) {\n const next = cur.slice();\n next[idx] = theme;\n return next;\n }\n return [...cur, theme];\n });\n }, []);\n\n // themes[0] is guaranteed non-undefined by the constructor-time check\n // above (themesProp is non-empty); the non-null assert encodes that\n // invariant for TypeScript, which can't trace it through useState.\n // biome-ignore lint/style/noNonNullAssertion: T2.5 runtime invariant — themesProp non-empty validated at top of function\n const active = themes.find((t) => t.name === themeName) ?? themes[0]!;\n\n const value = useMemo<ThemeContextValue>(\n () => ({\n theme: active,\n mode,\n themes,\n setTheme,\n setMode,\n toggleMode,\n registerTheme,\n }),\n [active, mode, themes, setTheme, setMode, toggleMode, registerTheme],\n );\n\n const densityValue = useMemo(() => ({ density, setDensity }), [density, setDensity]);\n\n return (\n <ThemeContext.Provider value={value}>\n <DensityContext.Provider value={densityValue}>{children}</DensityContext.Provider>\n </ThemeContext.Provider>\n );\n}\n\n/**\n * useTheme — access theme state from any component inside <ThemeProvider>.\n * Throws if used outside the provider — fail-fast.\n */\nfunction useTheme(): ThemeContextValue {\n const ctx = useContext(ThemeContext);\n if (!ctx) {\n throw new Error(\"useTheme must be used inside <ThemeProvider>.\");\n }\n return ctx;\n}\n\nexport { ThemeProvider, useTheme };\n"
45
45
  },
46
46
  {
47
47
  "path": "themes/theme-switcher.tsx",
@@ -53,7 +53,7 @@
53
53
  "path": "themes/index.ts",
54
54
  "type": "registry:lib",
55
55
  "target": "themes/index.ts",
56
- "content": "export type { ColorScale, Theme, ThemeFonts, ThemeMode } from \"@/themes/types\";\nexport { ThemeProvider, useTheme } from \"@/themes/theme-provider\";\nexport { ThemeScript } from \"@/themes/theme-script\";\nexport { ThemeSwitcher } from \"@/themes/theme-switcher\";\nexport { violetForge } from \"@/themes/violet-forge\";\nexport { classicPaper } from \"@/themes/classic-paper\";\nexport { auroraTerminal } from \"@/themes/aurora-terminal\";\n\nimport { auroraTerminal } from \"@/themes/aurora-terminal\";\nimport { classicPaper } from \"@/themes/classic-paper\";\nimport { violetForge } from \"@/themes/violet-forge\";\n\n/**\n * All themes bundled with Theo UI. Pass to `<ThemeProvider themes={builtinThemes}>`\n * if you want all of them available out of the box.\n */\nexport const builtinThemes = [violetForge, classicPaper, auroraTerminal];\n"
56
+ "content": "export type { ColorScale, Theme, ThemeFonts, ThemeMode } from \"@/themes/types\";\nexport { ThemeProvider, useTheme } from \"@/themes/theme-provider\";\nexport { ThemeScript } from \"@/themes/theme-script\";\nexport { ThemeSwitcher } from \"@/themes/theme-switcher\";\nexport { violetForge } from \"@/themes/violet-forge\";\nexport { classicPaper } from \"@/themes/classic-paper\";\nexport { auroraTerminal } from \"@/themes/aurora-terminal\";\nexport { defineTheme, type DefineThemeInput } from \"./define\";\nexport { hex, rgb } from \"./color\";\nexport { useDensity, type Density, type DensityContextValue } from \"./density\";\n\nimport { auroraTerminal } from \"@/themes/aurora-terminal\";\nimport { classicPaper } from \"@/themes/classic-paper\";\nimport { violetForge } from \"@/themes/violet-forge\";\n\n/**\n * All themes bundled with Theo UI. Pass to `<ThemeProvider themes={builtinThemes}>`\n * if you want all of them available out of the box.\n */\nexport const builtinThemes = [violetForge, classicPaper, auroraTerminal];\n"
57
57
  }
58
58
  ]
59
59
  }
@@ -5,7 +5,7 @@
5
5
  "title": "ThemeScript",
6
6
  "description": "Inline script for SSR-safe theme initialization in Next.js / Astro / Remix. Place in <head> to read persisted theme + mode from localStorage BEFORE React hydrates, eliminating FOUC and hydration mismatch.",
7
7
  "registryDependencies": [
8
- "theme-provider"
8
+ "https://usetheodev.github.io/theo-ui/r/theme-provider.json"
9
9
  ],
10
10
  "files": [
11
11
  {
@@ -6,8 +6,8 @@
6
6
  "description": "Primary entry-point provider — composes ThemeProvider + Toaster with sensible defaults. Use as the single root wrapper in consumer apps.",
7
7
  "dependencies": [],
8
8
  "registryDependencies": [
9
- "theme-provider",
10
- "toast"
9
+ "https://usetheodev.github.io/theo-ui/r/theme-provider.json",
10
+ "https://usetheodev.github.io/theo-ui/r/toast.json"
11
11
  ],
12
12
  "files": [
13
13
  {
@@ -10,15 +10,15 @@
10
10
  "lucide-react"
11
11
  ],
12
12
  "registryDependencies": [
13
- "cn",
14
- "tailwind-preset"
13
+ "https://usetheodev.github.io/theo-ui/r/cn.json",
14
+ "https://usetheodev.github.io/theo-ui/r/tailwind-preset.json"
15
15
  ],
16
16
  "files": [
17
17
  {
18
18
  "path": "components/primitives/toast/toast.tsx",
19
19
  "type": "registry:ui",
20
20
  "target": "components/ui/toast.tsx",
21
- "content": "import * as ToastPrimitive from \"@radix-ui/react-toast\";\nimport { type VariantProps, cva } from \"class-variance-authority\";\nimport { AlertCircle, CheckCircle2, Info, TriangleAlert, X } from \"lucide-react\";\nimport { forwardRef } from \"react\";\nimport type { ComponentPropsWithoutRef, ElementRef, ReactNode } from \"react\";\nimport { cn } from \"@/lib/cn\";\n\n/**\n * Toast — transient notification built on Radix Toast.\n *\n * Composition: app mounts <Toaster /> once. Components call `toast(...)` via\n * the `useToast()` hook (see toaster.tsx). The look is theme-aware: status\n * icon coloured by --primary/--success/--warning/--destructive; the body\n * card uses --popover surface.\n *\n * Variants:\n * - default (neutral)\n * - info (primary tint)\n * - success (deploy ok, action completed)\n * - warning (queued, permission requested)\n * - destructive (failed, fatal)\n */\n\nconst toastVariants = cva(\n [\n \"group pointer-events-auto relative flex w-full items-start gap-3 overflow-hidden rounded-lg border p-4 pr-10 shadow-md\",\n \"data-[state=open]:slide-in-from-top-full data-[state=open]:fade-in-0 data-[state=open]:animate-in\",\n \"data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=closed]:animate-out\",\n \"data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)]\",\n \"data-[swipe=end]:animate-out data-[swipe=cancel]:transition-[transform_var(--duration-base)] data-[swipe=move]:transition-none\",\n ],\n {\n variants: {\n variant: {\n default: \"border-border/40 bg-popover text-popover-foreground\",\n info: \"border-primary/40 bg-popover text-popover-foreground\",\n success: \"border-success/40 bg-popover text-popover-foreground\",\n warning: \"border-warning/40 bg-popover text-popover-foreground\",\n destructive: \"border-destructive/50 bg-popover text-popover-foreground\",\n },\n },\n defaultVariants: { variant: \"default\" },\n },\n);\n\nconst iconForVariant: Record<NonNullable<ToastVariant>, ReactNode> = {\n default: <Info className=\"size-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />,\n info: <Info className=\"size-4 shrink-0 text-primary\" aria-hidden=\"true\" />,\n success: <CheckCircle2 className=\"size-4 shrink-0 text-success\" aria-hidden=\"true\" />,\n warning: <TriangleAlert className=\"size-4 shrink-0 text-warning\" aria-hidden=\"true\" />,\n destructive: <AlertCircle className=\"size-4 shrink-0 text-destructive\" aria-hidden=\"true\" />,\n};\n\ntype ToastVariant = NonNullable<VariantProps<typeof toastVariants>[\"variant\"]>;\n\ninterface ToastProps\n extends ComponentPropsWithoutRef<typeof ToastPrimitive.Root>,\n VariantProps<typeof toastVariants> {}\n\nconst ToastRoot = forwardRef<ElementRef<typeof ToastPrimitive.Root>, ToastProps>(\n ({ className, variant = \"default\", children, ...props }, ref) => (\n <ToastPrimitive.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props}>\n <span aria-hidden=\"true\">{iconForVariant[variant as ToastVariant]}</span>\n <div className=\"min-w-0 flex-1\">{children}</div>\n </ToastPrimitive.Root>\n ),\n);\nToastRoot.displayName = \"Toast\";\n\nconst ToastTitle = forwardRef<\n ElementRef<typeof ToastPrimitive.Title>,\n ComponentPropsWithoutRef<typeof ToastPrimitive.Title>\n>(({ className, ...props }, ref) => (\n <ToastPrimitive.Title\n ref={ref}\n className={cn(\"font-medium text-body-sm text-foreground\", className)}\n {...props}\n />\n));\nToastTitle.displayName = \"Toast.Title\";\n\nconst ToastDescription = forwardRef<\n ElementRef<typeof ToastPrimitive.Description>,\n ComponentPropsWithoutRef<typeof ToastPrimitive.Description>\n>(({ className, ...props }, ref) => (\n <ToastPrimitive.Description\n ref={ref}\n className={cn(\"mt-0.5 text-body-sm text-muted-foreground\", className)}\n {...props}\n />\n));\nToastDescription.displayName = \"Toast.Description\";\n\nconst ToastClose = forwardRef<\n ElementRef<typeof ToastPrimitive.Close>,\n ComponentPropsWithoutRef<typeof ToastPrimitive.Close>\n>(({ className, ...props }, ref) => (\n <ToastPrimitive.Close\n ref={ref}\n className={cn(\n \"absolute top-2 right-2 rounded-md p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n className,\n )}\n toast-close=\"\"\n {...props}\n >\n <X className=\"size-3.5\" />\n <span className=\"sr-only\">Close</span>\n </ToastPrimitive.Close>\n));\nToastClose.displayName = \"Toast.Close\";\n\nconst ToastAction = forwardRef<\n ElementRef<typeof ToastPrimitive.Action>,\n ComponentPropsWithoutRef<typeof ToastPrimitive.Action>\n>(({ className, ...props }, ref) => (\n <ToastPrimitive.Action\n ref={ref}\n className={cn(\n \"mt-2 inline-flex h-7 items-center rounded-md border border-border/60 bg-card px-2 font-sans text-foreground text-label\",\n \"transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n className,\n )}\n {...props}\n />\n));\nToastAction.displayName = \"Toast.Action\";\n\nconst Toast = /*#__PURE__*/ Object.assign(ToastRoot, {\n Title: ToastTitle,\n Description: ToastDescription,\n Close: ToastClose,\n Action: ToastAction,\n Provider: ToastPrimitive.Provider,\n Viewport: ToastPrimitive.Viewport,\n});\n\nexport { Toast, toastVariants, type ToastVariant };\n"
21
+ "content": "import * as ToastPrimitive from \"@radix-ui/react-toast\";\nimport { type VariantProps, cva } from \"class-variance-authority\";\nimport { AlertCircle, CheckCircle2, Info, TriangleAlert, X } from \"lucide-react\";\nimport { forwardRef } from \"react\";\nimport type { ComponentPropsWithoutRef, ElementRef, ReactNode } from \"react\";\nimport { cn } from \"@/lib/cn\";\n\n/**\n * Toast — transient notification built on Radix Toast.\n *\n * Composition: app mounts <Toaster /> once. Components call `toast(...)` via\n * the `useToast()` hook (see toaster.tsx). The look is theme-aware: status\n * icon coloured by --primary/--success/--warning/--destructive; the body\n * card uses --popover surface.\n *\n * Variants:\n * - default (neutral)\n * - info (primary tint)\n * - success (deploy ok, action completed)\n * - warning (queued, permission requested)\n * - destructive (failed, fatal)\n */\n\nconst toastVariants = cva(\n [\n \"group pointer-events-auto relative flex w-full items-start gap-3 overflow-hidden rounded-lg border pr-10 shadow-md\",\n \"data-[state=open]:slide-in-from-top-full data-[state=open]:fade-in-0 data-[state=open]:animate-in\",\n \"data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=closed]:animate-out\",\n \"data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)]\",\n \"data-[swipe=end]:animate-out data-[swipe=cancel]:transition-[transform_var(--duration-base)] data-[swipe=move]:transition-none\",\n ],\n {\n variants: {\n variant: {\n default: \"border-border/40 bg-popover text-popover-foreground\",\n info: \"border-primary/40 bg-popover text-popover-foreground\",\n success: \"border-success/40 bg-popover text-popover-foreground\",\n warning: \"border-warning/40 bg-popover text-popover-foreground\",\n destructive: \"border-destructive/50 bg-popover text-popover-foreground\",\n },\n size: {\n sm: \"p-3 text-body-sm\",\n md: \"p-4 text-body-md\",\n lg: \"p-5 text-body-lg\",\n },\n },\n defaultVariants: { variant: \"default\", size: \"md\" },\n },\n);\n\nconst iconForVariant: Record<NonNullable<ToastVariant>, ReactNode> = {\n default: <Info className=\"size-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />,\n info: <Info className=\"size-4 shrink-0 text-primary\" aria-hidden=\"true\" />,\n success: <CheckCircle2 className=\"size-4 shrink-0 text-success\" aria-hidden=\"true\" />,\n warning: <TriangleAlert className=\"size-4 shrink-0 text-warning\" aria-hidden=\"true\" />,\n destructive: <AlertCircle className=\"size-4 shrink-0 text-destructive\" aria-hidden=\"true\" />,\n};\n\ntype ToastVariant = NonNullable<VariantProps<typeof toastVariants>[\"variant\"]>;\n\ninterface ToastProps\n extends ComponentPropsWithoutRef<typeof ToastPrimitive.Root>,\n VariantProps<typeof toastVariants> {}\n\nconst ToastRoot = forwardRef<ElementRef<typeof ToastPrimitive.Root>, ToastProps>(\n ({ className, variant = \"default\", size, children, ...props }, ref) => (\n <ToastPrimitive.Root\n ref={ref}\n className={cn(toastVariants({ variant, size }), className)}\n {...props}\n >\n <span aria-hidden=\"true\">{iconForVariant[variant as ToastVariant]}</span>\n <div className=\"min-w-0 flex-1\">{children}</div>\n </ToastPrimitive.Root>\n ),\n);\nToastRoot.displayName = \"Toast\";\n\nconst ToastTitle = forwardRef<\n ElementRef<typeof ToastPrimitive.Title>,\n ComponentPropsWithoutRef<typeof ToastPrimitive.Title>\n>(({ className, ...props }, ref) => (\n <ToastPrimitive.Title\n ref={ref}\n className={cn(\"font-medium text-body-sm text-foreground\", className)}\n {...props}\n />\n));\nToastTitle.displayName = \"Toast.Title\";\n\nconst ToastDescription = forwardRef<\n ElementRef<typeof ToastPrimitive.Description>,\n ComponentPropsWithoutRef<typeof ToastPrimitive.Description>\n>(({ className, ...props }, ref) => (\n <ToastPrimitive.Description\n ref={ref}\n className={cn(\"mt-0.5 text-body-sm text-muted-foreground\", className)}\n {...props}\n />\n));\nToastDescription.displayName = \"Toast.Description\";\n\nconst ToastClose = forwardRef<\n ElementRef<typeof ToastPrimitive.Close>,\n ComponentPropsWithoutRef<typeof ToastPrimitive.Close>\n>(({ className, ...props }, ref) => (\n <ToastPrimitive.Close\n ref={ref}\n className={cn(\n \"absolute top-2 right-2 rounded-md p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n className,\n )}\n toast-close=\"\"\n {...props}\n >\n <X className=\"size-3.5\" />\n <span className=\"sr-only\">Close</span>\n </ToastPrimitive.Close>\n));\nToastClose.displayName = \"Toast.Close\";\n\nconst ToastAction = forwardRef<\n ElementRef<typeof ToastPrimitive.Action>,\n ComponentPropsWithoutRef<typeof ToastPrimitive.Action>\n>(({ className, ...props }, ref) => (\n <ToastPrimitive.Action\n ref={ref}\n className={cn(\n \"mt-2 inline-flex h-7 items-center rounded-md border border-border/60 bg-card px-2 font-sans text-foreground text-label\",\n \"transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n className,\n )}\n {...props}\n />\n));\nToastAction.displayName = \"Toast.Action\";\n\nconst Toast = /*#__PURE__*/ Object.assign(ToastRoot, {\n Title: ToastTitle,\n Description: ToastDescription,\n Close: ToastClose,\n Action: ToastAction,\n Provider: ToastPrimitive.Provider,\n Viewport: ToastPrimitive.Viewport,\n});\n\nexport { Toast, toastVariants, type ToastVariant };\n"
22
22
  },
23
23
  {
24
24
  "path": "components/primitives/toast/toaster.tsx",
@@ -6,8 +6,8 @@
6
6
  "description": "Stacked-bar chart of input vs output tokens over time.",
7
7
  "dependencies": [],
8
8
  "registryDependencies": [
9
- "cn",
10
- "tailwind-preset"
9
+ "https://usetheodev.github.io/theo-ui/r/cn.json",
10
+ "https://usetheodev.github.io/theo-ui/r/tailwind-preset.json"
11
11
  ],
12
12
  "files": [
13
13
  {
@@ -8,9 +8,9 @@
8
8
  "lucide-react"
9
9
  ],
10
10
  "registryDependencies": [
11
- "cn",
12
- "tailwind-preset",
13
- "types"
11
+ "https://usetheodev.github.io/theo-ui/r/cn.json",
12
+ "https://usetheodev.github.io/theo-ui/r/tailwind-preset.json",
13
+ "https://usetheodev.github.io/theo-ui/r/types.json"
14
14
  ],
15
15
  "files": [
16
16
  {
@@ -8,8 +8,8 @@
8
8
  "lucide-react"
9
9
  ],
10
10
  "registryDependencies": [
11
- "cn",
12
- "tailwind-preset"
11
+ "https://usetheodev.github.io/theo-ui/r/cn.json",
12
+ "https://usetheodev.github.io/theo-ui/r/tailwind-preset.json"
13
13
  ],
14
14
  "files": [
15
15
  {
@@ -6,8 +6,8 @@
6
6
  "description": "Formatted output of a tool invocation.",
7
7
  "dependencies": [],
8
8
  "registryDependencies": [
9
- "cn",
10
- "tailwind-preset"
9
+ "https://usetheodev.github.io/theo-ui/r/cn.json",
10
+ "https://usetheodev.github.io/theo-ui/r/tailwind-preset.json"
11
11
  ],
12
12
  "files": [
13
13
  {
@@ -8,9 +8,9 @@
8
8
  "lucide-react"
9
9
  ],
10
10
  "registryDependencies": [
11
- "cn",
12
- "tailwind-preset",
13
- "types"
11
+ "https://usetheodev.github.io/theo-ui/r/cn.json",
12
+ "https://usetheodev.github.io/theo-ui/r/tailwind-preset.json",
13
+ "https://usetheodev.github.io/theo-ui/r/types.json"
14
14
  ],
15
15
  "files": [
16
16
  {
@@ -8,8 +8,8 @@
8
8
  "@radix-ui/react-tooltip"
9
9
  ],
10
10
  "registryDependencies": [
11
- "cn",
12
- "tailwind-preset"
11
+ "https://usetheodev.github.io/theo-ui/r/cn.json",
12
+ "https://usetheodev.github.io/theo-ui/r/tailwind-preset.json"
13
13
  ],
14
14
  "files": [
15
15
  {
@@ -8,8 +8,8 @@
8
8
  "lucide-react"
9
9
  ],
10
10
  "registryDependencies": [
11
- "cn",
12
- "tailwind-preset"
11
+ "https://usetheodev.github.io/theo-ui/r/cn.json",
12
+ "https://usetheodev.github.io/theo-ui/r/tailwind-preset.json"
13
13
  ],
14
14
  "files": [
15
15
  {
@@ -0,0 +1,101 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "whiteboard",
4
+ "type": "registry:ui",
5
+ "title": "Whiteboard",
6
+ "description": "View-only primitive that renders a declarative JSON scene as SVG with a hand-drawn aesthetic. Pan + zoom built-in; no editor surface. Subpath-isolated bundle in @usetheo/ui.",
7
+ "dependencies": [
8
+ "perfect-freehand",
9
+ "roughjs",
10
+ "zod"
11
+ ],
12
+ "registryDependencies": [
13
+ "https://usetheodev.github.io/theo-ui/r/tailwind-preset.json"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "components/primitives/whiteboard/whiteboard.tsx",
18
+ "type": "registry:ui",
19
+ "target": "components/ui/whiteboard/whiteboard.tsx",
20
+ "content": "import { useEffect, useMemo, useRef } from \"react\";\n/**\n * `<Whiteboard>` — view-only primitive that turns a JSON scene into a\n * hand-drawn SVG. Lives in the isolated subpath `@usetheo/ui/whiteboard`.\n *\n * See RFC 0001 (`docs/rfcs/0001-whiteboard.md`) and the plan in\n * `.claude/knowledge-base/plans/whiteboard-view-primitive-plan.md`.\n */\nimport rough from \"roughjs\";\nimport type { RoughGenerator } from \"roughjs/bin/generator\";\nimport { renderScene } from \"@/components/ui/whiteboard/render/scene\";\nimport type { WhiteboardScene, WhiteboardSceneInput } from \"@/components/ui/whiteboard/schema\";\nimport { type ValidationError, validateScene } from \"@/components/ui/whiteboard/validate\";\nimport { usePointerPan } from \"@/components/ui/whiteboard/viewport/use-pointer-pan\";\nimport { useViewport } from \"@/components/ui/whiteboard/viewport/use-viewport\";\n\n/** Input type accepted by the component (defaults like `headEnd` are optional). */\nexport type WhiteboardData = WhiteboardSceneInput;\nexport type { ValidationError } from \"@/components/ui/whiteboard/validate\";\n\nexport interface WhiteboardProps {\n /** The scene to render. Validated with Zod on every change. */\n data: WhiteboardData | unknown;\n className?: string;\n /** Starting zoom level (clamped 0.1–8). Default 1. */\n initialZoom?: number;\n /** World coordinate centered in the viewport on first render. */\n initialCenter?: [number, number];\n /** When true, computes a bounding box of all elements and zooms to fit. */\n fitOnLoad?: boolean;\n /** Called once per validation failure with the list of errors (EC-6: in useEffect, not render). */\n onValidationError?: (errors: ValidationError[]) => void;\n /** Accessible label for the SVG (defaults to \"Whiteboard diagram\"). */\n \"aria-label\"?: string;\n}\n\nfunction computeBounds(scene: WhiteboardScene): {\n minX: number;\n minY: number;\n maxX: number;\n maxY: number;\n} | null {\n if (scene.elements.length === 0) return null;\n let minX = Number.POSITIVE_INFINITY;\n let minY = Number.POSITIVE_INFINITY;\n let maxX = Number.NEGATIVE_INFINITY;\n let maxY = Number.NEGATIVE_INFINITY;\n for (const el of scene.elements) {\n const x1 = el.x;\n const y1 = el.y;\n let x2 = el.x;\n let y2 = el.y;\n if (el.type === \"rect\" || el.type === \"ellipse\" || el.type === \"diamond\") {\n x2 = el.x + el.w;\n y2 = el.y + el.h;\n } else if (el.type === \"line\" || el.type === \"arrow\") {\n x2 = el.to[0];\n y2 = el.to[1];\n } else if (el.type === \"freedraw\") {\n for (const [px, py] of el.points) {\n const tx = el.x + px;\n const ty = el.y + py;\n if (tx < minX) minX = tx;\n if (ty < minY) minY = ty;\n if (tx > maxX) maxX = tx;\n if (ty > maxY) maxY = ty;\n }\n continue;\n }\n if (x1 < minX) minX = x1;\n if (y1 < minY) minY = y1;\n if (x2 < minX) minX = x2;\n if (y2 < minY) minY = y2;\n if (x1 > maxX) maxX = x1;\n if (y1 > maxY) maxY = y1;\n if (x2 > maxX) maxX = x2;\n if (y2 > maxY) maxY = y2;\n }\n if (!Number.isFinite(minX)) return null;\n return { minX, minY, maxX, maxY };\n}\n\nexport function Whiteboard({\n data,\n className,\n initialZoom,\n initialCenter,\n fitOnLoad,\n onValidationError,\n \"aria-label\": ariaLabel,\n}: WhiteboardProps) {\n const validation = useMemo(() => validateScene(data), [data]);\n const scene = validation.ok ? validation.scene : null;\n\n // EC-6: side effects only inside useEffect — never call user callbacks in render.\n useEffect(() => {\n if (!validation.ok && onValidationError) {\n onValidationError(validation.errors);\n }\n }, [validation, onValidationError]);\n\n // Stable rough.js generator across renders.\n const generator: RoughGenerator | null = useMemo(() => {\n if (typeof globalThis === \"undefined\") return null;\n try {\n return rough.generator();\n } catch {\n return null;\n }\n }, []);\n\n // Fallback dimensions for invalid scenes (still render a valid svg).\n const sceneWidth = scene?.width ?? 400;\n const sceneHeight = scene?.height ?? 300;\n const sceneBackground = scene?.background;\n\n const viewport = useViewport({\n width: sceneWidth,\n height: sceneHeight,\n initialZoom,\n initialCenter,\n });\n\n const svgRef = useRef<SVGSVGElement | null>(null);\n\n const fitTo = viewport.fitTo;\n // EC-14: fitOnLoad must run in useEffect (after mount), not during render.\n useEffect(() => {\n if (!fitOnLoad || !scene) return;\n const bounds = computeBounds(scene);\n if (!bounds) return;\n fitTo(bounds, { width: sceneWidth, height: sceneHeight });\n }, [fitOnLoad, scene, sceneWidth, sceneHeight, fitTo]);\n\n const handlers = usePointerPan(svgRef, viewport, {\n width: sceneWidth,\n height: sceneHeight,\n });\n\n const label = ariaLabel ?? \"Whiteboard diagram\";\n\n return (\n <svg\n ref={svgRef}\n viewBox={viewport.viewBox({ width: sceneWidth, height: sceneHeight })}\n width={sceneWidth}\n height={sceneHeight}\n className={className}\n role=\"img\"\n aria-label={label}\n xmlns=\"http://www.w3.org/2000/svg\"\n data-whiteboard-state={scene ? \"ok\" : \"invalid\"}\n style={{ touchAction: \"none\", userSelect: \"none\" }}\n {...handlers}\n >\n <title>{label}</title>\n {sceneBackground ? (\n <rect\n x={0}\n y={0}\n width={sceneWidth}\n height={sceneHeight}\n fill={sceneBackground}\n data-layer=\"background\"\n />\n ) : null}\n {scene && generator ? renderScene(scene, generator) : null}\n </svg>\n );\n}\n"
21
+ },
22
+ {
23
+ "path": "components/primitives/whiteboard/schema.ts",
24
+ "type": "registry:ui",
25
+ "target": "components/ui/whiteboard/schema.ts",
26
+ "content": "/**\n * Zod schema for the Whiteboard JSON v1 (`WhiteboardScene`).\n *\n * Design goals (see RFC 0001 §4 and ADR D4 in the plan):\n * - LLM-friendly: minimal fields per element.\n * - Discriminated union by `type` for exhaustive typing in TS.\n * - Defensive against LLM mistakes:\n * - EC-3: `.finite()` everywhere — rejects NaN / Infinity.\n * - EC-4: `.max()` on dimensions to cap absurd values from the model.\n */\nimport { z } from \"zod\";\n\nconst finiteNumber = z.number().finite();\nconst finitePositive = finiteNumber.positive();\n\nconst strokeStyle = z.enum([\"solid\", \"dashed\", \"dotted\"]);\nconst fillStyle = z.enum([\"hachure\", \"solid\", \"cross-hatch\", \"zigzag\"]);\nconst align = z.enum([\"left\", \"center\", \"right\"]);\nconst fontFamily = z.enum([\"sans\", \"serif\", \"mono\", \"hand\"]);\nconst roundness = z.enum([\"sharp\", \"round\"]);\n\nconst baseElement = z.object({\n id: z.string().max(120).optional(),\n x: finiteNumber,\n y: finiteNumber,\n stroke: z.string().max(64).optional(),\n strokeWidth: finitePositive.max(50).optional(),\n strokeStyle: strokeStyle.optional(),\n fill: z.string().max(64).optional(),\n fillStyle: fillStyle.optional(),\n opacity: finiteNumber.min(0).max(1).optional(),\n roughness: finiteNumber.min(0).max(3).optional(),\n seed: z.number().int().finite().optional(),\n});\n\nconst dim = finitePositive.max(20000);\nconst labelText = z.string().max(500);\n\nconst rectElement = baseElement.extend({\n type: z.literal(\"rect\"),\n w: dim,\n h: dim,\n label: labelText.optional(),\n roundness: roundness.optional(),\n});\n\nconst ellipseElement = baseElement.extend({\n type: z.literal(\"ellipse\"),\n w: dim,\n h: dim,\n label: labelText.optional(),\n});\n\nconst diamondElement = baseElement.extend({\n type: z.literal(\"diamond\"),\n w: dim,\n h: dim,\n label: labelText.optional(),\n});\n\nconst point2 = z.tuple([finiteNumber, finiteNumber]);\n\nconst lineElement = baseElement.extend({\n type: z.literal(\"line\"),\n to: point2,\n});\n\nconst arrowElement = baseElement.extend({\n type: z.literal(\"arrow\"),\n to: point2,\n label: labelText.optional(),\n headStart: z.boolean().optional(),\n headEnd: z.boolean().default(true),\n});\n\nconst textElement = baseElement.extend({\n type: z.literal(\"text\"),\n text: z.string().max(5000),\n fontSize: finitePositive.max(500).optional(),\n align: align.optional(),\n fontFamily: fontFamily.optional(),\n});\n\nconst freedrawPoint = z.tuple([finiteNumber, finiteNumber, finiteNumber.optional()]);\n\nconst freedrawElement = baseElement.extend({\n type: z.literal(\"freedraw\"),\n points: z.array(freedrawPoint).min(2).max(5000),\n});\n\nexport const whiteboardElement = z.discriminatedUnion(\"type\", [\n rectElement,\n ellipseElement,\n diamondElement,\n lineElement,\n arrowElement,\n textElement,\n freedrawElement,\n]);\n\nexport const whiteboardScene = z.object({\n version: z.literal(1),\n width: dim, // EC-4: clamped 1..20000\n height: dim, // EC-4: clamped 1..20000\n background: z.string().max(64).optional(),\n elements: z.array(whiteboardElement).max(5000),\n});\n\n// Internal \"parsed\" types (defaults applied) — used inside the renderer.\nexport type WhiteboardElement = z.output<typeof whiteboardElement>;\nexport type WhiteboardScene = z.output<typeof whiteboardScene>;\nexport type RectElement = z.output<typeof rectElement>;\nexport type EllipseElement = z.output<typeof ellipseElement>;\nexport type DiamondElement = z.output<typeof diamondElement>;\nexport type LineElement = z.output<typeof lineElement>;\nexport type ArrowElement = z.output<typeof arrowElement>;\nexport type TextElement = z.output<typeof textElement>;\nexport type FreedrawElement = z.output<typeof freedrawElement>;\n\n// Public \"input\" types (defaults optional) — used by consumers passing data.\nexport type WhiteboardElementInput = z.input<typeof whiteboardElement>;\nexport type WhiteboardSceneInput = z.input<typeof whiteboardScene>;\n"
27
+ },
28
+ {
29
+ "path": "components/primitives/whiteboard/validate.ts",
30
+ "type": "registry:ui",
31
+ "target": "components/ui/whiteboard/validate.ts",
32
+ "content": "/**\n * Public validator for `WhiteboardScene` JSON. Wraps Zod and converts errors\n * into a structured shape that an LLM can consume to self-correct.\n *\n * Returns `{ ok: true, scene }` on success or `{ ok: false, errors }` with\n * each error carrying `{ path, message, code, got? }`. See RFC 0001 §4.\n */\nimport { type WhiteboardScene, whiteboardScene } from \"@/components/ui/whiteboard/schema\";\n\nexport interface ValidationError {\n /** Dot-joined Zod path, e.g. \"elements.2.type\" or \"width\". */\n path: string;\n /** Human-readable explanation (passes through Zod's message). */\n message: string;\n /** Zod issue code: `invalid_type`, `too_small`, `invalid_literal`, ... */\n code: string;\n /** Actual value received at `path`. Populated for type mismatches, discriminator mismatches, and any issue where Zod's `received` is absent but the value can be recovered by walking the original input along `path`. */\n got?: unknown;\n}\n\nexport type ValidationResult =\n | { ok: true; scene: WhiteboardScene }\n | { ok: false; errors: ValidationError[] };\n\ninterface ZodLikeIssue {\n path: ReadonlyArray<string | number>;\n message: string;\n code: string;\n received?: unknown;\n}\n\n/** Walk `input` following the issue path and return the offending value. */\nfunction valueAtPath(input: unknown, path: ReadonlyArray<string | number>): unknown {\n let cursor: unknown = input;\n for (const segment of path) {\n if (cursor === null || typeof cursor !== \"object\") return undefined;\n cursor = (cursor as Record<string | number, unknown>)[segment];\n }\n return cursor;\n}\n\nfunction formatIssue(issue: ZodLikeIssue, input: unknown): ValidationError {\n const error: ValidationError = {\n path: issue.path.join(\".\"),\n message: issue.message,\n code: issue.code,\n };\n // Populate `got` from the issue when Zod provides it (invalid_type), or by\n // walking the original input along the path (invalid_union / invalid_value).\n // LLM auto-correction benefits from seeing what was actually emitted.\n if (\"received\" in issue && issue.received !== undefined) {\n error.got = issue.received;\n } else if (issue.path.length > 0) {\n const value = valueAtPath(input, issue.path);\n if (value !== undefined) error.got = value;\n }\n return error;\n}\n\nexport function validateScene(input: unknown): ValidationResult {\n const result = whiteboardScene.safeParse(input);\n if (result.success) {\n return { ok: true, scene: result.data };\n }\n const errors = result.error.issues.map((issue) =>\n formatIssue(issue as unknown as ZodLikeIssue, input),\n );\n return { ok: false, errors };\n}\n"
33
+ },
34
+ {
35
+ "path": "components/primitives/whiteboard/seed.ts",
36
+ "type": "registry:ui",
37
+ "target": "components/ui/whiteboard/seed.ts",
38
+ "content": "/**\n * Deterministic seed derivation for rough.js — see ADR D9.\n *\n * rough.js uses pseudo-randomness to produce the hand-drawn look. Without a\n * stable seed every render shifts the strokes slightly, breaking snapshot\n * tests, causing SSR hydration mismatches, and visible jitter when the parent\n * re-renders. FNV-1a 32-bit gives us a fast, dependency-free hash.\n */\n\nconst FNV_OFFSET_BASIS_32 = 0x811c9dc5;\nconst FNV_PRIME_32 = 0x01000193;\n\n/** FNV-1a 32-bit hash. Returns a signed 32-bit integer. */\nexport function fnv1a32(input: string): number {\n let hash = FNV_OFFSET_BASIS_32;\n for (let i = 0; i < input.length; i++) {\n hash ^= input.charCodeAt(i);\n // Multiply by the FNV prime, but keep within 32-bit unsigned range.\n hash = Math.imul(hash, FNV_PRIME_32);\n }\n // Cast to signed 32-bit via `| 0`.\n return hash | 0;\n}\n\ninterface SeedableShape {\n type: string;\n x: number;\n y: number;\n w?: number;\n h?: number;\n label?: string;\n seed?: number;\n}\n\n/** Return the element's explicit `seed` or derive a stable one from its props. */\nexport function deriveSeed(el: SeedableShape): number {\n if (typeof el.seed === \"number\" && Number.isFinite(el.seed)) {\n return el.seed | 0;\n }\n // Compose a key from the dimensions that visually define the shape. Other\n // fields (colors, opacity) don't change the underlying rough.js geometry.\n const key = `${el.type}|${el.x}|${el.y}|${el.w ?? \"\"}|${el.h ?? \"\"}|${el.label ?? \"\"}`;\n return fnv1a32(key);\n}\n"
39
+ },
40
+ {
41
+ "path": "components/primitives/whiteboard/index.ts",
42
+ "type": "registry:ui",
43
+ "target": "components/ui/whiteboard/index.ts",
44
+ "content": "export { Whiteboard, type WhiteboardData, type WhiteboardProps } from \"@/components/ui/whiteboard/whiteboard\";\nexport { validateScene, type ValidationError, type ValidationResult } from \"@/components/ui/whiteboard/validate\";\n"
45
+ },
46
+ {
47
+ "path": "components/primitives/whiteboard/render/scene.tsx",
48
+ "type": "registry:ui",
49
+ "target": "components/ui/whiteboard/render/scene.tsx",
50
+ "content": "import type { RoughGenerator } from \"roughjs/bin/generator\";\nimport type { WhiteboardElement, WhiteboardScene } from \"@/components/ui/whiteboard/schema\";\nimport { deriveSeed } from \"@/components/ui/whiteboard/seed\";\nimport { renderFreedraw } from \"@/components/ui/whiteboard/render/freedraw\";\nimport { renderArrow, renderLine } from \"@/components/ui/whiteboard/render/line\";\nimport { renderDiamond, renderEllipse, renderRect } from \"@/components/ui/whiteboard/render/shape\";\nimport { renderText } from \"@/components/ui/whiteboard/render/text\";\n\nfunction elementWithSeed<T extends WhiteboardElement>(el: T): T {\n if (typeof el.seed === \"number\") return el;\n return { ...el, seed: deriveSeed(el) } as T;\n}\n\nfunction renderElement(el: WhiteboardElement, gen: RoughGenerator): React.ReactNode {\n switch (el.type) {\n case \"rect\":\n return renderRect(gen, elementWithSeed(el));\n case \"ellipse\":\n return renderEllipse(gen, elementWithSeed(el));\n case \"diamond\":\n return renderDiamond(gen, elementWithSeed(el));\n case \"line\":\n return renderLine(gen, elementWithSeed(el));\n case \"arrow\":\n return renderArrow(gen, elementWithSeed(el));\n case \"text\":\n return renderText(elementWithSeed(el));\n case \"freedraw\":\n return renderFreedraw(elementWithSeed(el));\n }\n}\n\nexport function renderScene(scene: WhiteboardScene, gen: RoughGenerator): React.ReactNode {\n return (\n <>\n {scene.elements.map((el, i) => (\n <g\n key={el.id ?? `__idx-${i}`}\n data-element-id={el.id ?? String(i)}\n data-element-type={el.type}\n >\n {renderElement(el, gen)}\n </g>\n ))}\n </>\n );\n}\n"
51
+ },
52
+ {
53
+ "path": "components/primitives/whiteboard/render/shape.tsx",
54
+ "type": "registry:ui",
55
+ "target": "components/ui/whiteboard/render/shape.tsx",
56
+ "content": "import type { RoughGenerator } from \"roughjs/bin/generator\";\nimport type { DiamondElement, EllipseElement, RectElement } from \"@/components/ui/whiteboard/schema\";\nimport { toRoughPaths } from \"@/components/ui/whiteboard/render/rough-paths\";\nimport { buildOptions, strokeDashArray } from \"@/components/ui/whiteboard/render/style\";\n\ninterface LabelProps {\n cx: number;\n cy: number;\n label: string;\n stroke?: string;\n}\n\nfunction Label({ cx, cy, label, stroke }: LabelProps) {\n return (\n <text\n x={cx}\n y={cy}\n textAnchor=\"middle\"\n dominantBaseline=\"central\"\n fontSize={16}\n fontFamily=\"ui-sans-serif, system-ui, sans-serif\"\n fill={stroke ?? \"currentColor\"}\n style={{ pointerEvents: \"none\" }}\n >\n {label}\n </text>\n );\n}\n\nfunction pathsToReactNodes(\n paths: ReturnType<typeof toRoughPaths>,\n keyPrefix: string,\n outlineStroke: string,\n dashArray?: string,\n): React.ReactNode[] {\n return paths.map((p, i) => {\n // Apply stroke-dasharray only to paths whose stroke matches the outline\n // color — this avoids dashing hachure/cross-hatch fill lines (which are\n // stroked in the fill color, not the outline color) when a shape mixes\n // fillStyle with strokeStyle.\n const isOutline = p.stroke === outlineStroke;\n return (\n <path\n // biome-ignore lint/suspicious/noArrayIndexKey: rough.js path order is stable for a given (geometry, seed) tuple — index is the most precise key.\n key={`${keyPrefix}-${i}`}\n d={p.d}\n stroke={p.stroke}\n strokeWidth={p.strokeWidth}\n fill={p.fill ?? \"none\"}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeDasharray={isOutline ? dashArray : undefined}\n />\n );\n });\n}\n\nexport function renderRect(gen: RoughGenerator, el: RectElement): React.ReactNode {\n const opts = buildOptions(el, el.seed ?? 0);\n const drawable = gen.rectangle(el.x, el.y, el.w, el.h, opts);\n const paths = toRoughPaths(gen, drawable);\n const dash = strokeDashArray(el.strokeStyle, el.strokeWidth ?? 1.5);\n return (\n <g opacity={el.opacity ?? 1}>\n {pathsToReactNodes(paths, \"rect\", opts.stroke as string, dash)}\n {el.label ? (\n <Label cx={el.x + el.w / 2} cy={el.y + el.h / 2} label={el.label} stroke={el.stroke} />\n ) : null}\n </g>\n );\n}\n\nexport function renderEllipse(gen: RoughGenerator, el: EllipseElement): React.ReactNode {\n const opts = buildOptions(el, el.seed ?? 0);\n // rough.js ellipse takes center + width + height.\n const drawable = gen.ellipse(el.x + el.w / 2, el.y + el.h / 2, el.w, el.h, opts);\n const paths = toRoughPaths(gen, drawable);\n const dash = strokeDashArray(el.strokeStyle, el.strokeWidth ?? 1.5);\n return (\n <g opacity={el.opacity ?? 1}>\n {pathsToReactNodes(paths, \"ellipse\", opts.stroke as string, dash)}\n {el.label ? (\n <Label cx={el.x + el.w / 2} cy={el.y + el.h / 2} label={el.label} stroke={el.stroke} />\n ) : null}\n </g>\n );\n}\n\nexport function renderDiamond(gen: RoughGenerator, el: DiamondElement): React.ReactNode {\n const opts = buildOptions(el, el.seed ?? 0);\n const cx = el.x + el.w / 2;\n const cy = el.y + el.h / 2;\n const points: [number, number][] = [\n [cx, el.y], // top\n [el.x + el.w, cy], // right\n [cx, el.y + el.h], // bottom\n [el.x, cy], // left\n ];\n const drawable = gen.polygon(points, opts);\n const paths = toRoughPaths(gen, drawable);\n const dash = strokeDashArray(el.strokeStyle, el.strokeWidth ?? 1.5);\n return (\n <g opacity={el.opacity ?? 1}>\n {pathsToReactNodes(paths, \"diamond\", opts.stroke as string, dash)}\n {el.label ? <Label cx={cx} cy={cy} label={el.label} stroke={el.stroke} /> : null}\n </g>\n );\n}\n"
57
+ },
58
+ {
59
+ "path": "components/primitives/whiteboard/render/line.tsx",
60
+ "type": "registry:ui",
61
+ "target": "components/ui/whiteboard/render/line.tsx",
62
+ "content": "import type { RoughGenerator } from \"roughjs/bin/generator\";\nimport type { ArrowElement, LineElement } from \"@/components/ui/whiteboard/schema\";\nimport { toRoughPaths } from \"@/components/ui/whiteboard/render/rough-paths\";\nimport { buildOptions, strokeDashArray } from \"@/components/ui/whiteboard/render/style\";\n\nconst HEAD_BASE_PX = 12;\nconst HEAD_ANGLE_RAD = Math.PI / 7; // ~25°\n\ninterface HeadGeom {\n apexX: number;\n apexY: number;\n leftX: number;\n leftY: number;\n rightX: number;\n rightY: number;\n}\n\nfunction arrowHeadGeometry(\n fromX: number,\n fromY: number,\n toX: number,\n toY: number,\n strokeWidth: number,\n): HeadGeom | null {\n const dx = toX - fromX;\n const dy = toY - fromY;\n const dist = Math.sqrt(dx * dx + dy * dy);\n if (dist === 0) return null;\n const angle = Math.atan2(dy, dx);\n // EC-7: clamp head length so it never exceeds 40% of the segment.\n const headLen = Math.min(HEAD_BASE_PX + strokeWidth * 2, dist * 0.4);\n const leftX = toX - headLen * Math.cos(angle - HEAD_ANGLE_RAD);\n const leftY = toY - headLen * Math.sin(angle - HEAD_ANGLE_RAD);\n const rightX = toX - headLen * Math.cos(angle + HEAD_ANGLE_RAD);\n const rightY = toY - headLen * Math.sin(angle + HEAD_ANGLE_RAD);\n return { apexX: toX, apexY: toY, leftX, leftY, rightX, rightY };\n}\n\nfunction lineBody(\n gen: RoughGenerator,\n el: LineElement | ArrowElement,\n seed: number,\n): React.ReactNode[] {\n const opts = buildOptions(el, seed);\n const drawable = gen.line(el.x, el.y, el.to[0], el.to[1], opts);\n const dash = strokeDashArray(el.strokeStyle, el.strokeWidth ?? 1.5);\n return toRoughPaths(gen, drawable).map((p, i) => (\n <path\n // biome-ignore lint/suspicious/noArrayIndexKey: stable rough.js path order\n key={`body-${i}`}\n d={p.d}\n stroke={p.stroke}\n strokeWidth={p.strokeWidth}\n fill=\"none\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeDasharray={dash}\n />\n ));\n}\n\nfunction arrowHead(\n gen: RoughGenerator,\n fromX: number,\n fromY: number,\n toX: number,\n toY: number,\n seed: number,\n el: ArrowElement,\n side: \"start\" | \"end\",\n): React.ReactNode[] {\n const geom = arrowHeadGeometry(fromX, fromY, toX, toY, el.strokeWidth ?? 1.5);\n if (!geom) return [];\n const opts = buildOptions(el, seed);\n // Two short rough-drawn segments forming the V.\n const leftDrawable = gen.line(geom.apexX, geom.apexY, geom.leftX, geom.leftY, opts);\n const rightDrawable = gen.line(geom.apexX, geom.apexY, geom.rightX, geom.rightY, opts);\n const leftPaths = toRoughPaths(gen, leftDrawable);\n const rightPaths = toRoughPaths(gen, rightDrawable);\n const nodes: React.ReactNode[] = [];\n for (const [i, p] of leftPaths.entries()) {\n nodes.push(\n <path\n key={`${side}-l-${i}`}\n d={p.d}\n stroke={p.stroke}\n strokeWidth={p.strokeWidth}\n fill=\"none\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n data-line-part=\"arrowhead\"\n />,\n );\n }\n for (const [i, p] of rightPaths.entries()) {\n nodes.push(\n <path\n key={`${side}-r-${i}`}\n d={p.d}\n stroke={p.stroke}\n strokeWidth={p.strokeWidth}\n fill=\"none\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n data-line-part=\"arrowhead\"\n />,\n );\n }\n return nodes;\n}\n\nexport function renderLine(gen: RoughGenerator, el: LineElement): React.ReactNode {\n return <g opacity={el.opacity ?? 1}>{lineBody(gen, el, el.seed ?? 0)}</g>;\n}\n\nexport function renderArrow(gen: RoughGenerator, el: ArrowElement): React.ReactNode {\n const seed = el.seed ?? 0;\n const nodes: React.ReactNode[] = [...lineBody(gen, el, seed)];\n if (el.headEnd !== false) {\n nodes.push(...arrowHead(gen, el.x, el.y, el.to[0], el.to[1], seed + 1, el, \"end\"));\n }\n if (el.headStart) {\n nodes.push(...arrowHead(gen, el.to[0], el.to[1], el.x, el.y, seed + 2, el, \"start\"));\n }\n let label: React.ReactNode = null;\n if (el.label) {\n const midX = (el.x + el.to[0]) / 2;\n const midY = (el.y + el.to[1]) / 2;\n // Offset perpendicular to the line so label doesn't sit on the stroke.\n const dx = el.to[0] - el.x;\n const dy = el.to[1] - el.y;\n const dist = Math.sqrt(dx * dx + dy * dy) || 1;\n const nx = -dy / dist;\n const ny = dx / dist;\n const offset = 12;\n label = (\n <text\n x={midX + nx * offset}\n y={midY + ny * offset}\n textAnchor=\"middle\"\n dominantBaseline=\"central\"\n fontSize={14}\n fontFamily=\"ui-sans-serif, system-ui, sans-serif\"\n fill={el.stroke ?? \"currentColor\"}\n style={{ pointerEvents: \"none\" }}\n >\n {el.label}\n </text>\n );\n }\n return (\n <g opacity={el.opacity ?? 1}>\n {nodes}\n {label}\n </g>\n );\n}\n"
63
+ },
64
+ {
65
+ "path": "components/primitives/whiteboard/render/freedraw.tsx",
66
+ "type": "registry:ui",
67
+ "target": "components/ui/whiteboard/render/freedraw.tsx",
68
+ "content": "import { getStroke } from \"perfect-freehand\";\nimport type { FreedrawElement } from \"@/components/ui/whiteboard/schema\";\n\nconst DEFAULT_STROKE_OPTIONS = {\n size: 8,\n thinning: 0.5,\n smoothing: 0.5,\n streamline: 0.5,\n};\n\nfunction fmt(n: number | undefined): string {\n return typeof n === \"number\" && Number.isFinite(n) ? n.toFixed(2) : \"0\";\n}\n\nfunction svgPathFromStroke(points: number[][]): string {\n if (points.length === 0) return \"\";\n const first = points[0] ?? [];\n let d = `M ${fmt(first[0])} ${fmt(first[1])}`;\n for (let i = 1; i < points.length; i++) {\n const p = points[i] ?? [];\n d += ` L ${fmt(p[0])} ${fmt(p[1])}`;\n }\n d += \" Z\";\n return d;\n}\n\nexport function renderFreedraw(el: FreedrawElement): React.ReactNode {\n const size = (el.strokeWidth ?? 1.5) * 5;\n const inputPoints = el.points.map(([x, y, pressure]) => {\n const tx = el.x + x;\n const ty = el.y + y;\n return pressure === undefined ? [tx, ty] : [tx, ty, pressure];\n });\n const stroke = getStroke(inputPoints, {\n ...DEFAULT_STROKE_OPTIONS,\n size,\n });\n const d = svgPathFromStroke(stroke as number[][]);\n return (\n <g opacity={el.opacity ?? 1}>\n <path d={d} fill={el.stroke ?? \"currentColor\"} stroke=\"none\" />\n </g>\n );\n}\n"
69
+ },
70
+ {
71
+ "path": "components/primitives/whiteboard/render/text.tsx",
72
+ "type": "registry:ui",
73
+ "target": "components/ui/whiteboard/render/text.tsx",
74
+ "content": "import type { TextElement } from \"@/components/ui/whiteboard/schema\";\n\nconst FONT_STACKS: Record<NonNullable<TextElement[\"fontFamily\"]>, string> = {\n sans: \"ui-sans-serif, system-ui, sans-serif\",\n serif: \"ui-serif, Georgia, serif\",\n mono: \"ui-monospace, SFMono-Regular, Menlo, monospace\",\n hand: '\"Virgil\", \"Caveat\", \"Comic Sans MS\", cursive',\n};\n\nfunction textAnchor(align: TextElement[\"align\"]): \"start\" | \"middle\" | \"end\" {\n if (align === \"center\") return \"middle\";\n if (align === \"right\") return \"end\";\n return \"start\";\n}\n\nexport function renderText(el: TextElement): React.ReactNode {\n const fontSize = el.fontSize ?? 18;\n const fontFamily = FONT_STACKS[el.fontFamily ?? \"hand\"];\n const anchor = textAnchor(el.align);\n const lines = el.text.split(\"\\n\");\n // dy: first tspan at 0, subsequent at fontSize * 1.2 line-height.\n return (\n <g opacity={el.opacity ?? 1}>\n <text\n x={el.x}\n y={el.y}\n textAnchor={anchor}\n dominantBaseline=\"hanging\"\n fontSize={fontSize}\n fontFamily={fontFamily}\n fill={el.stroke ?? \"currentColor\"}\n >\n {lines.map((line, i) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: text is split deterministically on '\\n' so the line index is the most stable key.\n <tspan key={i} x={el.x} dy={i === 0 ? 0 : fontSize * 1.2}>\n {line}\n </tspan>\n ))}\n </text>\n </g>\n );\n}\n"
75
+ },
76
+ {
77
+ "path": "components/primitives/whiteboard/render/rough-paths.ts",
78
+ "type": "registry:ui",
79
+ "target": "components/ui/whiteboard/render/rough-paths.ts",
80
+ "content": "/**\n * Thin adapter over `roughjs` — converts a `Drawable` into the array of\n * `<path>` descriptors we render in SVG. The actual `rough.generator()`\n * instance is created once per scene and reused across elements.\n */\nimport type { Drawable, Options } from \"roughjs/bin/core\";\nimport type { RoughGenerator } from \"roughjs/bin/generator\";\n\nexport interface RoughPath {\n d: string;\n stroke: string;\n strokeWidth: number;\n fill?: string;\n}\n\nexport type RoughOptions = Options;\n\n/** Convert a rough.js Drawable into renderable SVG path descriptors. */\nexport function toRoughPaths(generator: RoughGenerator, drawable: Drawable): RoughPath[] {\n const paths = generator.toPaths(drawable);\n return paths.map((p) => ({\n d: p.d,\n stroke: p.stroke,\n strokeWidth: p.strokeWidth,\n fill: p.fill,\n }));\n}\n"
81
+ },
82
+ {
83
+ "path": "components/primitives/whiteboard/render/style.ts",
84
+ "type": "registry:ui",
85
+ "target": "components/ui/whiteboard/render/style.ts",
86
+ "content": "/**\n * Maps a `WhiteboardElement` style props onto rough.js `Options`.\n */\nimport type { Options } from \"roughjs/bin/core\";\n\ninterface StyleSource {\n stroke?: string;\n strokeWidth?: number;\n strokeStyle?: \"solid\" | \"dashed\" | \"dotted\";\n fill?: string;\n fillStyle?: \"hachure\" | \"solid\" | \"cross-hatch\" | \"zigzag\";\n opacity?: number;\n roughness?: number;\n}\n\nconst DEFAULT_STROKE = \"currentColor\";\n\nexport function buildOptions(src: StyleSource, seed: number): Options {\n const o: Options = {\n seed,\n stroke: src.stroke ?? DEFAULT_STROKE,\n strokeWidth: src.strokeWidth ?? 1.5,\n roughness: src.roughness ?? 1.2,\n };\n if (src.strokeStyle === \"dashed\") o.strokeLineDash = [10, 6];\n else if (src.strokeStyle === \"dotted\") o.strokeLineDash = [2, 4];\n if (src.fill) o.fill = src.fill;\n if (src.fillStyle) o.fillStyle = src.fillStyle;\n return o;\n}\n\n/**\n * SVG `stroke-dasharray` value for a logical strokeStyle. rough.js's\n * `RoughGenerator.toPaths()` does NOT propagate `strokeLineDash` into the\n * returned `PathInfo` — only the canvas / direct-SVG backends apply it. We\n * must set the attribute ourselves on the rendered `<path>`. Returns\n * `undefined` for `solid` and any unknown value.\n */\nexport function strokeDashArray(\n strokeStyle: StyleSource[\"strokeStyle\"],\n strokeWidth: number,\n): string | undefined {\n if (strokeStyle === \"dashed\") {\n const dash = Math.max(8, strokeWidth * 6);\n const gap = Math.max(5, strokeWidth * 4);\n return `${dash} ${gap}`;\n }\n if (strokeStyle === \"dotted\") {\n // 1×strokeWidth dot, ~2×strokeWidth gap. Round end caps make these look\n // like dots instead of squares.\n return `${strokeWidth} ${strokeWidth * 2.5}`;\n }\n return undefined;\n}\n\nexport { DEFAULT_STROKE };\n"
87
+ },
88
+ {
89
+ "path": "components/primitives/whiteboard/viewport/use-pointer-pan.ts",
90
+ "type": "registry:ui",
91
+ "target": "components/ui/whiteboard/viewport/use-pointer-pan.ts",
92
+ "content": "import { useCallback, useEffect, useRef } from \"react\";\nimport type { ViewportControls } from \"@/components/ui/whiteboard/viewport/use-viewport\";\n\ninterface DragState {\n pointerId: number;\n lastX: number;\n lastY: number;\n}\n\n/** Attaches pan + zoom listeners to an SVG element. */\nexport function usePointerPan(\n ref: React.RefObject<SVGSVGElement | null>,\n viewport: ViewportControls,\n size: { width: number; height: number },\n): {\n onPointerDown: (e: React.PointerEvent<SVGSVGElement>) => void;\n onPointerMove: (e: React.PointerEvent<SVGSVGElement>) => void;\n onPointerUp: (e: React.PointerEvent<SVGSVGElement>) => void;\n onPointerCancel: (e: React.PointerEvent<SVGSVGElement>) => void;\n} {\n const dragRef = useRef<DragState | null>(null);\n const spaceHeldRef = useRef(false);\n\n // EC-2: wheel events on React's JSX onWheel are passive — `preventDefault`\n // does nothing, which causes the page to scroll while the user tries to\n // zoom. We must attach the wheel handler imperatively with `{passive:false}`.\n useEffect(() => {\n const el = ref.current;\n if (!el) return;\n const handler = (e: WheelEvent) => {\n e.preventDefault();\n const rect = el.getBoundingClientRect();\n const localX = e.clientX - rect.left;\n const localY = e.clientY - rect.top;\n // Map screen-px → viewBox coords by ratio of rect to size.\n const scaleX = size.width / rect.width;\n const scaleY = size.height / rect.height;\n viewport.zoomAt(localX * scaleX, localY * scaleY, -e.deltaY * 0.001, size);\n };\n el.addEventListener(\"wheel\", handler, { passive: false });\n return () => el.removeEventListener(\"wheel\", handler);\n }, [ref, viewport, size]);\n\n // Track Space key for pan-with-any-button (Excalidraw \"hand\" mode).\n useEffect(() => {\n const down = (e: KeyboardEvent) => {\n if (e.code === \"Space\") spaceHeldRef.current = true;\n };\n const up = (e: KeyboardEvent) => {\n if (e.code === \"Space\") spaceHeldRef.current = false;\n };\n window.addEventListener(\"keydown\", down);\n window.addEventListener(\"keyup\", up);\n return () => {\n window.removeEventListener(\"keydown\", down);\n window.removeEventListener(\"keyup\", up);\n };\n }, []);\n\n const onPointerDown = useCallback((e: React.PointerEvent<SVGSVGElement>) => {\n // EC-10: only start drag when the down event is on our SVG, not on a\n // child rendered via portal/etc. event.currentTarget guarantees this.\n if (e.target instanceof Element && !e.currentTarget.contains(e.target)) return;\n // Left button (0) or middle (1) or Space-held pan.\n const isMouseButton = e.button === 0 || e.button === 1;\n if (!isMouseButton && e.pointerType === \"mouse\") return;\n if (e.pointerType === \"mouse\" && e.button !== 1 && !spaceHeldRef.current && e.button !== 0) {\n return;\n }\n dragRef.current = { pointerId: e.pointerId, lastX: e.clientX, lastY: e.clientY };\n e.currentTarget.setPointerCapture(e.pointerId);\n }, []);\n\n const onPointerMove = useCallback(\n (e: React.PointerEvent<SVGSVGElement>) => {\n const drag = dragRef.current;\n if (!drag || drag.pointerId !== e.pointerId) return;\n const dx = e.clientX - drag.lastX;\n const dy = e.clientY - drag.lastY;\n drag.lastX = e.clientX;\n drag.lastY = e.clientY;\n const el = ref.current;\n if (!el) return;\n const rect = el.getBoundingClientRect();\n const scaleX = size.width / rect.width;\n const scaleY = size.height / rect.height;\n viewport.pan(dx * scaleX, dy * scaleY);\n },\n [ref, viewport, size],\n );\n\n const releaseDrag = useCallback((e: React.PointerEvent<SVGSVGElement>) => {\n const drag = dragRef.current;\n if (!drag || drag.pointerId !== e.pointerId) return;\n try {\n e.currentTarget.releasePointerCapture(e.pointerId);\n } catch {\n // Already released — ignore.\n }\n dragRef.current = null;\n }, []);\n\n return {\n onPointerDown,\n onPointerMove,\n onPointerUp: releaseDrag,\n onPointerCancel: releaseDrag,\n };\n}\n"
93
+ },
94
+ {
95
+ "path": "components/primitives/whiteboard/viewport/use-viewport.ts",
96
+ "type": "registry:ui",
97
+ "target": "components/ui/whiteboard/viewport/use-viewport.ts",
98
+ "content": "/**\n * Viewport state and helpers — pan via (x, y) and zoom via a scalar.\n *\n * Coordinate system:\n * - World space: where elements are defined (the JSON `x`, `y` etc.).\n * - Screen space: the rendered SVG bounding box on the page.\n * - viewBox: `${x} ${y} ${width/zoom} ${height/zoom}`. Increasing zoom\n * SHRINKS the viewBox dimensions, magnifying the rendered content.\n *\n * `zoomAt(screenX, screenY, delta, viewportSize)` keeps the world point under\n * the cursor stable while zooming (see test for invariant).\n */\nimport { useCallback, useMemo, useState } from \"react\";\n\nexport const MIN_ZOOM = 0.1;\nexport const MAX_ZOOM = 8;\n\nexport interface ViewportState {\n x: number;\n y: number;\n zoom: number;\n}\n\nexport interface ViewportSize {\n width: number;\n height: number;\n}\n\nexport interface ViewportBounds {\n minX: number;\n minY: number;\n maxX: number;\n maxY: number;\n}\n\nexport interface UseViewportOptions {\n width: number;\n height: number;\n initialZoom?: number;\n initialCenter?: [number, number];\n}\n\nexport interface ViewportControls {\n state: ViewportState;\n pan: (dx: number, dy: number) => void;\n setZoom: (zoom: number) => void;\n zoomAt: (screenX: number, screenY: number, delta: number, size: ViewportSize) => void;\n reset: () => void;\n fitTo: (bounds: ViewportBounds, size: ViewportSize) => void;\n viewBox: (size: ViewportSize) => string;\n}\n\nfunction clampZoom(z: number): number {\n if (!Number.isFinite(z)) return 1;\n return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));\n}\n\nexport function useViewport(opts: UseViewportOptions): ViewportControls {\n const initial: ViewportState = useMemo(() => {\n const zoom = clampZoom(opts.initialZoom ?? 1);\n if (opts.initialCenter) {\n // Position viewBox so initialCenter sits at the visual center.\n const [cx, cy] = opts.initialCenter;\n return {\n x: cx - opts.width / (2 * zoom),\n y: cy - opts.height / (2 * zoom),\n zoom,\n };\n }\n return { x: 0, y: 0, zoom };\n }, [opts.initialCenter, opts.initialZoom, opts.width, opts.height]);\n\n const [state, setState] = useState<ViewportState>(initial);\n\n const pan = useCallback((dx: number, dy: number) => {\n setState((prev) => ({ ...prev, x: prev.x - dx / prev.zoom, y: prev.y - dy / prev.zoom }));\n }, []);\n\n const setZoom = useCallback((zoom: number) => {\n setState((prev) => ({ ...prev, zoom: clampZoom(zoom) }));\n }, []);\n\n const zoomAt = useCallback(\n (screenX: number, screenY: number, delta: number, _size: ViewportSize) => {\n setState((prev) => {\n const oldZoom = prev.zoom;\n const newZoom = clampZoom(prev.zoom * Math.exp(delta));\n if (newZoom === oldZoom) return prev;\n // World coordinate currently under cursor.\n const worldX = prev.x + screenX / oldZoom;\n const worldY = prev.y + screenY / oldZoom;\n // After zoom, we want the same screen point to map to the same world point.\n // viewBox.x + (screenX / newZoom) === worldX → x = worldX - screenX/newZoom.\n return {\n x: worldX - screenX / newZoom,\n y: worldY - screenY / newZoom,\n zoom: newZoom,\n };\n });\n },\n [],\n );\n\n const reset = useCallback(() => setState(initial), [initial]);\n\n const fitTo = useCallback((bounds: ViewportBounds, size: ViewportSize) => {\n const bboxWidth = Math.max(1, bounds.maxX - bounds.minX);\n const bboxHeight = Math.max(1, bounds.maxY - bounds.minY);\n const zoomX = size.width / bboxWidth;\n const zoomY = size.height / bboxHeight;\n const zoom = clampZoom(Math.min(zoomX, zoomY));\n const cx = (bounds.minX + bounds.maxX) / 2;\n const cy = (bounds.minY + bounds.maxY) / 2;\n setState({\n x: cx - size.width / (2 * zoom),\n y: cy - size.height / (2 * zoom),\n zoom,\n });\n }, []);\n\n const viewBox = useCallback(\n (size: ViewportSize) =>\n `${state.x} ${state.y} ${size.width / state.zoom} ${size.height / state.zoom}`,\n [state],\n );\n\n return { state, pan, setZoom, zoomAt, reset, fitTo, viewBox };\n}\n"
99
+ }
100
+ ]
101
+ }