@theokit/ui 0.13.0 → 0.14.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 (48) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +5 -5
  3. package/dist/chunk-DMVWZWU5.js +62 -0
  4. package/dist/chunk-DMVWZWU5.js.map +1 -0
  5. package/dist/{chunk-M6JIC5PU.js → chunk-ETMMPUFO.js} +3 -3
  6. package/dist/{chunk-M6JIC5PU.js.map → chunk-ETMMPUFO.js.map} +1 -1
  7. package/dist/{chunk-VT7VSYH5.js → chunk-KE773ODY.js} +4 -4
  8. package/dist/{chunk-VT7VSYH5.js.map → chunk-KE773ODY.js.map} +1 -1
  9. package/dist/{chunk-BYZ6OFH4.js → chunk-KPYORAP6.js} +6 -6
  10. package/dist/chunk-KPYORAP6.js.map +1 -0
  11. package/dist/chunk-UCGROAS4.js +61 -0
  12. package/dist/chunk-UCGROAS4.js.map +1 -0
  13. package/dist/{chunk-LKRNUSKZ.js → chunk-W6KORCLX.js} +4 -4
  14. package/dist/chunk-W6KORCLX.js.map +1 -0
  15. package/dist/components.css +1 -1
  16. package/dist/composites/metric-card/index.js +5 -0
  17. package/dist/composites/metric-card/index.js.map +1 -0
  18. package/dist/composites/stability-bundle-viewer/index.js +1 -1
  19. package/dist/composites/status-indicator/index.js +4 -0
  20. package/dist/composites/status-indicator/index.js.map +1 -0
  21. package/dist/index.d.ts +165 -40
  22. package/dist/index.js +863 -612
  23. package/dist/index.js.map +1 -1
  24. package/dist/{plugin-D5xmXqYb.d.ts → plugin-Atb0VKtr.d.ts} +1 -1
  25. package/dist/preset-v3-legacy.js +1 -1
  26. package/dist/preset-v3-legacy.js.map +1 -1
  27. package/dist/primitives/gateway-status-indicator/index.js +1 -1
  28. package/dist/primitives/run-status-pill/index.js +1 -1
  29. package/dist/primitives/update-banner/index.js +1 -1
  30. package/dist/slide/index.d.ts +2 -2
  31. package/dist/slide/plugins/emoji/index.d.ts +1 -1
  32. package/dist/slide/plugins/math/index.d.ts +1 -1
  33. package/dist/slide/plugins/mermaid/index.d.ts +1 -1
  34. package/dist/slide/plugins/shiki/index.d.ts +1 -1
  35. package/dist/slide-deck/index.d.ts +1 -1
  36. package/dist/tokens-v4.css +77 -41
  37. package/dist/tokens.css +158 -73
  38. package/dist/whiteboard/index.d.ts +2 -2
  39. package/package.json +18 -2
  40. package/registry/index.json +12 -0
  41. package/registry/r/metric-card.json +23 -0
  42. package/registry/r/safe-href.json +1 -1
  43. package/registry/r/status-indicator.json +20 -0
  44. package/registry/r/tailwind-preset.json +1 -1
  45. package/registry/r/theme-provider.json +6 -6
  46. package/registry/r/tokens.json +1 -1
  47. package/dist/chunk-BYZ6OFH4.js.map +0 -1
  48. package/dist/chunk-LKRNUSKZ.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theokit/ui",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Theo UI — framework-agnostic React component library with the Violet Forge design system. Focused on AI-agent interfaces, cloud dashboards, and developer-tooling surfaces.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -285,6 +285,10 @@
285
285
  "types": "./dist/index.d.ts",
286
286
  "import": "./dist/primitives/mention-menu/index.js"
287
287
  },
288
+ "./metric-card": {
289
+ "types": "./dist/index.d.ts",
290
+ "import": "./dist/composites/metric-card/index.js"
291
+ },
288
292
  "./metrics-panel": {
289
293
  "types": "./dist/index.d.ts",
290
294
  "import": "./dist/primitives/metrics-panel/index.js"
@@ -437,6 +441,10 @@
437
441
  "types": "./dist/index.d.ts",
438
442
  "import": "./dist/primitives/status-dot/index.js"
439
443
  },
444
+ "./status-indicator": {
445
+ "types": "./dist/index.d.ts",
446
+ "import": "./dist/composites/status-indicator/index.js"
447
+ },
440
448
  "./steps-rail": {
441
449
  "types": "./dist/index.d.ts",
442
450
  "import": "./dist/primitives/steps-rail/index.js"
@@ -606,6 +614,10 @@
606
614
  "quality:bundle": "tsx scripts/validate-bundle-size.ts",
607
615
  "quality:bundle:update": "tsx scripts/validate-bundle-size.ts --update",
608
616
  "quality:a11y": "vitest run src/test/ladle-axe.test.tsx",
617
+ "quality:visual": "playwright test",
618
+ "quality:visual:update": "playwright test --update-snapshots",
619
+ "quality:contrast": "tsx scripts/validate-contrast.ts",
620
+ "quality:contrast:update": "tsx scripts/validate-contrast.ts --update",
609
621
  "dogfood:whiteboard": "tsx scripts/dogfood-whiteboard.ts",
610
622
  "dogfood:slide": "tsx scripts/dogfood-slide.ts",
611
623
  "dogfood:slide-deck": "tsx scripts/dogfood-slide-deck.ts",
@@ -613,7 +625,7 @@
613
625
  "dogfood:v4-zero-config": "tsx scripts/dogfood-v4-zero-config.ts",
614
626
  "dogfood:v4-real-build": "bash scripts/dogfood-v4-real-build.sh",
615
627
  "dogfood:precompiled-utilities": "tsx scripts/dogfood-precompiled-utilities.ts",
616
- "quality:gates": "pnpm format:check && pnpm lint:ci && pnpm typecheck && pnpm quality:knip && pnpm test && pnpm build && pnpm quality:publint && pnpm registry:build && pnpm registry:validate && pnpm quality:structure && pnpm quality:bundle && pnpm quality:a11y && pnpm ladle:build && pnpm dogfood:whiteboard && pnpm dogfood:slide && pnpm dogfood:slide-deck && pnpm dogfood:slide-rich && pnpm dogfood:v4-zero-config && pnpm dogfood:precompiled-utilities",
628
+ "quality:gates": "pnpm format:check && pnpm lint:ci && pnpm typecheck && pnpm quality:knip && pnpm test && pnpm build && pnpm quality:publint && pnpm registry:build && pnpm registry:validate && pnpm quality:structure && pnpm quality:bundle && pnpm quality:a11y && pnpm quality:visual && pnpm ladle:build && pnpm dogfood:whiteboard && pnpm dogfood:slide && pnpm dogfood:slide-deck && pnpm dogfood:slide-rich && pnpm dogfood:v4-zero-config && pnpm dogfood:precompiled-utilities",
617
629
  "quality:gates:fast": "pnpm format:check && pnpm lint:ci && pnpm typecheck && pnpm quality:knip && pnpm registry:build && pnpm registry:validate && pnpm quality:structure",
618
630
  "quality:knip": "knip",
619
631
  "quality:knip:fix": "knip --fix",
@@ -726,17 +738,20 @@
726
738
  "lucide-react": "^0.471.0",
727
739
  "tailwind-merge": "^2.5.5",
728
740
  "tailwindcss-animate": "^1.0.7",
741
+ "valibot": "^0.42.1",
729
742
  "zod": "^4.4.3"
730
743
  },
731
744
  "devDependencies": {
732
745
  "@arethetypeswrong/cli": "^0.18.2",
733
746
  "@biomejs/biome": "^1.9.4",
734
747
  "@ladle/react": "^4.1.2",
748
+ "@playwright/test": "^1.60.0",
735
749
  "@tailwindcss/cli": "^4.3.0",
736
750
  "@testing-library/dom": "^10.4.0",
737
751
  "@testing-library/jest-dom": "^6.6.3",
738
752
  "@testing-library/react": "^16.1.0",
739
753
  "@testing-library/user-event": "^14.5.2",
754
+ "@types/culori": "^4.0.1",
740
755
  "@types/hast": "^3.0.4",
741
756
  "@types/mdast": "^4.0.4",
742
757
  "@types/node": "^22.10.5",
@@ -745,6 +760,7 @@
745
760
  "@vitejs/plugin-react": "^4.7.0",
746
761
  "@vitest/coverage-v8": "2.1.9",
747
762
  "autoprefixer": "^10.4.20",
763
+ "culori": "^4.0.2",
748
764
  "geist": "^1.7.0",
749
765
  "happy-dom": "^20.9.0",
750
766
  "hast-util-from-html": "^2.0.3",
@@ -408,6 +408,12 @@
408
408
  "title": "MentionMenu",
409
409
  "description": "Keyboard-navigable popover for slash-command / @file / #memory"
410
410
  },
411
+ {
412
+ "name": "metric-card",
413
+ "type": "registry:ui",
414
+ "title": "MetricCard",
415
+ "description": "Dashboard metric tile composite — title + value + delta with trend semantics. Supports invertTrend for cost/churn metrics (EC-17)."
416
+ },
411
417
  {
412
418
  "name": "metrics-panel",
413
419
  "type": "registry:block",
@@ -684,6 +690,12 @@
684
690
  "title": "StatusDot",
685
691
  "description": "Semantic status indicator (small colored circle + optional label). Five status kinds: live (success), building (warning, auto-pulses), failed (destructive), idle (muted), warning (warning, static). Three sizes (xs 6px / sm 8px default / md 10px). When neither label nor aria-label is provided, auto-applies aria-label=status and emits a dev warning."
686
692
  },
693
+ {
694
+ "name": "status-indicator",
695
+ "type": "registry:ui",
696
+ "title": "StatusIndicator",
697
+ "description": "Operational state indicator (online/offline/degraded/info) consuming status-* semantic tokens (ADR-0007)."
698
+ },
687
699
  {
688
700
  "name": "steps-rail",
689
701
  "type": "registry:ui",
@@ -0,0 +1,23 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "metric-card",
4
+ "type": "registry:ui",
5
+ "title": "MetricCard",
6
+ "description": "Dashboard metric tile composite — title + value + delta with trend semantics. Supports invertTrend for cost/churn metrics (EC-17).",
7
+ "dependencies": [
8
+ "lucide-react"
9
+ ],
10
+ "registryDependencies": [
11
+ "https://usetheodev.github.io/theo-ui/r/card.json",
12
+ "https://usetheodev.github.io/theo-ui/r/cn.json",
13
+ "https://usetheodev.github.io/theo-ui/r/tailwind-preset.json"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "components/composites/metric-card/metric-card.tsx",
18
+ "type": "registry:ui",
19
+ "target": "components/ui/metric-card.tsx",
20
+ "content": "import { Minus, TrendingDown, TrendingUp } from \"lucide-react\";\nimport { type HTMLAttributes, type ReactNode, forwardRef } from \"react\";\n\nimport { cn } from \"@/lib/cn\";\nimport { Card } from \"@/components/ui/card\";\n\n/**\n * MetricCard — dashboard metric tile (composite).\n *\n * Pattern recurrente \"Card + CardHeader + value + delta + trend icon\" promoted\n * to a first-class composite per ADR-0007 community-best-practices plan.\n *\n * Trend → token mapping (default `invertTrend=false`):\n * up → text-success (positive growth)\n * down → text-destructive (negative growth)\n * neutral → text-muted-foreground\n *\n * EC-17 absorbed: pass `invertTrend` for metrics where \"up is bad\" (cost,\n * churn, latency). The mapping inverts cleanly:\n * up → text-destructive (cost growing = bad)\n * down → text-success (cost dropping = good)\n *\n * @example\n * <MetricCard title=\"Revenue\" value=\"$12,345\" delta={{ value: \"+12%\", trend: \"up\" }} />\n * <MetricCard title=\"Monthly Cost\" value=\"$3,200\" delta={{ value: \"+18%\", trend: \"up\" }} invertTrend />\n */\n\nexport type MetricCardTrend = \"up\" | \"down\" | \"neutral\";\n\nexport interface MetricCardDelta {\n /** Display text (e.g., `\"+12%\"`, `\"-3.4 pp\"`, `\"unchanged\"`). */\n value: string;\n /** Trend direction — drives icon and color (subject to `invertTrend`). */\n trend: MetricCardTrend;\n}\n\nexport interface MetricCardProps extends HTMLAttributes<HTMLDivElement> {\n /** Metric label (e.g., \"Revenue\", \"Active Users\"). */\n title: string;\n /** Headline value (e.g., \"$12,345\", \"1,234\"). */\n value: ReactNode;\n /** Optional delta with trend direction. */\n delta?: MetricCardDelta;\n /** Optional subtle context line below the value (e.g., \"vs last month\"). */\n hint?: ReactNode;\n /** Optional icon rendered top-right (e.g., `<DollarSign />`). */\n icon?: ReactNode;\n /**\n * EC-17: invert default trend semantics. Use for Cost / Churn / Latency\n * metrics where \"up\" is bad. Default `false` (Revenue / Users / Conversions).\n */\n invertTrend?: boolean;\n}\n\nfunction trendColor(trend: MetricCardTrend, invert: boolean): string {\n if (trend === \"neutral\") return \"text-muted-foreground\";\n const isPositive = invert ? trend === \"down\" : trend === \"up\";\n return isPositive ? \"text-success\" : \"text-destructive\";\n}\n\nfunction TrendIcon({ trend, className }: { trend: MetricCardTrend; className?: string }) {\n if (trend === \"up\") return <TrendingUp className={cn(\"size-3.5\", className)} aria-hidden />;\n if (trend === \"down\") return <TrendingDown className={cn(\"size-3.5\", className)} aria-hidden />;\n return <Minus className={cn(\"size-3.5\", className)} aria-hidden />;\n}\n\nexport const MetricCard = forwardRef<HTMLDivElement, MetricCardProps>(\n ({ title, value, delta, hint, icon, invertTrend = false, className, ...rest }, ref) => {\n return (\n <Card\n ref={ref}\n // T5.5: `@container/metric-card` makes the tile responsive to its PARENT\n // width, not the viewport. Consumers can drop multiple cards into any\n // grid and child elements scale via `@sm:`, `@md:`, `@lg:` variants.\n className={cn(\"@container/metric-card flex flex-col gap-2 p-4\", className)}\n data-testid=\"metric-card\"\n {...rest}\n >\n <div className=\"flex items-start justify-between gap-3\">\n <span className=\"font-medium text-muted-foreground text-xs uppercase tracking-wide\">\n {title}\n </span>\n {icon !== undefined && (\n <span className=\"text-muted-foreground\" aria-hidden>\n {icon}\n </span>\n )}\n </div>\n {/* Value scales 2xl → 3xl when the card container exceeds 18rem. */}\n <div className=\"font-semibold @sm/metric-card:text-3xl text-2xl text-foreground tracking-tight\">\n {value}\n </div>\n {(delta !== undefined || hint !== undefined) && (\n <div className=\"flex items-center gap-2 text-xs\">\n {delta !== undefined && (\n <span\n className={cn(\n \"inline-flex items-center gap-1 font-medium\",\n trendColor(delta.trend, invertTrend),\n )}\n data-trend={delta.trend}\n >\n <TrendIcon trend={delta.trend} />\n {delta.value}\n </span>\n )}\n {hint !== undefined && <span className=\"text-muted-foreground\">{hint}</span>}\n </div>\n )}\n </Card>\n );\n },\n);\nMetricCard.displayName = \"MetricCard\";\n"
21
+ }
22
+ ]
23
+ }
@@ -10,7 +10,7 @@
10
10
  "path": "lib/safe-href.ts",
11
11
  "type": "registry:lib",
12
12
  "target": "lib/safe-href.ts",
13
- "content": "/**\n * safeHref — defang `javascript:`, `vbscript:`, and `data:text/html` URIs.\n *\n * T3.3 (Security SEC-003). When a consumer passes user-controlled data as\n * an href to one of our card composites (`ProjectCard.href`, `PreviewEnvCard.url`,\n * etc.), an attacker who controls that data can craft a `javascript:alert(...)`\n * URI that fires in the application's origin on click. This is a standard\n * component-library hardening (mitigation also documented in `SECURITY.md`).\n *\n * Returns `undefined` for dangerous protocols so the consuming component\n * can short-circuit to a non-link rendering. Returns the URL unchanged for\n * safe protocols (`http`, `https`, `mailto`, `tel`, relative paths).\n *\n * Allowed (returns input unchanged):\n * - \"https://example.com/path?query\"\n * - \"/internal/route\"\n * - \"mailto:dev@usetheo.dev\"\n * - \"tel:+15551234567\"\n *\n * Blocked (returns undefined):\n * - \"javascript:alert(1)\" — XSS via JS execution\n * - \" JavaScript:alert(1)\" — case-insensitive, leading whitespace\n * - \"vbscript:msgbox(1)\" — legacy IE XSS surface (still relevant on\n * certain enterprise envs)\n * - \"data:text/html,...\" — inline HTML/script payloads\n */\n\nconst DANGEROUS_PROTOCOL_PATTERNS: readonly RegExp[] = [\n /^javascript:/i,\n /^vbscript:/i,\n /^data:text\\/html/i,\n];\n\nexport function safeHref(url: string | null | undefined): string | undefined {\n if (url === null || url === undefined) return undefined;\n const trimmed = url.trim();\n if (trimmed.length === 0) return undefined;\n for (const pattern of DANGEROUS_PROTOCOL_PATTERNS) {\n if (pattern.test(trimmed)) return undefined;\n }\n return url;\n}\n"
13
+ "content": "/**\n * safeHref — defang `javascript:`, `vbscript:`, and `data:text/html` URIs.\n *\n * T3.3 (Security SEC-003). When a consumer passes user-controlled data as\n * an href to one of our card composites (`ProjectCard.href`, `PreviewEnvCard.url`,\n * etc.), an attacker who controls that data can craft a `javascript:alert(...)`\n * URI that fires in the application's origin on click. This is a standard\n * component-library hardening (mitigation also documented in `SECURITY.md`).\n *\n * Returns `undefined` for dangerous protocols so the consuming component\n * can short-circuit to a non-link rendering. Returns the URL unchanged for\n * safe protocols (`http`, `https`, `mailto`, `tel`, relative paths).\n *\n * Allowed (returns input unchanged):\n * - \"https://example.com/path?query\"\n * - \"/internal/route\"\n * - \"mailto:dev@theokit.dev\"\n * - \"tel:+15551234567\"\n *\n * Blocked (returns undefined):\n * - \"javascript:alert(1)\" — XSS via JS execution\n * - \" JavaScript:alert(1)\" — case-insensitive, leading whitespace\n * - \"vbscript:msgbox(1)\" — legacy IE XSS surface (still relevant on\n * certain enterprise envs)\n * - \"data:text/html,...\" — inline HTML/script payloads\n */\n\nconst DANGEROUS_PROTOCOL_PATTERNS: readonly RegExp[] = [\n /^javascript:/i,\n /^vbscript:/i,\n /^data:text\\/html/i,\n];\n\nexport function safeHref(url: string | null | undefined): string | undefined {\n if (url === null || url === undefined) return undefined;\n const trimmed = url.trim();\n if (trimmed.length === 0) return undefined;\n for (const pattern of DANGEROUS_PROTOCOL_PATTERNS) {\n if (pattern.test(trimmed)) return undefined;\n }\n return url;\n}\n"
14
14
  }
15
15
  ]
16
16
  }
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "status-indicator",
4
+ "type": "registry:ui",
5
+ "title": "StatusIndicator",
6
+ "description": "Operational state indicator (online/offline/degraded/info) consuming status-* semantic tokens (ADR-0007).",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "https://usetheodev.github.io/theo-ui/r/cn.json",
10
+ "https://usetheodev.github.io/theo-ui/r/tailwind-preset.json"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "components/composites/status-indicator/status-indicator.tsx",
15
+ "type": "registry:ui",
16
+ "target": "components/ui/status-indicator.tsx",
17
+ "content": "import { type HTMLAttributes, forwardRef } from \"react\";\n\nimport { cn } from \"@/lib/cn\";\n\n/**\n * StatusIndicator — semantic operational-state indicator (composite).\n *\n * Consumes the `--status-online` / `--status-offline` / `--status-degraded`\n * / `--status-info` token group (ADR-0007). Statuses describe component\n * liveness (alive/dead/slow/info-flag), distinct from action-result\n * semantics (success/destructive/warning/info — see `StatusDot` primitive).\n *\n * Hierarchy invariant (T4.1): this composite consumes primitives via\n * Tailwind tokens (`bg-status-*`), never imports another composite. The\n * `StatusDot` primitive remains as a separate, more generic API.\n *\n * @example\n * <StatusIndicator status=\"online\" />\n * <StatusIndicator status=\"degraded\" label=\"Slow\" />\n * <StatusIndicator status=\"offline\" label=\"Disconnected\" size=\"md\" />\n */\n\nexport type StatusIndicatorKind = \"online\" | \"offline\" | \"degraded\" | \"info\";\nexport type StatusIndicatorSize = \"sm\" | \"md\";\n\nexport interface StatusIndicatorProps extends Omit<HTMLAttributes<HTMLSpanElement>, \"children\"> {\n /** Operational state to convey. Maps to `--status-{kind}`. */\n status: StatusIndicatorKind;\n /** Optional inline label to the right of the dot. */\n label?: string;\n /** Size of the dot. Default `sm` (8px). */\n size?: StatusIndicatorSize;\n /** When true, pulses the dot to draw attention (e.g., live state). */\n pulse?: boolean;\n}\n\nconst DOT_COLOR: Record<StatusIndicatorKind, string> = {\n online: \"bg-status-online\",\n offline: \"bg-status-offline\",\n degraded: \"bg-status-degraded\",\n info: \"bg-status-info\",\n};\n\nconst LABEL_COLOR: Record<StatusIndicatorKind, string> = {\n online: \"text-foreground\",\n offline: \"text-foreground\",\n degraded: \"text-foreground\",\n info: \"text-foreground\",\n};\n\nconst ARIA_LABEL: Record<StatusIndicatorKind, string> = {\n online: \"Online\",\n offline: \"Offline\",\n degraded: \"Degraded\",\n info: \"Information\",\n};\n\nconst SIZE_DOT: Record<StatusIndicatorSize, string> = {\n sm: \"size-2\",\n md: \"size-2.5\",\n};\n\nexport const StatusIndicator = forwardRef<HTMLSpanElement, StatusIndicatorProps>(\n (\n { status, label, size = \"sm\", pulse = false, className, \"aria-label\": ariaLabel, ...rest },\n ref,\n ) => {\n const accessibleLabel = label ?? ariaLabel ?? ARIA_LABEL[status];\n return (\n <span\n ref={ref}\n role=\"img\"\n aria-label={accessibleLabel}\n className={cn(\"inline-flex items-center gap-2 font-medium text-xs\", className)}\n data-status={status}\n {...rest}\n >\n <span\n className={cn(\n \"inline-block rounded-full\",\n SIZE_DOT[size],\n DOT_COLOR[status],\n pulse && \"animate-pulse\",\n )}\n aria-hidden\n />\n {label !== undefined && <span className={LABEL_COLOR[status]}>{label}</span>}\n </span>\n );\n },\n);\nStatusIndicator.displayName = \"StatusIndicator\";\n"
18
+ }
19
+ ]
20
+ }
@@ -13,7 +13,7 @@
13
13
  "path": "styles/tailwind-preset.ts",
14
14
  "type": "registry:lib",
15
15
  "target": "styles/tailwind-preset.ts",
16
- "content": "/**\n * Theo UI Tailwind preset — Violet Forge identity.\n *\n * Single source of truth for the design system's utility-level tokens:\n * - colors mapped to CSS variables (HSL split via `hsl(var(--x) / <alpha>)`)\n * - Geist-inspired typescale (display / title / body / label / code tiers)\n * - radii, shadows, motion timing & duration, keyframes\n * - tailwindcss-animate plugin\n *\n * Consumed by:\n * - `tailwind.config.ts` (this repo) via `presets: [theoUIPreset]`\n * - registry/r/tailwind-preset.json (shipped to consumers via shadcn CLI)\n *\n * Consumers integrate as:\n *\n * import type { Config } from \"tailwindcss\";\n * import { theoUIPreset } from \"./styles/tailwind-preset\";\n *\n * export default {\n * darkMode: \"class\",\n * content: [\"./src/**\\/*.{ts,tsx}\"],\n * presets: [theoUIPreset],\n * } satisfies Config;\n *\n * Note: `darkMode` and `content` are NOT in the preset — they are consumer\n * decisions. The preset only contains `theme.extend.*` and `plugins`.\n */\n\nimport type { Config } from \"tailwindcss\";\nimport animate from \"tailwindcss-animate\";\n\nconst hsl = (token: string) => `hsl(var(${token}) / <alpha-value>)`;\n\nexport const theoUIPreset: Partial<Config> = {\n theme: {\n container: {\n center: true,\n padding: \"1rem\",\n screens: {\n \"2xl\": \"1280px\",\n },\n },\n extend: {\n colors: {\n background: hsl(\"--background\"),\n foreground: hsl(\"--foreground\"),\n card: {\n DEFAULT: hsl(\"--card\"),\n foreground: hsl(\"--card-foreground\"),\n },\n popover: {\n DEFAULT: hsl(\"--popover\"),\n foreground: hsl(\"--popover-foreground\"),\n },\n primary: {\n DEFAULT: hsl(\"--primary\"),\n deep: hsl(\"--primary-deep\"),\n glow: hsl(\"--primary-glow\"),\n foreground: hsl(\"--primary-foreground\"),\n },\n secondary: {\n DEFAULT: hsl(\"--secondary\"),\n foreground: hsl(\"--secondary-foreground\"),\n },\n accent: {\n DEFAULT: hsl(\"--accent\"),\n deep: hsl(\"--accent-deep\"),\n foreground: hsl(\"--accent-foreground\"),\n },\n muted: {\n DEFAULT: hsl(\"--muted\"),\n foreground: hsl(\"--muted-foreground\"),\n },\n success: {\n DEFAULT: hsl(\"--success\"),\n foreground: hsl(\"--success-foreground\"),\n },\n warning: {\n DEFAULT: hsl(\"--warning\"),\n foreground: hsl(\"--warning-foreground\"),\n },\n destructive: {\n DEFAULT: hsl(\"--destructive\"),\n foreground: hsl(\"--destructive-foreground\"),\n },\n info: {\n DEFAULT: hsl(\"--info\"),\n foreground: hsl(\"--info-foreground\"),\n },\n border: hsl(\"--border\"),\n input: hsl(\"--input\"),\n ring: hsl(\"--ring\"),\n },\n fontFamily: {\n display: \"var(--font-display)\",\n sans: \"var(--font-body)\",\n mono: \"var(--font-mono)\",\n },\n /* Geist-inspired Violet Forge typescale.\n *\n * Three strict weights: 400 (body), 500 (UI), 600 (display/headings).\n * Letter-spacing scales with size — aggressive negative on display.\n * Mirrors the Vercel/Geist vocabulary while keeping Theo's identity.\n */\n fontSize: {\n // Display tier — aggressive compression, content-led headlines\n \"display-2xl\": [\"64px\", { lineHeight: \"1\", letterSpacing: \"-0.0464em\", fontWeight: \"600\" }],\n \"display-xl\": [\"48px\", { lineHeight: \"1.05\", letterSpacing: \"-0.05em\", fontWeight: \"600\" }],\n \"display-lg\": [\"40px\", { lineHeight: \"1.1\", letterSpacing: \"-0.05em\", fontWeight: \"600\" }],\n \"display-md\": [\"32px\", { lineHeight: \"1.2\", letterSpacing: \"-0.04em\", fontWeight: \"600\" }],\n headline: [\"28px\", { lineHeight: \"1.25\", letterSpacing: \"-0.035em\", fontWeight: \"600\" }],\n // Title tier — section / card heads\n \"title-lg\": [\"24px\", { lineHeight: \"1.33\", letterSpacing: \"-0.04em\", fontWeight: \"600\" }],\n \"title-md\": [\"20px\", { lineHeight: \"1.4\", letterSpacing: \"-0.03em\", fontWeight: \"600\" }],\n // Body tier — FAANG-density realignment 2026-05-22: body-md is the\n // industry-standard 14px (shadcn / Vercel Geist / Linear / Stripe /\n // Mantine). The previous 15px was idiosyncratic. body-sm (14px label\n // weight) remains separate via its line-height / weight signature.\n \"body-lg\": [\"18px\", { lineHeight: \"1.56\", letterSpacing: \"-0.01em\", fontWeight: \"400\" }],\n \"body-md\": [\"14px\", { lineHeight: \"1.43\", letterSpacing: \"0\", fontWeight: \"400\" }],\n \"body-sm\": [\"13px\", { lineHeight: \"1.46\", fontWeight: \"400\" }],\n // Label tier — used on buttons, nav, secondary actions\n label: [\"14px\", { lineHeight: \"1.43\", fontWeight: \"500\" }],\n \"label-caps\": [\"12px\", { lineHeight: \"1.33\", letterSpacing: \"0.04em\", fontWeight: \"500\" }],\n // Mono — code surfaces, technical labels\n \"code-md\": [\"14px\", { lineHeight: \"1.5\", fontWeight: \"400\" }],\n \"code-sm\": [\"13px\", { lineHeight: \"1.54\", fontWeight: \"500\" }],\n },\n borderRadius: {\n none: \"var(--radius-none)\",\n sm: \"var(--radius-sm)\",\n md: \"var(--radius-md)\",\n lg: \"var(--radius-lg)\",\n xl: \"var(--radius-xl)\",\n \"2xl\": \"var(--radius-2xl)\",\n full: \"var(--radius-full)\",\n },\n boxShadow: {\n sm: \"var(--shadow-sm)\",\n md: \"var(--shadow-md)\",\n lg: \"var(--shadow-lg)\",\n glow: \"var(--shadow-glow)\",\n \"glow-strong\": \"var(--shadow-glow-strong)\",\n },\n transitionTimingFunction: {\n \"out-soft\": \"var(--ease-out-soft)\",\n snap: \"var(--ease-snap)\",\n },\n transitionDuration: {\n fast: \"var(--duration-fast)\",\n base: \"var(--duration-base)\",\n slow: \"var(--duration-slow)\",\n },\n keyframes: {\n \"fade-in-up\": {\n \"0%\": { opacity: \"0\", transform: \"translateY(8px)\" },\n \"100%\": { opacity: \"1\", transform: \"translateY(0)\" },\n },\n \"pulse-glow\": {\n \"0%, 100%\": { boxShadow: \"0 0 0 0 hsl(var(--primary) / 0.5)\" },\n \"50%\": { boxShadow: \"0 0 0 8px hsl(var(--primary) / 0)\" },\n },\n },\n animation: {\n \"fade-in-up\": \"fade-in-up var(--duration-base) var(--ease-out-soft) both\",\n \"pulse-glow\": \"pulse-glow 1.5s var(--ease-in-out) infinite\",\n },\n },\n },\n plugins: [animate],\n};\n"
16
+ "content": "/**\n * Theo UI Tailwind preset — Violet Forge identity.\n *\n * Single source of truth for the design system's utility-level tokens:\n * - colors mapped to CSS variables (HSL split via `hsl(var(--x) / <alpha>)`)\n * - Geist-inspired typescale (display / title / body / label / code tiers)\n * - radii, shadows, motion timing & duration, keyframes\n * - tailwindcss-animate plugin\n *\n * Consumed by:\n * - `tailwind.config.ts` (this repo) via `presets: [theoUIPreset]`\n * - registry/r/tailwind-preset.json (shipped to consumers via shadcn CLI)\n *\n * Consumers integrate as:\n *\n * import type { Config } from \"tailwindcss\";\n * import { theoUIPreset } from \"./styles/tailwind-preset\";\n *\n * export default {\n * darkMode: \"class\",\n * content: [\"./src/**\\/*.{ts,tsx}\"],\n * presets: [theoUIPreset],\n * } satisfies Config;\n *\n * Note: `darkMode` and `content` are NOT in the preset — they are consumer\n * decisions. The preset only contains `theme.extend.*` and `plugins`.\n */\n\nimport type { Config } from \"tailwindcss\";\nimport animate from \"tailwindcss-animate\";\n\n// Post-T2.4 (ADR-0005): tokens are OKLCH. The previous `hsl(var(--x) / <alpha>)`\n// pattern resolves to `hsl(oklch(...))` which is invalid CSS — utilities\n// silently fall back to transparent. OKLCH relative-color syntax preserves\n// Tailwind's `<alpha-value>` slot for opacity modifiers (e.g. `bg-primary/50`).\nconst hsl = (token: string) => `oklch(from var(${token}) l c h / <alpha-value>)`;\n\nexport const theoUIPreset: Partial<Config> = {\n theme: {\n container: {\n center: true,\n padding: \"1rem\",\n screens: {\n \"2xl\": \"1280px\",\n },\n },\n extend: {\n colors: {\n background: hsl(\"--background\"),\n foreground: hsl(\"--foreground\"),\n card: {\n DEFAULT: hsl(\"--card\"),\n foreground: hsl(\"--card-foreground\"),\n },\n popover: {\n DEFAULT: hsl(\"--popover\"),\n foreground: hsl(\"--popover-foreground\"),\n },\n primary: {\n DEFAULT: hsl(\"--primary\"),\n deep: hsl(\"--primary-deep\"),\n glow: hsl(\"--primary-glow\"),\n foreground: hsl(\"--primary-foreground\"),\n },\n secondary: {\n DEFAULT: hsl(\"--secondary\"),\n foreground: hsl(\"--secondary-foreground\"),\n },\n accent: {\n DEFAULT: hsl(\"--accent\"),\n deep: hsl(\"--accent-deep\"),\n foreground: hsl(\"--accent-foreground\"),\n },\n muted: {\n DEFAULT: hsl(\"--muted\"),\n foreground: hsl(\"--muted-foreground\"),\n },\n success: {\n DEFAULT: hsl(\"--success\"),\n foreground: hsl(\"--success-foreground\"),\n },\n warning: {\n DEFAULT: hsl(\"--warning\"),\n foreground: hsl(\"--warning-foreground\"),\n },\n destructive: {\n DEFAULT: hsl(\"--destructive\"),\n foreground: hsl(\"--destructive-foreground\"),\n },\n info: {\n DEFAULT: hsl(\"--info\"),\n foreground: hsl(\"--info-foreground\"),\n },\n border: hsl(\"--border\"),\n input: hsl(\"--input\"),\n ring: hsl(\"--ring\"),\n },\n fontFamily: {\n display: \"var(--font-display)\",\n sans: \"var(--font-body)\",\n mono: \"var(--font-mono)\",\n },\n /* Geist-inspired Violet Forge typescale.\n *\n * Three strict weights: 400 (body), 500 (UI), 600 (display/headings).\n * Letter-spacing scales with size — aggressive negative on display.\n * Mirrors the Vercel/Geist vocabulary while keeping Theo's identity.\n */\n fontSize: {\n // Display tier — aggressive compression, content-led headlines\n \"display-2xl\": [\"64px\", { lineHeight: \"1\", letterSpacing: \"-0.0464em\", fontWeight: \"600\" }],\n \"display-xl\": [\"48px\", { lineHeight: \"1.05\", letterSpacing: \"-0.05em\", fontWeight: \"600\" }],\n \"display-lg\": [\"40px\", { lineHeight: \"1.1\", letterSpacing: \"-0.05em\", fontWeight: \"600\" }],\n \"display-md\": [\"32px\", { lineHeight: \"1.2\", letterSpacing: \"-0.04em\", fontWeight: \"600\" }],\n headline: [\"28px\", { lineHeight: \"1.25\", letterSpacing: \"-0.035em\", fontWeight: \"600\" }],\n // Title tier — section / card heads\n \"title-lg\": [\"24px\", { lineHeight: \"1.33\", letterSpacing: \"-0.04em\", fontWeight: \"600\" }],\n \"title-md\": [\"20px\", { lineHeight: \"1.4\", letterSpacing: \"-0.03em\", fontWeight: \"600\" }],\n // Body tier — FAANG-density realignment 2026-05-22: body-md is the\n // industry-standard 14px (shadcn / Vercel Geist / Linear / Stripe /\n // Mantine). The previous 15px was idiosyncratic. body-sm (14px label\n // weight) remains separate via its line-height / weight signature.\n \"body-lg\": [\"18px\", { lineHeight: \"1.56\", letterSpacing: \"-0.01em\", fontWeight: \"400\" }],\n \"body-md\": [\"14px\", { lineHeight: \"1.43\", letterSpacing: \"0\", fontWeight: \"400\" }],\n \"body-sm\": [\"13px\", { lineHeight: \"1.46\", fontWeight: \"400\" }],\n // Label tier — used on buttons, nav, secondary actions\n label: [\"14px\", { lineHeight: \"1.43\", fontWeight: \"500\" }],\n \"label-caps\": [\"12px\", { lineHeight: \"1.33\", letterSpacing: \"0.04em\", fontWeight: \"500\" }],\n // Mono — code surfaces, technical labels\n \"code-md\": [\"14px\", { lineHeight: \"1.5\", fontWeight: \"400\" }],\n \"code-sm\": [\"13px\", { lineHeight: \"1.54\", fontWeight: \"500\" }],\n },\n borderRadius: {\n none: \"var(--radius-none)\",\n sm: \"var(--radius-sm)\",\n md: \"var(--radius-md)\",\n lg: \"var(--radius-lg)\",\n xl: \"var(--radius-xl)\",\n \"2xl\": \"var(--radius-2xl)\",\n full: \"var(--radius-full)\",\n },\n boxShadow: {\n sm: \"var(--shadow-sm)\",\n md: \"var(--shadow-md)\",\n lg: \"var(--shadow-lg)\",\n glow: \"var(--shadow-glow)\",\n \"glow-strong\": \"var(--shadow-glow-strong)\",\n },\n transitionTimingFunction: {\n \"out-soft\": \"var(--ease-out-soft)\",\n snap: \"var(--ease-snap)\",\n },\n transitionDuration: {\n fast: \"var(--duration-fast)\",\n base: \"var(--duration-base)\",\n slow: \"var(--duration-slow)\",\n },\n keyframes: {\n \"fade-in-up\": {\n \"0%\": { opacity: \"0\", transform: \"translateY(8px)\" },\n \"100%\": { opacity: \"1\", transform: \"translateY(0)\" },\n },\n \"pulse-glow\": {\n \"0%, 100%\": { boxShadow: \"0 0 0 0 hsl(var(--primary) / 0.5)\" },\n \"50%\": { boxShadow: \"0 0 0 8px hsl(var(--primary) / 0)\" },\n },\n },\n animation: {\n \"fade-in-up\": \"fade-in-up var(--duration-base) var(--ease-out-soft) both\",\n \"pulse-glow\": \"pulse-glow 1.5s var(--ease-in-out) infinite\",\n },\n },\n },\n plugins: [animate],\n};\n"
17
17
  }
18
18
  ]
19
19
  }
@@ -17,31 +17,31 @@
17
17
  "path": "themes/types.ts",
18
18
  "type": "registry:lib",
19
19
  "target": "themes/types.ts",
20
- "content": "/**\n * Theo UI — Theme types.\n *\n * A Theme is a frozen bundle of CSS-var values that the runtime can swap by\n * setting `data-theme=\"<name>\"` on `<html>`. The structure mirrors what lives\n * in tokens.css so themes can be merged without ambiguity.\n */\n\nexport type ThemeMode = \"light\" | \"dark\";\n\nexport interface ColorScale {\n background: string;\n foreground: string;\n card: string;\n \"card-foreground\": string;\n popover: string;\n \"popover-foreground\": string;\n primary: string;\n \"primary-deep\": string;\n \"primary-glow\": string;\n \"primary-foreground\": string;\n secondary: string;\n \"secondary-foreground\": string;\n accent: string;\n \"accent-deep\": string;\n \"accent-foreground\": string;\n muted: string;\n \"muted-foreground\": string;\n border: string;\n input: string;\n ring: string;\n success: string;\n \"success-foreground\": string;\n warning: string;\n \"warning-foreground\": string;\n destructive: string;\n \"destructive-foreground\": string;\n info: string;\n \"info-foreground\": string;\n}\n\nexport interface ThemeFonts {\n /** Display headlines (h1-h3, hero text). */\n display: string;\n /** Body / UI text. */\n body: string;\n /** Code, mono, paths, timestamps. */\n mono: string;\n}\n\nexport interface Theme {\n /** Stable id, used in `data-theme`. */\n name: string;\n /** Human-readable label for theme switchers. */\n label: string;\n /** Optional short description shown in switchers. */\n description?: string;\n fonts: ThemeFonts;\n light: ColorScale;\n dark: ColorScale;\n /**\n * Optional URL(s) to fetch before applying. The provider injects a `<link>`\n * tag once per URL to load remote fonts. Already-injected URLs are deduped.\n */\n fontUrls?: string[];\n}\n"
20
+ "content": "/**\n * Theo UI — Theme types.\n *\n * A Theme is a frozen bundle of CSS-var values that the runtime can swap by\n * setting `data-theme=\"<name>\"` on `<html>`. The structure mirrors what lives\n * in tokens.css so themes can be merged without ambiguity.\n */\n\nexport type ThemeMode = \"light\" | \"dark\";\n\nexport interface ColorScale {\n background: string;\n foreground: string;\n card: string;\n \"card-foreground\": string;\n popover: string;\n \"popover-foreground\": string;\n primary: string;\n /**\n * Tonal scale variants of `primary`. Optional since T3.2 (ADR-0006).\n * When omitted, derived in CSS from `--primary` via `oklch(from ...)`.\n * Override per-theme by providing an explicit string.\n */\n \"primary-deep\"?: string;\n \"primary-glow\"?: string;\n \"primary-foreground\": string;\n secondary: string;\n \"secondary-foreground\": string;\n accent: string;\n /**\n * Tonal scale variant of `accent`. Optional since T3.2 — derived in CSS\n * when omitted. Override per-theme by providing an explicit string.\n */\n \"accent-deep\"?: string;\n \"accent-foreground\": string;\n muted: string;\n \"muted-foreground\": string;\n border: string;\n input: string;\n ring: string;\n success: string;\n \"success-foreground\": string;\n warning: string;\n \"warning-foreground\": string;\n destructive: string;\n \"destructive-foreground\": string;\n info: string;\n \"info-foreground\": string;\n /**\n * Status semantic group (D4 ADR — community-best-practices plan).\n *\n * Operational state colors (gateway connected/disconnected/slow/info-flag)\n * separated from action-result semantics (success/destructive/warning/info).\n * Defaults in built-in themes mirror their semantic counterparts; consumers\n * may override for visually-distinct status surfaces. `defineTheme(partial)`\n * auto-populates from semantic group when omitted.\n */\n \"status-online\": string;\n \"status-online-foreground\": string;\n \"status-offline\": string;\n \"status-offline-foreground\": string;\n \"status-degraded\": string;\n \"status-degraded-foreground\": string;\n \"status-info\": string;\n \"status-info-foreground\": string;\n}\n\nexport interface ThemeFonts {\n /** Display headlines (h1-h3, hero text). */\n display: string;\n /** Body / UI text. */\n body: string;\n /** Code, mono, paths, timestamps. */\n mono: string;\n}\n\nexport interface Theme {\n /** Stable id, used in `data-theme`. */\n name: string;\n /** Human-readable label for theme switchers. */\n label: string;\n /** Optional short description shown in switchers. */\n description?: string;\n fonts: ThemeFonts;\n light: ColorScale;\n dark: ColorScale;\n /**\n * Optional URL(s) to fetch before applying. The provider injects a `<link>`\n * tag once per URL to load remote fonts. Already-injected URLs are deduped.\n */\n fontUrls?: string[];\n}\n"
21
21
  },
22
22
  {
23
23
  "path": "themes/violet-forge.ts",
24
24
  "type": "registry:lib",
25
25
  "target": "themes/violet-forge.ts",
26
- "content": "import type { Theme } from \"@/themes/types\";\n\n/**\n * Violet Forge — the default Theo theme.\n *\n * Identity: Theo violet primary (#7C3AED), burnt sienna accent (#C96442),\n * Vercel-style neutral surfaces (pure white light / charcoal dark),\n * Geist Sans + Geist Mono throughout.\n *\n * Source of truth for `data-theme` overrides. Values mirror\n * src/styles/tokens.css for the default `:root`.\n */\nexport const violetForge: Theme = {\n name: \"violet-forge\",\n label: \"Violet Forge\",\n description: \"Theo default — violet primary, burnt sienna accent, Geist Sans + Geist Mono.\",\n fonts: {\n display: '\"Geist\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif',\n body: '\"Geist\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif',\n mono: '\"Geist Mono\", ui-monospace, SFMono-Regular, Menlo, monospace',\n },\n fontUrls: [\n \"https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Geist+Mono:wght@100..900&display=swap\",\n ],\n light: {\n background: \"0 0% 100%\",\n foreground: \"0 0% 4%\",\n card: \"0 0% 100%\",\n \"card-foreground\": \"0 0% 4%\",\n popover: \"0 0% 100%\",\n \"popover-foreground\": \"0 0% 4%\",\n primary: \"262 83% 58%\",\n \"primary-deep\": \"263 70% 42%\",\n \"primary-glow\": \"263 90% 76%\",\n \"primary-foreground\": \"0 0% 100%\",\n secondary: \"0 0% 96%\",\n \"secondary-foreground\": \"0 0% 4%\",\n accent: \"15 54% 53%\",\n \"accent-deep\": \"15 55% 40%\",\n \"accent-foreground\": \"0 0% 100%\",\n muted: \"0 0% 96%\",\n \"muted-foreground\": \"0 0% 45%\",\n border: \"0 0% 91%\",\n input: \"0 0% 91%\",\n ring: \"262 83% 58%\",\n success: \"142 71% 36%\",\n \"success-foreground\": \"0 0% 100%\",\n warning: \"33 92% 44%\",\n \"warning-foreground\": \"0 0% 100%\",\n destructive: \"0 72% 51%\",\n \"destructive-foreground\": \"0 0% 100%\",\n info: \"217 91% 60%\",\n \"info-foreground\": \"0 0% 100%\",\n },\n dark: {\n background: \"0 0% 4%\",\n foreground: \"0 0% 96%\",\n card: \"0 0% 7%\",\n \"card-foreground\": \"0 0% 96%\",\n popover: \"0 0% 9%\",\n \"popover-foreground\": \"0 0% 96%\",\n primary: \"262 83% 58%\",\n \"primary-deep\": \"263 70% 42%\",\n \"primary-glow\": \"263 90% 76%\",\n \"primary-foreground\": \"0 0% 100%\",\n secondary: \"0 0% 11%\",\n \"secondary-foreground\": \"0 0% 96%\",\n accent: \"15 54% 53%\",\n \"accent-deep\": \"15 55% 40%\",\n \"accent-foreground\": \"0 0% 100%\",\n muted: \"0 0% 11%\",\n \"muted-foreground\": \"0 0% 60%\",\n border: \"0 0% 16%\",\n input: \"0 0% 11%\",\n ring: \"262 83% 58%\",\n success: \"152 79% 52%\",\n \"success-foreground\": \"0 0% 4%\",\n warning: \"38 92% 50%\",\n \"warning-foreground\": \"0 0% 4%\",\n destructive: \"350 100% 65%\",\n \"destructive-foreground\": \"0 0% 4%\",\n info: \"213 100% 70%\",\n \"info-foreground\": \"0 0% 4%\",\n },\n};\n"
26
+ "content": "import type { Theme } from \"@/themes/types\";\n\n/**\n * Violet Forge — the default Theo theme.\n *\n * Identity: Theo violet primary (#7C3AED), burnt sienna accent (#C96442),\n * Vercel-style neutral surfaces (pure white light / charcoal dark),\n * Geist Sans + Geist Mono throughout.\n *\n * Source of truth for `data-theme` overrides. Values mirror\n * src/styles/tokens.css for the default `:root`.\n */\nexport const violetForge: Theme = {\n name: \"violet-forge\",\n label: \"Violet Forge\",\n description: \"Theo default — violet primary, burnt sienna accent, Geist Sans + Geist Mono.\",\n fonts: {\n display: '\"Geist\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif',\n body: '\"Geist\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif',\n mono: '\"Geist Mono\", ui-monospace, SFMono-Regular, Menlo, monospace',\n },\n fontUrls: [\n \"https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Geist+Mono:wght@100..900&display=swap\",\n ],\n light: {\n background: \"oklch(1 0 0)\",\n foreground: \"oklch(0.146 0 0)\",\n card: \"oklch(1 0 0)\",\n \"card-foreground\": \"oklch(0.146 0 0)\",\n popover: \"oklch(1 0 0)\",\n \"popover-foreground\": \"oklch(0.146 0 0)\",\n primary: \"oklch(0.542 0.245 293)\",\n \"primary-foreground\": \"oklch(1 0 0)\",\n secondary: \"oklch(0.97 0 0)\",\n \"secondary-foreground\": \"oklch(0.146 0 0)\",\n accent: \"oklch(0.621 0.132 39)\",\n \"accent-foreground\": \"oklch(1 0 0)\",\n muted: \"oklch(0.97 0 0)\",\n \"muted-foreground\": \"oklch(0.555 0 0)\",\n border: \"oklch(0.931 0 0)\",\n input: \"oklch(0.931 0 0)\",\n ring: \"oklch(0.542 0.245 293)\",\n success: \"oklch(0.611 0.161 149.7)\",\n \"success-foreground\": \"oklch(1 0 0)\",\n warning: \"oklch(0.67 0.154 60.6)\",\n \"warning-foreground\": \"oklch(1 0 0)\",\n destructive: \"oklch(0.579 0.214 27.2)\",\n \"destructive-foreground\": \"oklch(1 0 0)\",\n info: \"oklch(0.626 0.186 259.6)\",\n \"info-foreground\": \"oklch(1 0 0)\",\n \"status-online\": \"oklch(0.611 0.161 149.7)\",\n \"status-online-foreground\": \"oklch(1 0 0)\",\n \"status-offline\": \"oklch(0.579 0.214 27.2)\",\n \"status-offline-foreground\": \"oklch(1 0 0)\",\n \"status-degraded\": \"oklch(0.67 0.154 60.6)\",\n \"status-degraded-foreground\": \"oklch(1 0 0)\",\n \"status-info\": \"oklch(0.626 0.186 259.6)\",\n \"status-info-foreground\": \"oklch(1 0 0)\",\n },\n dark: {\n background: \"oklch(0.146 0 0)\",\n foreground: \"oklch(0.97 0 0)\",\n card: \"oklch(0.182 0 0)\",\n \"card-foreground\": \"oklch(0.97 0 0)\",\n popover: \"oklch(0.204 0 0)\",\n \"popover-foreground\": \"oklch(0.97 0 0)\",\n primary: \"oklch(0.542 0.245 293)\",\n \"primary-foreground\": \"oklch(1 0 0)\",\n secondary: \"oklch(0.227 0 0)\",\n \"secondary-foreground\": \"oklch(0.97 0 0)\",\n accent: \"oklch(0.621 0.132 39)\",\n \"accent-foreground\": \"oklch(1 0 0)\",\n muted: \"oklch(0.227 0 0)\",\n \"muted-foreground\": \"oklch(0.683 0 0)\",\n border: \"oklch(0.28 0 0)\",\n input: \"oklch(0.227 0 0)\",\n ring: \"oklch(0.542 0.245 293)\",\n success: \"oklch(0.814 0.192 155.7)\",\n \"success-foreground\": \"oklch(0.146 0 0)\",\n warning: \"oklch(0.77 0.165 70.6)\",\n \"warning-foreground\": \"oklch(0.146 0 0)\",\n destructive: \"oklch(0.677 0.213 15.6)\",\n \"destructive-foreground\": \"oklch(0.146 0 0)\",\n info: \"oklch(0.732 0.142 254.4)\",\n \"info-foreground\": \"oklch(0.146 0 0)\",\n \"status-online\": \"oklch(0.814 0.192 155.7)\",\n \"status-online-foreground\": \"oklch(0.146 0 0)\",\n \"status-offline\": \"oklch(0.677 0.213 15.6)\",\n \"status-offline-foreground\": \"oklch(0.146 0 0)\",\n \"status-degraded\": \"oklch(0.77 0.165 70.6)\",\n \"status-degraded-foreground\": \"oklch(0.146 0 0)\",\n \"status-info\": \"oklch(0.732 0.142 254.4)\",\n \"status-info-foreground\": \"oklch(0.146 0 0)\",\n },\n};\n"
27
27
  },
28
28
  {
29
29
  "path": "themes/classic-paper.ts",
30
30
  "type": "registry:lib",
31
31
  "target": "themes/classic-paper.ts",
32
- "content": "import type { Theme } from \"@/themes/types\";\n\n/**\n * Classic Paper — light-primary with deep-navy dark mirror; Inter + JetBrains Mono.\n *\n * Identity: warm paper background, deep navy foreground, indigo primary\n * (closer to traditional dashboard SaaS), Inter throughout. Maximizes\n * legibility and familiarity use when reading endurance > differentiation.\n *\n * Provides a full `dark` palette mirror so consumers toggling `.dark` still\n * get a coherent surface (it is not \"light-only\" by accident).\n */\nexport const classicPaper: Theme = {\n name: \"classic-paper\",\n label: \"Classic Paper\",\n description: \"Inter + paper background. Maximum legibility, conservative.\",\n fonts: {\n display: '\"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif',\n body: '\"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif',\n mono: '\"JetBrains Mono\", ui-monospace, SFMono-Regular, Menlo, monospace',\n },\n fontUrls: [\n \"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap\",\n ],\n light: {\n background: \"36 30% 98%\",\n foreground: \"222 47% 11%\",\n card: \"0 0% 100%\",\n \"card-foreground\": \"222 47% 11%\",\n popover: \"0 0% 100%\",\n \"popover-foreground\": \"222 47% 11%\",\n primary: \"221 83% 53%\",\n \"primary-deep\": \"224 76% 38%\",\n \"primary-glow\": \"217 91% 70%\",\n \"primary-foreground\": \"0 0% 100%\",\n secondary: \"210 40% 96%\",\n \"secondary-foreground\": \"222 47% 11%\",\n accent: \"37 92% 40%\",\n \"accent-deep\": \"32 81% 30%\",\n \"accent-foreground\": \"0 0% 100%\",\n muted: \"210 40% 96%\",\n \"muted-foreground\": \"215 16% 47%\",\n border: \"214 32% 91%\",\n input: \"214 32% 91%\",\n ring: \"221 83% 53%\",\n success: \"142 71% 36%\",\n \"success-foreground\": \"0 0% 100%\",\n warning: \"33 92% 44%\",\n \"warning-foreground\": \"0 0% 100%\",\n destructive: \"0 72% 51%\",\n \"destructive-foreground\": \"0 0% 100%\",\n info: \"217 91% 60%\",\n \"info-foreground\": \"0 0% 100%\",\n },\n // Dark mirror — mainly the same hues with inverted lightness so the theme\n // still feels coherent if a consumer toggles `.dark`.\n dark: {\n background: \"222 47% 8%\",\n foreground: \"210 40% 98%\",\n card: \"222 47% 11%\",\n \"card-foreground\": \"210 40% 98%\",\n popover: \"222 47% 11%\",\n \"popover-foreground\": \"210 40% 98%\",\n primary: \"217 91% 60%\",\n \"primary-deep\": \"221 83% 45%\",\n \"primary-glow\": \"213 100% 80%\",\n \"primary-foreground\": \"222 47% 11%\",\n secondary: \"217 19% 18%\",\n \"secondary-foreground\": \"210 40% 98%\",\n accent: \"37 92% 60%\",\n \"accent-deep\": \"32 81% 45%\",\n \"accent-foreground\": \"222 47% 11%\",\n muted: \"217 19% 18%\",\n \"muted-foreground\": \"215 20% 65%\",\n border: \"217 19% 22%\",\n input: \"217 19% 18%\",\n ring: \"217 91% 60%\",\n success: \"152 79% 52%\",\n \"success-foreground\": \"222 47% 11%\",\n warning: \"38 92% 50%\",\n \"warning-foreground\": \"222 47% 11%\",\n destructive: \"350 100% 65%\",\n \"destructive-foreground\": \"222 47% 11%\",\n info: \"213 100% 70%\",\n \"info-foreground\": \"222 47% 11%\",\n },\n};\n"
32
+ "content": "import type { Theme } from \"@/themes/types\";\n\n/**\n * Classic Paper — visibly warm cream surface; Inter + JetBrains Mono.\n *\n * Identity: cream paper background with sepia warmth (calibrated against the\n * Vintage Paper / Anthropic Claude UI references), deep navy foreground,\n * indigo primary. Optimized for long agent/chat sessions where pure-white\n * surfaces cause vision fatigue (per IxDF 2026 + ACM 2025 light-mode studies).\n *\n * Token calibration (light mode):\n * - background L=0.95 chroma=0.025 hue=80 visibly cream paper\n * - card L=0.97 chroma=0.012 hue=80 — sub-paper layer (cards stand out)\n * - foreground unchanged (deep navy, AAA contrast >12:1 vs background)\n *\n * Dark mirror unchanged.\n */\nexport const classicPaper: Theme = {\n name: \"classic-paper\",\n label: \"Classic Paper\",\n description: \"Inter + paper background. Maximum legibility, conservative.\",\n fonts: {\n display: '\"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif',\n body: '\"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif',\n mono: '\"JetBrains Mono\", ui-monospace, SFMono-Regular, Menlo, monospace',\n },\n fontUrls: [\n \"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap\",\n ],\n light: {\n background: \"oklch(0.95 0.025 80)\",\n foreground: \"oklch(0.206 0.039 265.5)\",\n card: \"oklch(0.97 0.012 80)\",\n \"card-foreground\": \"oklch(0.206 0.039 265.5)\",\n popover: \"oklch(0.98 0.008 80)\",\n \"popover-foreground\": \"oklch(0.206 0.039 265.5)\",\n primary: \"oklch(0.545 0.215 262.7)\",\n \"primary-foreground\": \"oklch(1 0 0)\",\n secondary: \"oklch(0.93 0.02 80)\",\n \"secondary-foreground\": \"oklch(0.206 0.039 265.5)\",\n accent: \"oklch(0.647 0.139 69)\",\n \"accent-foreground\": \"oklch(1 0 0)\",\n muted: \"oklch(0.93 0.02 80)\",\n \"muted-foreground\": \"oklch(0.45 0.03 80)\",\n border: \"oklch(0.88 0.025 80)\",\n input: \"oklch(0.92 0.02 80)\",\n ring: \"oklch(0.545 0.215 262.7)\",\n success: \"oklch(0.611 0.161 149.7)\",\n \"success-foreground\": \"oklch(1 0 0)\",\n warning: \"oklch(0.67 0.154 60.6)\",\n \"warning-foreground\": \"oklch(1 0 0)\",\n destructive: \"oklch(0.579 0.214 27.2)\",\n \"destructive-foreground\": \"oklch(1 0 0)\",\n info: \"oklch(0.626 0.186 259.6)\",\n \"info-foreground\": \"oklch(1 0 0)\",\n \"status-online\": \"oklch(0.611 0.161 149.7)\",\n \"status-online-foreground\": \"oklch(1 0 0)\",\n \"status-offline\": \"oklch(0.579 0.214 27.2)\",\n \"status-offline-foreground\": \"oklch(1 0 0)\",\n \"status-degraded\": \"oklch(0.67 0.154 60.6)\",\n \"status-degraded-foreground\": \"oklch(1 0 0)\",\n \"status-info\": \"oklch(0.626 0.186 259.6)\",\n \"status-info-foreground\": \"oklch(1 0 0)\",\n },\n // Dark mirror — mainly the same hues with inverted lightness so the theme\n // still feels coherent if a consumer toggles `.dark`.\n dark: {\n background: \"oklch(0.177 0.029 265.8)\",\n foreground: \"oklch(0.984 0.003 247.9)\",\n card: \"oklch(0.206 0.039 265.5)\",\n \"card-foreground\": \"oklch(0.984 0.003 247.9)\",\n popover: \"oklch(0.206 0.039 265.5)\",\n \"popover-foreground\": \"oklch(0.984 0.003 247.9)\",\n primary: \"oklch(0.626 0.186 259.6)\",\n \"primary-foreground\": \"oklch(0.206 0.039 265.5)\",\n secondary: \"oklch(0.291 0.022 259.9)\",\n \"secondary-foreground\": \"oklch(0.984 0.003 247.9)\",\n accent: \"oklch(0.803 0.15 74.7)\",\n \"accent-foreground\": \"oklch(0.206 0.039 265.5)\",\n muted: \"oklch(0.291 0.022 259.9)\",\n \"muted-foreground\": \"oklch(0.71 0.035 256.8)\",\n border: \"oklch(0.329 0.026 259.9)\",\n input: \"oklch(0.291 0.022 259.9)\",\n ring: \"oklch(0.626 0.186 259.6)\",\n success: \"oklch(0.814 0.192 155.7)\",\n \"success-foreground\": \"oklch(0.206 0.039 265.5)\",\n warning: \"oklch(0.77 0.165 70.6)\",\n \"warning-foreground\": \"oklch(0.206 0.039 265.5)\",\n destructive: \"oklch(0.677 0.213 15.6)\",\n \"destructive-foreground\": \"oklch(0.206 0.039 265.5)\",\n info: \"oklch(0.732 0.142 254.4)\",\n \"info-foreground\": \"oklch(0.206 0.039 265.5)\",\n \"status-online\": \"oklch(0.814 0.192 155.7)\",\n \"status-online-foreground\": \"oklch(0.206 0.039 265.5)\",\n \"status-offline\": \"oklch(0.677 0.213 15.6)\",\n \"status-offline-foreground\": \"oklch(0.206 0.039 265.5)\",\n \"status-degraded\": \"oklch(0.77 0.165 70.6)\",\n \"status-degraded-foreground\": \"oklch(0.206 0.039 265.5)\",\n \"status-info\": \"oklch(0.732 0.142 254.4)\",\n \"status-info-foreground\": \"oklch(0.206 0.039 265.5)\",\n },\n};\n"
33
33
  },
34
34
  {
35
35
  "path": "themes/aurora-terminal.ts",
36
36
  "type": "registry:lib",
37
37
  "target": "themes/aurora-terminal.ts",
38
- "content": "import type { Theme } from \"@/themes/types\";\n\n/**\n * Aurora Terminal — dark-first, cyan-aurora primary, Geist Mono everywhere.\n *\n * Identity: deep oceanic background, cyan-aurora primary, aurora-pink accent.\n * Headers use Geist with heavier tracking; body uses Geist Mono for full\n * \"developer console\" feel. Suits CLI/devtools showcase.\n */\nexport const auroraTerminal: Theme = {\n name: \"aurora-terminal\",\n label: \"Aurora Terminal\",\n description: \"Dark sci-fi developer console — cyan-aurora + Geist Mono body.\",\n fonts: {\n display: '\"Geist\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif',\n body: '\"Geist\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif',\n mono: '\"Geist Mono\", ui-monospace, SFMono-Regular, Menlo, monospace',\n },\n fontUrls: [\n \"https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Geist+Mono:wght@100..900&display=swap\",\n ],\n light: {\n background: \"220 30% 96%\",\n foreground: \"222 47% 11%\",\n card: \"0 0% 100%\",\n \"card-foreground\": \"222 47% 11%\",\n popover: \"0 0% 100%\",\n \"popover-foreground\": \"222 47% 11%\",\n primary: \"178 78% 41%\",\n \"primary-deep\": \"180 100% 25%\",\n \"primary-glow\": \"180 89% 70%\",\n \"primary-foreground\": \"222 47% 11%\",\n secondary: \"210 40% 96%\",\n \"secondary-foreground\": \"222 47% 11%\",\n accent: \"340 82% 60%\",\n \"accent-deep\": \"340 80% 50%\",\n \"accent-foreground\": \"0 0% 100%\",\n muted: \"214 32% 91%\",\n \"muted-foreground\": \"215 16% 47%\",\n border: \"214 32% 91%\",\n input: \"214 32% 91%\",\n ring: \"178 78% 41%\",\n success: \"152 79% 42%\",\n \"success-foreground\": \"0 0% 100%\",\n warning: \"33 92% 44%\",\n \"warning-foreground\": \"0 0% 100%\",\n destructive: \"0 72% 51%\",\n \"destructive-foreground\": \"0 0% 100%\",\n info: \"217 91% 60%\",\n \"info-foreground\": \"0 0% 100%\",\n },\n dark: {\n background: \"224 36% 7%\",\n foreground: \"220 30% 96%\",\n card: \"224 35% 10%\",\n \"card-foreground\": \"220 30% 96%\",\n popover: \"224 35% 10%\",\n \"popover-foreground\": \"220 30% 96%\",\n primary: \"178 71% 60%\",\n \"primary-deep\": \"180 100% 35%\",\n \"primary-glow\": \"180 89% 80%\",\n \"primary-foreground\": \"224 36% 7%\",\n secondary: \"222 30% 14%\",\n \"secondary-foreground\": \"220 30% 96%\",\n accent: \"340 90% 65%\",\n \"accent-deep\": \"340 80% 50%\",\n \"accent-foreground\": \"224 36% 7%\",\n muted: \"222 30% 14%\",\n \"muted-foreground\": \"215 20% 65%\",\n border: \"222 28% 18%\",\n input: \"222 30% 14%\",\n ring: \"178 71% 60%\",\n success: \"152 79% 52%\",\n \"success-foreground\": \"224 36% 7%\",\n warning: \"38 92% 50%\",\n \"warning-foreground\": \"224 36% 7%\",\n destructive: \"350 100% 65%\",\n \"destructive-foreground\": \"224 36% 7%\",\n info: \"213 100% 70%\",\n \"info-foreground\": \"224 36% 7%\",\n },\n};\n"
38
+ "content": "import type { Theme } from \"@/themes/types\";\n\n/**\n * Aurora Terminal — dark-first, cyan-aurora primary, Geist Mono everywhere.\n *\n * Identity: deep oceanic background, cyan-aurora primary, aurora-pink accent.\n * Headers use Geist with heavier tracking; body uses Geist Mono for full\n * \"developer console\" feel. Suits CLI/devtools showcase.\n */\nexport const auroraTerminal: Theme = {\n name: \"aurora-terminal\",\n label: \"Aurora Terminal\",\n description: \"Dark sci-fi developer console — cyan-aurora + Geist Mono body.\",\n fonts: {\n display: '\"Geist\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif',\n body: '\"Geist\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif',\n mono: '\"Geist Mono\", ui-monospace, SFMono-Regular, Menlo, monospace',\n },\n fontUrls: [\n \"https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Geist+Mono:wght@100..900&display=swap\",\n ],\n light: {\n background: \"oklch(0.966 0.006 264.5)\",\n foreground: \"oklch(0.206 0.039 265.5)\",\n card: \"oklch(1 0 0)\",\n \"card-foreground\": \"oklch(0.206 0.039 265.5)\",\n popover: \"oklch(1 0 0)\",\n \"popover-foreground\": \"oklch(0.206 0.039 265.5)\",\n primary: \"oklch(0.714 0.12 191)\",\n \"primary-foreground\": \"oklch(0.206 0.039 265.5)\",\n secondary: \"oklch(0.968 0.007 247.9)\",\n \"secondary-foreground\": \"oklch(0.206 0.039 265.5)\",\n accent: \"oklch(0.646 0.206 4.9)\",\n \"accent-foreground\": \"oklch(1 0 0)\",\n muted: \"oklch(0.926 0.013 255)\",\n \"muted-foreground\": \"oklch(0.556 0.04 256.8)\",\n border: \"oklch(0.926 0.013 255)\",\n input: \"oklch(0.926 0.013 255)\",\n ring: \"oklch(0.714 0.12 191)\",\n success: \"oklch(0.711 0.171 155.2)\",\n \"success-foreground\": \"oklch(1 0 0)\",\n warning: \"oklch(0.67 0.154 60.6)\",\n \"warning-foreground\": \"oklch(1 0 0)\",\n destructive: \"oklch(0.579 0.214 27.2)\",\n \"destructive-foreground\": \"oklch(1 0 0)\",\n info: \"oklch(0.626 0.186 259.6)\",\n \"info-foreground\": \"oklch(1 0 0)\",\n \"status-online\": \"oklch(0.711 0.171 155.2)\",\n \"status-online-foreground\": \"oklch(1 0 0)\",\n \"status-offline\": \"oklch(0.579 0.214 27.2)\",\n \"status-offline-foreground\": \"oklch(1 0 0)\",\n \"status-degraded\": \"oklch(0.67 0.154 60.6)\",\n \"status-degraded-foreground\": \"oklch(1 0 0)\",\n \"status-info\": \"oklch(0.626 0.186 259.6)\",\n \"status-info-foreground\": \"oklch(1 0 0)\",\n },\n dark: {\n background: \"oklch(0.169 0.021 268.7)\",\n foreground: \"oklch(0.966 0.006 264.5)\",\n card: \"oklch(0.199 0.027 268.5)\",\n \"card-foreground\": \"oklch(0.966 0.006 264.5)\",\n popover: \"oklch(0.199 0.027 268.5)\",\n \"popover-foreground\": \"oklch(0.966 0.006 264.5)\",\n primary: \"oklch(0.834 0.122 191.9)\",\n \"primary-foreground\": \"oklch(0.169 0.021 268.7)\",\n secondary: \"oklch(0.242 0.03 266.3)\",\n \"secondary-foreground\": \"oklch(0.966 0.006 264.5)\",\n accent: \"oklch(0.68 0.2 3.6)\",\n \"accent-foreground\": \"oklch(0.169 0.021 268.7)\",\n muted: \"oklch(0.242 0.03 266.3)\",\n \"muted-foreground\": \"oklch(0.71 0.035 256.8)\",\n border: \"oklch(0.281 0.035 266.3)\",\n input: \"oklch(0.242 0.03 266.3)\",\n ring: \"oklch(0.834 0.122 191.9)\",\n success: \"oklch(0.814 0.192 155.7)\",\n \"success-foreground\": \"oklch(0.169 0.021 268.7)\",\n warning: \"oklch(0.77 0.165 70.6)\",\n \"warning-foreground\": \"oklch(0.169 0.021 268.7)\",\n destructive: \"oklch(0.677 0.213 15.6)\",\n \"destructive-foreground\": \"oklch(0.169 0.021 268.7)\",\n info: \"oklch(0.732 0.142 254.4)\",\n \"info-foreground\": \"oklch(0.169 0.021 268.7)\",\n \"status-online\": \"oklch(0.814 0.192 155.7)\",\n \"status-online-foreground\": \"oklch(0.169 0.021 268.7)\",\n \"status-offline\": \"oklch(0.677 0.213 15.6)\",\n \"status-offline-foreground\": \"oklch(0.169 0.021 268.7)\",\n \"status-degraded\": \"oklch(0.77 0.165 70.6)\",\n \"status-degraded-foreground\": \"oklch(0.169 0.021 268.7)\",\n \"status-info\": \"oklch(0.732 0.142 254.4)\",\n \"status-info-foreground\": \"oklch(0.169 0.021 268.7)\",\n },\n};\n"
39
39
  },
40
40
  {
41
41
  "path": "themes/theme-provider.tsx",
42
42
  "type": "registry:lib",
43
43
  "target": "themes/theme-provider.tsx",
44
- "content": "import {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useState,\n} 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 `[@theokit/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(`[@theokit/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 // SSR-safe initialization (0.6.3-next.0 hydration-mismatch fix).\n //\n // Previously: `useState(() => localStorage.getItem(…))`. The initializer\n // ran on BOTH server (no `window`, returned default) AND client at\n // hydration time (with `window`, returned the stored value). The two\n // results disagreed → React threw a hydration error on every page load\n // for any user who had previously changed themes, and re-rendered the\n // entire tree from scratch — defeating SSR.\n //\n // Fix: initialize with the SSR default ALWAYS. Promote to the stored\n // value in a post-mount `useEffect` below. The 1-frame visual flicker\n // is mitigated by the optional `<ThemeScript>` component, which sets\n // `data-theme` / `data-mode` / `data-density` on `<html>` before React\n // hydrates — see `theme-script.tsx`.\n //\n // The `hydratedRef` flag below guards the persist effect so that\n // first-mount writes (with the SSR default values) don't clobber the\n // user's stored preference in the brief window between mount and the\n // post-mount hydration effect.\n const [themeName, setThemeName] = useState<string>(defaultTheme);\n const [mode, setModeState] = useState<ThemeMode>(defaultMode);\n const [density, setDensityState] = useState<Density>(defaultDensity);\n\n // First-run guard for the persist effect below. On initial mount the\n // state is the SSR-safe default; we MUST NOT clobber the user's stored\n // preference with that default before the post-mount hydration effect\n // can promote it. After the first persist-effect call returns early,\n // every subsequent change (post-hydration setState OR user-driven)\n // persists normally.\n const skipFirstPersistRef = useRef(true);\n\n // Post-mount hydration: read localStorage and promote stored values to\n // state. Runs ONCE on mount. If `storageKey` is null or no value is\n // stored, this is a no-op — state stays at the SSR defaults.\n useEffect(() => {\n if (typeof window === \"undefined\" || !storageKey) return;\n try {\n const storedName = window.localStorage.getItem(`${storageKey}:name`);\n const storedMode = window.localStorage.getItem(`${storageKey}:mode`);\n const storedDensity = window.localStorage.getItem(`${storageKey}:density`);\n if (storedName) setThemeName(storedName);\n if (storedMode === \"dark\" || storedMode === \"light\") setModeState(storedMode);\n if (\n storedDensity === \"compact\" ||\n storedDensity === \"comfortable\" ||\n storedDensity === \"spacious\"\n ) {\n setDensityState(storedDensity);\n }\n } catch (err) {\n warnStorageFailure(\"read theme + mode + density\", err);\n }\n }, [storageKey]);\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 //\n // The first run is SKIPPED via `skipFirstPersistRef`: state at mount\n // is the SSR-safe default. If we wrote it to storage immediately,\n // we'd clobber the user's stored preference between mount and the\n // post-mount hydration effect that promotes the stored value. After\n // the first call, every subsequent run persists — whether the change\n // came from the hydration effect (no-op write back of the stored\n // value) or a user-driven `setTheme` / `toggleMode` / `setDensity`.\n useEffect(() => {\n if (skipFirstPersistRef.current) {\n skipFirstPersistRef.current = false;\n return;\n }\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"
44
+ "content": "import {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport type { JSX, ReactNode } from \"react\";\nimport { safeHref } from \"@/lib/safe-href\";\nimport { COLOR_VALUE_PATTERN } from \"./color-value-pattern\";\nimport { type Density, DensityContext, injectDensityCss } from \"./density\";\nimport { formatThemeIssues, validateTheme } from \"./schema\";\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_VALUE_PATTERN was inlined here pre-T2.5. Now extracted to\n// `color-value-pattern.ts` so the regex is reusable by the Valibot schema (T2.7)\n// and so the OKLCH relative-color syntax (`oklch(from var(--x) calc(l - 0.16) c h)`,\n// required by T3.1 tonal derivations) is supported without expanding the inline\n// regex monolith. See EC-5 in the edge-case review.\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 `[@theokit/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 * Respect the consumer's OS `prefers-color-scheme` preference on initial\n * mount (D6 / T5.1). When `true` (default) and no theme-mode is stored in\n * `localStorage[storageKey]`, the provider reads\n * `matchMedia('(prefers-color-scheme: dark)')` and subscribes to changes.\n * User-driven `setMode()` overrides the system signal — subsequent OS\n * changes are ignored after the user fixes a preference.\n *\n * Pass `false` to force `defaultMode` regardless of the system preference.\n * EC-12: matchMedia listener is cleaned up on unmount.\n */\n respectSystemMode?: boolean;\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(`[@theokit/ui] theme storage failure (${scope}):`, err);\n}\n\n// T2.7: production-mode diagnostic for invalid themes that fall through\n// dev-time throw. Single console.warn so engineer sees something instead of\n// total silence; theme is kept in the array but per-value regex fallback\n// substitutes `transparent` for unsafe values downstream.\nfunction warnInvalidTheme(message: string): void {\n // biome-ignore lint/suspicious/noConsole: dev/prod diagnostic for invalid theme (T2.7/D5)\n console.warn(message);\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 respectSystemMode = true,\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 // T2.7 (D5): valibot shape validation runs FIRST — catches missing keys,\n // wrong types, malformed font URLs. Existing per-value regex validation\n // below stays as the second defense layer against CSS injection.\n for (const t of themesProp) {\n const result = validateTheme(t);\n if (!result.success) {\n const message = formatThemeIssues(\n (t as { name?: string }).name ?? \"(unnamed)\",\n result.issues ?? [],\n );\n if (IS_DEV) throw new Error(message);\n warnInvalidTheme(message);\n // In production we keep the theme array intact; the per-value regex\n // fallbacks below will substitute `transparent` for any value that\n // still fails the second layer, so the app continues rendering.\n }\n }\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 // SSR-safe initialization (0.6.3-next.0 hydration-mismatch fix).\n //\n // Previously: `useState(() => localStorage.getItem(…))`. The initializer\n // ran on BOTH server (no `window`, returned default) AND client at\n // hydration time (with `window`, returned the stored value). The two\n // results disagreed → React threw a hydration error on every page load\n // for any user who had previously changed themes, and re-rendered the\n // entire tree from scratch — defeating SSR.\n //\n // Fix: initialize with the SSR default ALWAYS. Promote to the stored\n // value in a post-mount `useEffect` below. The 1-frame visual flicker\n // is mitigated by the optional `<ThemeScript>` component, which sets\n // `data-theme` / `data-mode` / `data-density` on `<html>` before React\n // hydrates — see `theme-script.tsx`.\n //\n // The `hydratedRef` flag below guards the persist effect so that\n // first-mount writes (with the SSR default values) don't clobber the\n // user's stored preference in the brief window between mount and the\n // post-mount hydration effect.\n const [themeName, setThemeName] = useState<string>(defaultTheme);\n const [mode, setModeState] = useState<ThemeMode>(defaultMode);\n const [density, setDensityState] = useState<Density>(defaultDensity);\n\n // T5.1 / D6: track whether the user has explicitly fixed the mode via\n // setMode / toggleMode. Once true, OS prefers-color-scheme changes do\n // NOT override the user's choice.\n const userOverrodeModeRef = useRef(false);\n\n // First-run guard for the persist effect below. On initial mount the\n // state is the SSR-safe default; we MUST NOT clobber the user's stored\n // preference with that default before the post-mount hydration effect\n // can promote it. After the first persist-effect call returns early,\n // every subsequent change (post-hydration setState OR user-driven)\n // persists normally.\n const skipFirstPersistRef = useRef(true);\n\n // Post-mount hydration: read localStorage and promote stored values to\n // state. Runs ONCE on mount. If `storageKey` is null or no value is\n // stored, this is a no-op — state stays at the SSR defaults.\n useEffect(() => {\n if (typeof window === \"undefined\" || !storageKey) return;\n try {\n const storedName = window.localStorage.getItem(`${storageKey}:name`);\n const storedMode = window.localStorage.getItem(`${storageKey}:mode`);\n const storedDensity = window.localStorage.getItem(`${storageKey}:density`);\n if (storedName) setThemeName(storedName);\n if (storedMode === \"dark\" || storedMode === \"light\") {\n setModeState(storedMode);\n // Stored value implies user fixed a preference at some point.\n userOverrodeModeRef.current = true;\n }\n if (\n storedDensity === \"compact\" ||\n storedDensity === \"comfortable\" ||\n storedDensity === \"spacious\"\n ) {\n setDensityState(storedDensity);\n }\n } catch (err) {\n warnStorageFailure(\"read theme + mode + density\", err);\n }\n }, [storageKey]);\n\n // T5.1 / D6 / EC-12: subscribe to OS prefers-color-scheme when\n // respectSystemMode is true. On mount, if user has NOT overridden the\n // mode (no stored preference), align with the OS signal. Re-aligns on\n // OS changes UNLESS user explicitly fixed a preference. Cleanup on\n // unmount via removeEventListener (EC-12) to avoid listener leaks in\n // micro-frontend scenarios.\n useEffect(() => {\n if (!respectSystemMode) return;\n if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return;\n const mql = window.matchMedia(\"(prefers-color-scheme: dark)\");\n // Align on first run if user hasn't overridden.\n if (!userOverrodeModeRef.current) {\n setModeState(mql.matches ? \"dark\" : \"light\");\n }\n const onChange = (event: MediaQueryListEvent): void => {\n if (!userOverrodeModeRef.current) {\n setModeState(event.matches ? \"dark\" : \"light\");\n }\n };\n mql.addEventListener(\"change\", onChange);\n return () => {\n mql.removeEventListener(\"change\", onChange);\n };\n }, [respectSystemMode]);\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 //\n // The first run is SKIPPED via `skipFirstPersistRef`: state at mount\n // is the SSR-safe default. If we wrote it to storage immediately,\n // we'd clobber the user's stored preference between mount and the\n // post-mount hydration effect that promotes the stored value. After\n // the first call, every subsequent run persists — whether the change\n // came from the hydration effect (no-op write back of the stored\n // value) or a user-driven `setTheme` / `toggleMode` / `setDensity`.\n useEffect(() => {\n if (skipFirstPersistRef.current) {\n skipFirstPersistRef.current = false;\n return;\n }\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) => {\n // T5.1 / D6: fix the user preference; subsequent system changes no longer override.\n userOverrodeModeRef.current = true;\n setModeState(next);\n }, []);\n const toggleMode = useCallback(() => {\n userOverrodeModeRef.current = true;\n setModeState((cur) => (cur === \"light\" ? \"dark\" : \"light\"));\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\";\nexport { defineTheme, type DefineThemeInput } from \"./define\";\nexport { hex, rgb } from \"./color\";\nexport { useDensity, type Density, type DensityContextValue } from \"./density\";\n// Seven new themes — RFC 0007, plan: seven-themes (2026-05-22).\n// Trademark-safe slugs (D1.1) — `*-style` / `*-mono` / `*-glass` suffixes,\n// description includes \"Inspired by, not affiliated with [X]\".\nexport { vercelMono } from \"./vercel-mono\";\nexport { githubDark } from \"./github-dark\";\nexport { dracula } from \"./dracula\";\nexport { oneDark } from \"./one-dark\";\nexport { anthropicStyle } from \"./anthropic-style\";\nexport { openaiStyle } from \"./openai-style\";\nexport { linearGlass } from \"./linear-glass\";\n\nimport { anthropicStyle } from \"./anthropic-style\";\nimport { auroraTerminal } from \"@/themes/aurora-terminal\";\nimport { classicPaper } from \"@/themes/classic-paper\";\nimport { dracula } from \"./dracula\";\nimport { githubDark } from \"./github-dark\";\nimport { linearGlass } from \"./linear-glass\";\nimport { oneDark } from \"./one-dark\";\nimport { openaiStyle } from \"./openai-style\";\nimport { vercelMono } from \"./vercel-mono\";\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 10 available out of the box.\n *\n * EC-4 note: passing the full 10-entry list triggers ~60 KB CSS injection in\n * `<style id=\"theo-ui-theme-vars\">`. For apps focused on 1-2 themes, prefer\n * `themes={[violetForge, dracula]}` to keep the payload at ~12 KB.\n */\nexport const builtinThemes = [\n violetForge,\n classicPaper,\n auroraTerminal,\n vercelMono,\n githubDark,\n dracula,\n oneDark,\n anthropicStyle,\n openaiStyle,\n linearGlass,\n];\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, hexToHsl, rgb, rgbToHslLegacy } from \"./color\";\nexport { useDensity, type Density, type DensityContextValue } from \"./density\";\n// Seven new themes — RFC 0007, plan: seven-themes (2026-05-22).\n// Trademark-safe slugs (D1.1) — `*-style` / `*-mono` / `*-glass` suffixes,\n// description includes \"Inspired by, not affiliated with [X]\".\nexport { vercelMono } from \"./vercel-mono\";\nexport { githubDark } from \"./github-dark\";\nexport { dracula } from \"./dracula\";\nexport { oneDark } from \"./one-dark\";\nexport { anthropicStyle } from \"./anthropic-style\";\nexport { openaiStyle } from \"./openai-style\";\nexport { linearGlass } from \"./linear-glass\";\n\nimport { anthropicStyle } from \"./anthropic-style\";\nimport { auroraTerminal } from \"@/themes/aurora-terminal\";\nimport { classicPaper } from \"@/themes/classic-paper\";\nimport { dracula } from \"./dracula\";\nimport { githubDark } from \"./github-dark\";\nimport { linearGlass } from \"./linear-glass\";\nimport { oneDark } from \"./one-dark\";\nimport { openaiStyle } from \"./openai-style\";\nimport { vercelMono } from \"./vercel-mono\";\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 10 available out of the box.\n *\n * EC-4 note: passing the full 10-entry list triggers ~60 KB CSS injection in\n * `<style id=\"theo-ui-theme-vars\">`. For apps focused on 1-2 themes, prefer\n * `themes={[violetForge, dracula]}` to keep the payload at ~12 KB.\n */\nexport const builtinThemes = [\n violetForge,\n classicPaper,\n auroraTerminal,\n vercelMono,\n githubDark,\n dracula,\n oneDark,\n anthropicStyle,\n openaiStyle,\n linearGlass,\n];\n"
57
57
  }
58
58
  ]
59
59
  }
@@ -9,7 +9,7 @@
9
9
  "path": "styles/tokens.css",
10
10
  "type": "registry:lib",
11
11
  "target": "styles/tokens.css",
12
- "content": "/* Theo UI — Design System \"Violet Forge\" tokens.\n *\n * Tokens normativos. Não edite valores aqui sem revisão do design system.\n * Referência: docs/design-system.md\n *\n * Estratégia:\n * - CSS custom properties para todas as cores, scales e durations.\n * - Default é light. Dark é ativado via [data-theme=\"dark\"] ou .dark.\n * - HSL split (h s l) em vez de hex permite alpha via color-mix() ou hsl(var(--x) / .5).\n *\n * Roxo Theo: #7C3AED → hsl(262 83% 58%)\n * Burnt sienna: #C96442 → hsl(15 54% 53%)\n */\n\n/* `:root` and `[data-theme=\"dark\"]` declarations are intentionally NOT wrapped\n * in `@layer base` so they apply at the document level regardless of whether\n * the consumer's CSS pipeline runs Tailwind's preflight. The texture utilities\n * below remain in `@layer utilities` because that layer affects ordering only,\n * not the cascade root.\n */\n:root {\n /* Base palette ------------------------------------------------------- *\n * Neutrals are 100% desaturated (Vercel-style). Color comes from the\n * primary (violet) + accent (burnt sienna) tokens below — never from\n * surface tints.\n */\n --background: 0 0% 100%; /* #FFFFFF */\n --foreground: 0 0% 4%; /* #0A0A0A */\n\n --card: 0 0% 100%; /* #FFFFFF */\n --card-foreground: 0 0% 4%;\n\n --popover: 0 0% 100%;\n --popover-foreground: 0 0% 4%;\n\n /* Primary — Theo violet (equity) ------------------------------------- */\n --primary: 262 83% 58%; /* #7C3AED */\n --primary-deep: 263 70% 42%; /* #5B21B6 — pressed */\n --primary-glow: 263 90% 76%; /* #A78BFA — hover halo */\n --primary-foreground: 0 0% 100%;\n\n /* Secondary — neutral muted ----------------------------------------- */\n --secondary: 0 0% 96%; /* #F5F5F5 */\n --secondary-foreground: 0 0% 4%;\n\n /* Accent — burnt sienna --------------------------------------------- */\n --accent: 15 54% 53%; /* #C96442 */\n --accent-deep: 15 55% 40%; /* #9C4A2E */\n --accent-foreground: 0 0% 100%;\n\n /* Muted ------------------------------------------------------------- */\n --muted: 0 0% 96%;\n --muted-foreground: 0 0% 45%; /* #737373 */\n\n /* Surfaces, borders, inputs ----------------------------------------- */\n --border: 0 0% 91%; /* #E8E8E8 — Vercel-style hairline */\n --input: 0 0% 91%;\n --ring: 262 83% 58%; /* matches primary */\n\n /* Semantics --------------------------------------------------------- */\n --success: 142 71% 36%; /* #16A34A */\n --success-foreground: 0 0% 100%;\n --warning: 33 92% 44%; /* #D97706 */\n --warning-foreground: 0 0% 100%;\n --destructive: 0 72% 51%; /* #DC2626 */\n --destructive-foreground: 0 0% 100%;\n --info: 217 91% 60%; /* #3B82F6 */\n --info-foreground: 0 0% 100%;\n\n /* Radii ------------------------------------------------------------- */\n --radius-none: 0px;\n --radius-sm: 4px;\n --radius-md: 6px;\n --radius-lg: 10px;\n --radius-xl: 14px;\n --radius-2xl: 20px;\n --radius-full: 9999px;\n /* shadcn compat */\n --radius: 14px;\n\n /* Spacing scale (4px base) ------------------------------------------ */\n --space-1: 4px;\n --space-2: 8px;\n --space-3: 12px;\n --space-4: 16px;\n --space-5: 20px;\n --space-6: 24px;\n --space-8: 32px;\n --space-10: 40px;\n --space-12: 48px;\n --space-16: 64px;\n --space-20: 80px;\n --space-24: 96px;\n --space-32: 128px;\n\n /* Elevation — theme-aware (uses --foreground for shadow ink,\n * --primary for the signature glow, so swapping themes recolors them).\n */\n --shadow-sm: 0 1px 2px 0 hsl(var(--foreground) / 0.06);\n --shadow-md: 0 2px 8px -2px hsl(var(--foreground) / 0.08), 0 1px 3px hsl(var(--foreground) / 0.06);\n --shadow-lg: 0 12px 32px -8px hsl(var(--foreground) / 0.12), 0 4px 12px\n hsl(var(--foreground) / 0.08);\n --shadow-glow: 0 0 24px hsl(var(--primary) / 0.25);\n --shadow-glow-strong: 0 0 32px hsl(var(--primary) / 0.4);\n\n /* Motion ------------------------------------------------------------ */\n --ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);\n --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);\n --ease-snap: cubic-bezier(0.85, 0, 0.15, 1);\n --duration-fast: 120ms;\n --duration-base: 200ms;\n --duration-slow: 360ms;\n --stagger: 60ms;\n\n /* Typography (Geist family — Vercel-inspired Violet Forge) ---------- */\n --font-display: \"Geist\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n --font-body: \"Geist\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n --font-mono: \"Geist Mono\", ui-monospace, SFMono-Regular, Menlo, monospace;\n}\n\n/* Dark mode — dominante --------------------------------------------- *\n * Vercel-aligned grayscale: pure neutrals (0% saturation) for every\n * surface and text layer. Color only in primary (violet), accent (burnt\n * sienna), and semantic tokens (success/warning/destructive/info).\n *\n * Activated exclusively via the `.dark` class on `<html>` (set by\n * `ThemeProvider`, `ThemeScript`, or consumers manually). The previous\n * `[data-theme=\"dark\"]` companion selector was dead — `data-theme` holds\n * the theme NAME, never the literal `\"dark\"`.\n */\n.dark {\n --background: 0 0% 4%; /* #0A0A0A */\n --foreground: 0 0% 96%; /* #F5F5F5 */\n\n --card: 0 0% 7%; /* #121212 */\n --card-foreground: 0 0% 96%;\n\n --popover: 0 0% 9%; /* #171717 */\n --popover-foreground: 0 0% 96%;\n\n --primary: 262 83% 58%;\n --primary-deep: 263 70% 42%;\n --primary-glow: 263 90% 76%;\n --primary-foreground: 0 0% 100%;\n\n --secondary: 0 0% 11%; /* #1C1C1C */\n --secondary-foreground: 0 0% 96%;\n\n --accent: 15 54% 53%;\n --accent-deep: 15 55% 40%;\n --accent-foreground: 0 0% 100%;\n\n --muted: 0 0% 11%;\n --muted-foreground: 0 0% 60%; /* #999 — Vercel gray-500 */\n\n --border: 0 0% 16%; /* #292929 */\n --input: 0 0% 11%;\n --ring: 262 83% 58%;\n\n --success: 152 79% 52%; /* #22E58C */\n --success-foreground: 0 0% 4%;\n --warning: 38 92% 50%; /* #F59E0B */\n --warning-foreground: 0 0% 4%;\n --destructive: 350 100% 65%; /* #FF4F6D */\n --destructive-foreground: 0 0% 4%;\n --info: 213 100% 70%; /* #5FB3FF */\n --info-foreground: 0 0% 4%;\n\n /* In dark mode, shadows are heavier ink (against the dark surface) and\n * the glow brightens. Both still derive from theme tokens.\n */\n --shadow-sm: 0 1px 2px 0 hsl(0 0% 0% / 0.4);\n --shadow-md: 0 2px 8px -2px hsl(0 0% 0% / 0.5), 0 1px 3px hsl(0 0% 0% / 0.4);\n --shadow-lg: 0 12px 32px -8px hsl(0 0% 0% / 0.6), 0 4px 12px hsl(0 0% 0% / 0.4);\n --shadow-glow: 0 0 24px hsl(var(--primary-glow) / 0.4);\n --shadow-glow-strong: 0 0 36px hsl(var(--primary-glow) / 0.6);\n}\n\n/* Reduced motion — neutralizes durations and transitions for users who\n * request prefers-reduced-motion: reduce. Components that use animation as\n * semantic state (e.g. a spinner on a running step) can opt back in with\n * Tailwind's `motion-safe:` prefix.\n *\n * Reference: WCAG 2.3.3 Animation from Interactions (Level AAA).\n */\n@media (prefers-reduced-motion: reduce) {\n :root {\n --duration-fast: 0ms;\n --duration-base: 0ms;\n --duration-slow: 0ms;\n --stagger: 0ms;\n }\n *,\n *::before,\n *::after {\n animation-duration: 0.001ms !important;\n animation-iteration-count: 1 !important;\n transition-duration: 0.001ms !important;\n scroll-behavior: auto !important;\n }\n}\n\n/* Texture utilities (signature) ---------------------------------------- */\n@layer utilities {\n .bg-dotted-violet {\n background-image: radial-gradient(hsl(var(--primary) / 0.08) 1px, transparent 1px);\n background-size: 20px 20px;\n }\n\n .bg-dotted-violet-strong {\n background-image: radial-gradient(hsl(var(--primary) / 0.16) 1px, transparent 1px);\n background-size: 20px 20px;\n }\n\n .bg-hero-glow {\n background-image: radial-gradient(\n ellipse 60% 50% at 70% 0%,\n hsl(var(--primary) / 0.18) 0%,\n transparent 60%\n );\n }\n\n .bg-paper-grain {\n background-image: url(\"data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/%3E%3CfeColorMatrix values='0 0 0 0 0.06 0 0 0 0 0.04 0 0 0 0 0.08 0 0 0 0.18 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E\");\n }\n\n .text-balance {\n text-wrap: balance;\n }\n}\n"
12
+ "content": "/* Theo UI — Design System \"Violet Forge\" tokens.\n *\n * Tokens normativos. Não edite valores aqui sem revisão do design system.\n * Referência: docs/design-system.md\n *\n * Estratégia:\n * - CSS custom properties para todas as cores, scales e durations.\n * - Default é light. Dark é ativado via [data-theme=\"dark\"] ou .dark.\n * - HSL split (h s l) em vez de hex permite alpha via color-mix() ou hsl(var(--x) / .5).\n *\n * Roxo Theo: #7C3AED → hsl(262 83% 58%)\n * Burnt sienna: #C96442 → hsl(15 54% 53%)\n */\n\n/* `:root` and `[data-theme=\"dark\"]` declarations are intentionally NOT wrapped\n * in `@layer base` so they apply at the document level regardless of whether\n * the consumer's CSS pipeline runs Tailwind's preflight. The texture utilities\n * below remain in `@layer utilities` because that layer affects ordering only,\n * not the cascade root.\n */\n:root {\n /* Base palette ------------------------------------------------------- *\n * Neutrals are 100% desaturated (Vercel-style). Color comes from the\n * primary (violet) + accent (burnt sienna) tokens below — never from\n * surface tints.\n */\n --background: oklch(1 0 0); /* #FFFFFF */\n --foreground: oklch(0.146 0 0); /* #0A0A0A */\n\n --card: oklch(1 0 0); /* #FFFFFF */\n --card-foreground: oklch(0.146 0 0);\n\n --popover: oklch(1 0 0);\n --popover-foreground: oklch(0.146 0 0);\n\n /* Primary — Theo violet (equity) ------------------------------------- *\n * Post-T3.1 (ADR-0006): -deep and -glow are derived algorithmically via\n * OKLCH relative-color syntax. Themes that need a per-token override may\n * still declare these explicitly — the cascade resolves the override.\n * `max()`/`min()` clamps protect dark/light themes from L overflow (EC-7).\n */\n --primary: oklch(0.542 0.245 293); /* #7C3AED */\n --primary-deep: oklch(from var(--primary) max(0.05, calc(l - 0.16)) c h);\n --primary-glow: oklch(from var(--primary) min(0.95, calc(l + 0.18)) c h);\n --primary-foreground: oklch(1 0 0);\n\n /* Secondary — neutral muted ----------------------------------------- */\n --secondary: oklch(0.97 0 0); /* #F5F5F5 */\n --secondary-foreground: oklch(0.146 0 0);\n\n /* Accent — burnt sienna --------------------------------------------- *\n * T3.1: accent-deep derived. Override path identical to primary.\n */\n --accent: oklch(0.621 0.132 39); /* #C96442 */\n --accent-deep: oklch(from var(--accent) max(0.05, calc(l - 0.13)) c h);\n --accent-foreground: oklch(1 0 0);\n\n /* Muted ------------------------------------------------------------- */\n --muted: oklch(0.97 0 0);\n --muted-foreground: oklch(0.555 0 0); /* #737373 */\n\n /* Surfaces, borders, inputs ----------------------------------------- */\n --border: oklch(0.931 0 0); /* #E8E8E8 — Vercel-style hairline */\n --input: oklch(0.931 0 0);\n --ring: oklch(0.542 0.245 293); /* matches primary */\n\n /* Semantics --------------------------------------------------------- */\n --success: oklch(0.611 0.161 149.7); /* #16A34A */\n --success-foreground: oklch(1 0 0);\n --warning: oklch(0.67 0.154 60.6); /* #D97706 */\n --warning-foreground: oklch(1 0 0);\n --destructive: oklch(0.579 0.214 27.2); /* #DC2626 */\n --destructive-foreground: oklch(1 0 0);\n --info: oklch(0.626 0.186 259.6); /* #3B82F6 */\n --info-foreground: oklch(1 0 0);\n\n /* Status (D4 — operational state group) ----------------------------- *\n * Separate from semantic group (success/destructive/warning/info, which\n * are action-result colors). Status describes component liveness:\n * online (alive), offline (dead), degraded (alive-but-slow), info (flag).\n * Defaults mirror semantic counterparts; themes override for distinction.\n */\n --status-online: oklch(0.611 0.161 149.7);\n --status-online-foreground: oklch(1 0 0);\n --status-offline: oklch(0.579 0.214 27.2);\n --status-offline-foreground: oklch(1 0 0);\n --status-degraded: oklch(0.67 0.154 60.6);\n --status-degraded-foreground: oklch(1 0 0);\n --status-info: oklch(0.626 0.186 259.6);\n --status-info-foreground: oklch(1 0 0);\n\n /* Radii ------------------------------------------------------------- */\n --radius-none: 0px;\n --radius-sm: 4px;\n --radius-md: 6px;\n --radius-lg: 10px;\n --radius-xl: 14px;\n --radius-2xl: 20px;\n --radius-full: 9999px;\n /* shadcn compat */\n --radius: 14px;\n\n /* Spacing scale (4px base) ------------------------------------------ */\n --space-1: 4px;\n --space-2: 8px;\n --space-3: 12px;\n --space-4: 16px;\n --space-5: 20px;\n --space-6: 24px;\n --space-8: 32px;\n --space-10: 40px;\n --space-12: 48px;\n --space-16: 64px;\n --space-20: 80px;\n --space-24: 96px;\n --space-32: 128px;\n\n /* Elevation — theme-aware (uses --foreground for shadow ink,\n * --primary for the signature glow, so swapping themes recolors them).\n * Post-T2.5: tokens are OKLCH; we compose alpha via color-mix(in oklch, ...)\n * which is perceptually correct AND works with any color-space variable.\n */\n --shadow-sm: 0 1px 2px 0 color-mix(in oklch, var(--foreground) 6%, transparent);\n --shadow-md: 0 2px 8px -2px color-mix(in oklch, var(--foreground) 8%, transparent), 0 1px 3px\n color-mix(in oklch, var(--foreground) 6%, transparent);\n --shadow-lg: 0 12px 32px -8px color-mix(in oklch, var(--foreground) 12%, transparent), 0 4px 12px\n color-mix(in oklch, var(--foreground) 8%, transparent);\n --shadow-glow: 0 0 24px color-mix(in oklch, var(--primary) 25%, transparent);\n --shadow-glow-strong: 0 0 32px color-mix(in oklch, var(--primary) 40%, transparent);\n\n /* Motion ------------------------------------------------------------ */\n --ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);\n --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);\n --ease-snap: cubic-bezier(0.85, 0, 0.15, 1);\n --duration-fast: 120ms;\n --duration-base: 200ms;\n --duration-slow: 360ms;\n --stagger: 60ms;\n\n /* Typography (Geist family — Vercel-inspired Violet Forge) ---------- */\n --font-display: \"Geist\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n --font-body: \"Geist\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n --font-mono: \"Geist Mono\", ui-monospace, SFMono-Regular, Menlo, monospace;\n}\n\n/* Dark mode — dominante --------------------------------------------- *\n * Vercel-aligned grayscale: pure neutrals (0% saturation) for every\n * surface and text layer. Color only in primary (violet), accent (burnt\n * sienna), and semantic tokens (success/warning/destructive/info).\n *\n * Activated exclusively via the `.dark` class on `<html>` (set by\n * `ThemeProvider`, `ThemeScript`, or consumers manually). The previous\n * `[data-theme=\"dark\"]` companion selector was dead — `data-theme` holds\n * the theme NAME, never the literal `\"dark\"`.\n */\n.dark {\n --background: oklch(0.146 0 0); /* #0A0A0A */\n --foreground: oklch(0.97 0 0); /* #F5F5F5 */\n\n --card: oklch(0.182 0 0); /* #121212 */\n --card-foreground: oklch(0.97 0 0);\n\n --popover: oklch(0.204 0 0); /* #171717 */\n --popover-foreground: oklch(0.97 0 0);\n\n --primary: oklch(0.542 0.245 293);\n --primary-deep: oklch(from var(--primary) max(0.05, calc(l - 0.16)) c h);\n --primary-glow: oklch(from var(--primary) min(0.95, calc(l + 0.18)) c h);\n --primary-foreground: oklch(1 0 0);\n\n --secondary: oklch(0.227 0 0); /* #1C1C1C */\n --secondary-foreground: oklch(0.97 0 0);\n\n --accent: oklch(0.621 0.132 39);\n --accent-deep: oklch(from var(--accent) max(0.05, calc(l - 0.13)) c h);\n --accent-foreground: oklch(1 0 0);\n\n --muted: oklch(0.227 0 0);\n --muted-foreground: oklch(0.683 0 0); /* #999 — Vercel gray-500 */\n\n --border: oklch(0.28 0 0); /* #292929 */\n --input: oklch(0.227 0 0);\n --ring: oklch(0.542 0.245 293);\n\n --success: oklch(0.814 0.192 155.7); /* #22E58C */\n --success-foreground: oklch(0.146 0 0);\n --warning: oklch(0.77 0.165 70.6); /* #F59E0B */\n --warning-foreground: oklch(0.146 0 0);\n --destructive: oklch(0.677 0.213 15.6); /* #FF4F6D */\n --destructive-foreground: oklch(0.146 0 0);\n --info: oklch(0.732 0.142 254.4); /* #5FB3FF */\n --info-foreground: oklch(0.146 0 0);\n\n /* Status (D4 — dark mode mirror of light defaults) ------------------ */\n --status-online: oklch(0.814 0.192 155.7);\n --status-online-foreground: oklch(0.146 0 0);\n --status-offline: oklch(0.677 0.213 15.6);\n --status-offline-foreground: oklch(0.146 0 0);\n --status-degraded: oklch(0.77 0.165 70.6);\n --status-degraded-foreground: oklch(0.146 0 0);\n --status-info: oklch(0.732 0.142 254.4);\n --status-info-foreground: oklch(0.146 0 0);\n\n /* In dark mode, shadows are heavier ink (against the dark surface) and\n * the glow brightens. Both still derive from theme tokens.\n * Post-T2.5: OKLCH-aware composition via color-mix() — black ink via\n * the literal oklch(0 0 0) (no theme indirection needed).\n */\n --shadow-sm: 0 1px 2px 0 oklch(0 0 0 / 0.4);\n --shadow-md: 0 2px 8px -2px oklch(0 0 0 / 0.5), 0 1px 3px oklch(0 0 0 / 0.4);\n --shadow-lg: 0 12px 32px -8px oklch(0 0 0 / 0.6), 0 4px 12px oklch(0 0 0 / 0.4);\n --shadow-glow: 0 0 24px color-mix(in oklch, var(--primary-glow) 40%, transparent);\n --shadow-glow-strong: 0 0 36px color-mix(in oklch, var(--primary-glow) 60%, transparent);\n}\n\n/* Forced colors (Windows High Contrast Mode) — D7 / ADR-0008 ----------- *\n * Maps Theo's semantic tokens to system colors so the UI remains usable\n * (and required by WCAG 1.4.1) when the OS forces a high-contrast palette.\n * Decorative textures opt out via `forced-color-adjust: none` (the dotted\n * patterns and hero glow would invert into solid blocks otherwise).\n *\n * Reference: MDN forced-colors media query, WCAG 2.2 SC 1.4.1.\n */\n@media (forced-colors: active) {\n :root,\n .dark {\n --background: Canvas;\n --foreground: CanvasText;\n --card: Canvas;\n --card-foreground: CanvasText;\n --popover: Canvas;\n --popover-foreground: CanvasText;\n --primary: Highlight;\n --primary-foreground: HighlightText;\n --secondary: ButtonFace;\n --secondary-foreground: ButtonText;\n --accent: Highlight;\n --accent-foreground: HighlightText;\n --muted: ButtonFace;\n --muted-foreground: GrayText;\n --border: ButtonBorder;\n --input: ButtonBorder;\n --ring: Highlight;\n }\n\n /* Texture utilities are decorative — keep them out of the system-color map. */\n .bg-dotted-violet,\n .bg-dotted-violet-strong,\n .bg-hero-glow,\n .bg-paper-grain {\n forced-color-adjust: none;\n }\n}\n\n/* Reduced motion — neutralizes durations and transitions for users who\n * request prefers-reduced-motion: reduce. Components that use animation as\n * semantic state (e.g. a spinner on a running step) can opt back in with\n * Tailwind's `motion-safe:` prefix.\n *\n * Reference: WCAG 2.3.3 Animation from Interactions (Level AAA).\n */\n@media (prefers-reduced-motion: reduce) {\n :root {\n --duration-fast: 0ms;\n --duration-base: 0ms;\n --duration-slow: 0ms;\n --stagger: 0ms;\n }\n *,\n *::before,\n *::after {\n animation-duration: 0.001ms !important;\n animation-iteration-count: 1 !important;\n transition-duration: 0.001ms !important;\n scroll-behavior: auto !important;\n }\n}\n\n/* Texture utilities (signature) ---------------------------------------- *\n * Post-T2.5: tokens are OKLCH; we compose alpha via color-mix(in oklch, …),\n * which is perceptually correct and works with any color-space variable.\n */\n@layer utilities {\n .bg-dotted-violet {\n background-image: radial-gradient(\n color-mix(in oklch, var(--primary) 8%, transparent) 1px,\n transparent 1px\n );\n background-size: 20px 20px;\n }\n\n .bg-dotted-violet-strong {\n background-image: radial-gradient(\n color-mix(in oklch, var(--primary) 16%, transparent) 1px,\n transparent 1px\n );\n background-size: 20px 20px;\n }\n\n .bg-hero-glow {\n background-image: radial-gradient(\n ellipse 60% 50% at 70% 0%,\n color-mix(in oklch, var(--primary) 18%, transparent) 0%,\n transparent 60%\n );\n }\n\n .bg-paper-grain {\n background-image: url(\"data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/%3E%3CfeColorMatrix values='0 0 0 0 0.06 0 0 0 0 0.04 0 0 0 0 0.08 0 0 0 0.18 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E\");\n }\n\n .text-balance {\n text-wrap: balance;\n }\n}\n"
13
13
  },
14
14
  {
15
15
  "path": "styles/fonts.css",
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/components/primitives/gateway-status-indicator/gateway-status-indicator.tsx"],"names":[],"mappings":";;;;AAyBA,IAAM,WAAA,GAAmF;AAAA,EACvF,MAAA,EAAQ;AAAA,IACN,GAAA,EAAK,gBAAA;AAAA,IACL,KAAA,EAAO,QAAA;AAAA,IACP,IAAA,EAAM;AAAA,GACR;AAAA,EACA,OAAA,EAAS;AAAA,IACP,GAAA,EAAK,YAAA;AAAA,IACL,KAAA,EAAO,SAAA;AAAA,IACP,IAAA,EAAM;AAAA,GACR;AAAA,EACA,QAAA,EAAU;AAAA,IACR,GAAA,EAAK,cAAA;AAAA,IACL,KAAA,EAAO,UAAA;AAAA,IACP,IAAA,EAAM;AAAA,GACR;AAAA,EACA,YAAA,EAAc;AAAA,IACZ,GAAA,EAAK,aAAA;AAAA,IACL,KAAA,EAAO,oBAAA;AAAA,IACP,IAAA,EAAM;AAAA;AAEV,CAAA;AAEA,SAAS,cAAc,EAAA,EAAuC;AAC5D,EAAA,IAAI,EAAA,KAAO,QAAW,OAAO,IAAA;AAC7B,EAAA,IAAI,CAAC,MAAA,CAAO,QAAA,CAAS,EAAE,CAAA,IAAK,EAAA,GAAK,GAAG,OAAO,IAAA;AAC3C,EAAA,IAAI,EAAA,GAAK,GAAG,OAAO,MAAA;AACnB,EAAA,IAAI,KAAK,GAAA,EAAM,OAAO,GAAG,IAAA,CAAK,KAAA,CAAM,EAAE,CAAC,CAAA,EAAA,CAAA;AACvC,EAAA,OAAO,CAAA,EAAA,CAAI,EAAA,GAAK,GAAA,EAAM,OAAA,CAAQ,CAAC,CAAC,CAAA,CAAA,CAAA;AAClC;AAEO,IAAM,sBAAA,GAAyB,UAAA;AAAA,EACpC,CACE,EAAE,MAAA,EAAQ,SAAA,EAAW,OAAA,GAAU,SAAA,EAAW,SAAA,EAAW,aAAA,EAAe,UAAA,EAAY,GAAG,IAAA,EAAK,EACxF,GAAA,KACG;AACH,IAAA,MAAM,IAAA,GAAO,YAAY,MAAM,CAAA;AAC/B,IAAA,MAAM,OAAA,GAAU,cAAc,SAAS,CAAA;AACvC,IAAA,uBACE,IAAA;AAAA,MAAC,MAAA;AAAA,MAAA;AAAA,QACC,GAAA;AAAA,QACA,IAAA,EAAK,KAAA;AAAA,QACL,cAAY,IAAA,CAAK,IAAA;AAAA,QACjB,SAAA,EAAW,EAAA,CAAG,oDAAA,EAAsD,SAAS,CAAA;AAAA,QAC7E,eAAa,UAAA,IAAc,0BAAA;AAAA,QAC3B,aAAA,EAAa,MAAA;AAAA,QACZ,GAAG,IAAA;AAAA,QAEJ,QAAA,EAAA;AAAA,0BAAA,GAAA;AAAA,YAAC,MAAA;AAAA,YAAA;AAAA,cACC,SAAA,EAAW,EAAA;AAAA,gBACT,kCAAA;AAAA,gBACA,IAAA,CAAK,GAAA;AAAA,gBACL,WAAW,cAAA,IAAkB;AAAA,eAC/B;AAAA,cACA,aAAA,EAAW;AAAA;AAAA,WACb;AAAA,UACC,OAAA,KAAY,SAAA,oBACX,IAAA,CAAC,MAAA,EAAA,EAAK,WAAU,iBAAA,EACb,QAAA,EAAA;AAAA,YAAA,IAAA,CAAK,KAAA;AAAA,YACL,OAAA,KAAY,wBACX,GAAA,CAAC,MAAA,EAAA,EAAK,WAAU,4BAAA,EAA6B,aAAA,EAAY,mBACtD,QAAA,EAAA,OAAA,EACH;AAAA,WAAA,EAEJ;AAAA;AAAA;AAAA,KAEJ;AAAA,EAEJ;AACF;AACA,sBAAA,CAAuB,WAAA,GAAc,wBAAA","file":"chunk-BYZ6OFH4.js","sourcesContent":["import { type HTMLAttributes, forwardRef } from \"react\";\n\nimport { cn } from \"../../../lib/cn.js\";\n\n/**\n * GatewayStatusIndicator — live connection-status dot for a gateway/server.\n *\n * Variants:\n * - compact : colored dot only (sidebar footer use)\n * - labeled : dot + label + optional latency text\n *\n * `reconnecting` state animates with `animate-pulse` and respects\n * `prefers-reduced-motion` automatically (Tailwind defaults).\n */\n\nexport type GatewayStatus = \"online\" | \"offline\" | \"degraded\" | \"reconnecting\";\n\nexport interface GatewayStatusIndicatorProps\n extends Omit<HTMLAttributes<HTMLSpanElement>, \"title\"> {\n status: GatewayStatus;\n latencyMs?: number;\n variant?: \"compact\" | \"labeled\";\n \"data-testid\"?: string;\n}\n\nconst STATUS_META: Record<GatewayStatus, { dot: string; label: string; aria: string }> = {\n online: {\n dot: \"bg-emerald-500\",\n label: \"Online\",\n aria: \"Gateway online\",\n },\n offline: {\n dot: \"bg-red-500\",\n label: \"Offline\",\n aria: \"Gateway offline\",\n },\n degraded: {\n dot: \"bg-amber-500\",\n label: \"Degraded\",\n aria: \"Gateway degraded\",\n },\n reconnecting: {\n dot: \"bg-blue-500\",\n label: \"Reconnecting…\",\n aria: \"Gateway reconnecting\",\n },\n};\n\nfunction formatLatency(ms: number | undefined): string | null {\n if (ms === undefined) return null;\n if (!Number.isFinite(ms) || ms < 0) return null;\n if (ms < 1) return \"<1ms\";\n if (ms < 1000) return `${Math.round(ms)}ms`;\n return `${(ms / 1000).toFixed(1)}s`;\n}\n\nexport const GatewayStatusIndicator = forwardRef<HTMLSpanElement, GatewayStatusIndicatorProps>(\n (\n { status, latencyMs, variant = \"labeled\", className, \"data-testid\": dataTestId, ...rest },\n ref,\n ) => {\n const meta = STATUS_META[status];\n const latency = formatLatency(latencyMs);\n return (\n <span\n ref={ref}\n role=\"img\"\n aria-label={meta.aria}\n className={cn(\"inline-flex items-center gap-2 font-medium text-xs\", className)}\n data-testid={dataTestId ?? \"gateway-status-indicator\"}\n data-status={status}\n {...rest}\n >\n <span\n className={cn(\n \"inline-block size-2 rounded-full\",\n meta.dot,\n status === \"reconnecting\" && \"animate-pulse\",\n )}\n aria-hidden\n />\n {variant === \"labeled\" && (\n <span className=\"text-foreground\">\n {meta.label}\n {latency !== null && (\n <span className=\"ml-1 text-muted-foreground\" data-testid=\"gateway-latency\">\n {latency}\n </span>\n )}\n </span>\n )}\n </span>\n );\n },\n);\nGatewayStatusIndicator.displayName = \"GatewayStatusIndicator\";\n"]}