@sero-ai/ui 0.2.0 → 0.2.1

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 (54) hide show
  1. package/.turbo/turbo-typecheck.log +1 -1
  2. package/LICENSE +201 -0
  3. package/package.json +19 -16
  4. package/src/components/ai-elements/attachments.tsx +4 -4
  5. package/src/components/ai-elements/audio-player.tsx +1 -1
  6. package/src/components/ai-elements/chain-of-thought.tsx +2 -2
  7. package/src/components/ai-elements/confirmation.tsx +2 -2
  8. package/src/components/ai-elements/context.tsx +2 -2
  9. package/src/components/ai-elements/environment-variables.tsx +6 -6
  10. package/src/components/ai-elements/file-tree.tsx +3 -3
  11. package/src/components/ai-elements/inline-citation.tsx +2 -2
  12. package/src/components/ai-elements/jsx-preview.tsx +2 -2
  13. package/src/components/ai-elements/message.tsx +2 -2
  14. package/src/components/ai-elements/mic-selector.tsx +8 -8
  15. package/src/components/ai-elements/open-in-chat.tsx +2 -2
  16. package/src/components/ai-elements/package-info.tsx +4 -4
  17. package/src/components/ai-elements/plan.tsx +2 -2
  18. package/src/components/ai-elements/prompt-input-context.tsx +8 -8
  19. package/src/components/ai-elements/queue.tsx +1 -1
  20. package/src/components/ai-elements/reasoning.tsx +3 -3
  21. package/src/components/ai-elements/schema-display.tsx +7 -7
  22. package/src/components/ai-elements/snippet.tsx +3 -3
  23. package/src/components/ai-elements/sources.tsx +2 -2
  24. package/src/components/ai-elements/terminal.tsx +5 -5
  25. package/src/components/ai-elements/test-results.tsx +8 -8
  26. package/src/components/ai-elements/transcription.tsx +2 -2
  27. package/src/components/ai-elements/voice-selector.tsx +2 -2
  28. package/src/components/ai-elements/web-preview.tsx +5 -6
  29. package/src/components/model-selection/available-model-picker.tsx +1 -1
  30. package/src/components/ui/calendar.tsx +1 -1
  31. package/src/components/ui/carousel.tsx +1 -1
  32. package/src/components/ui/chart.tsx +3 -3
  33. package/src/components/ui/command.tsx +1 -1
  34. package/src/components/ui/field.tsx +2 -2
  35. package/src/components/ui/form.tsx +2 -2
  36. package/src/components/ui/input-otp.tsx +2 -2
  37. package/src/components/ui/input.tsx +3 -1
  38. package/src/components/ui/menubar.tsx +1 -1
  39. package/src/components/ui/native-select.tsx +2 -0
  40. package/src/components/ui/navigation-menu.tsx +1 -1
  41. package/src/components/ui/plugin-safety-disclaimer.tsx +3 -3
  42. package/src/components/ui/progress.tsx +1 -1
  43. package/src/components/ui/resizable.tsx +1 -1
  44. package/src/components/ui/search-input.tsx +26 -27
  45. package/src/components/ui/slider.tsx +7 -2
  46. package/src/components/ui/textarea.tsx +3 -1
  47. package/src/components/ui/toggle-group.tsx +1 -1
  48. package/src/components/ui/tree.tsx +3 -3
  49. package/src/index.ts +3 -0
  50. package/src/styles/globals.css +91 -26
  51. package/src/styles/plugin.css +4 -0
  52. package/src/theme/apply-theme.ts +250 -0
  53. package/src/theme/index.ts +2 -0
  54. package/src/theme/types.ts +253 -0
@@ -7,34 +7,33 @@ interface SearchInputProps extends React.ComponentProps<"input"> {
7
7
  containerClassName?: string
8
8
  endAdornment?: React.ReactNode
9
9
  iconClassName?: string
10
+ ref?: React.Ref<HTMLInputElement>
10
11
  }
11
12
 
12
- const SearchInput = React.forwardRef<HTMLInputElement, SearchInputProps>(
13
- ({ containerClassName, className, endAdornment, iconClassName, ...props }, ref) => {
14
- return (
15
- <div
16
- data-slot="search-input"
17
- className={cn("flex items-center gap-2 px-3", containerClassName)}
18
- >
19
- <SearchIcon
20
- data-slot="search-input-icon"
21
- className={cn("text-muted-foreground size-3.5 shrink-0", iconClassName)}
22
- />
23
- <input
24
- ref={ref}
25
- data-slot="search-input-control"
26
- className={cn(
27
- "placeholder:text-muted-foreground h-9 w-full min-w-0 bg-transparent pl-1.5 text-xs text-foreground outline-none disabled:cursor-not-allowed disabled:opacity-50",
28
- className
29
- )}
30
- {...props}
31
- />
32
- {endAdornment}
33
- </div>
34
- )
35
- }
36
- )
37
-
38
- SearchInput.displayName = "SearchInput"
13
+ function SearchInput({ containerClassName, className, endAdornment, iconClassName, placeholder, "aria-label": ariaLabel, ref, ...props }: SearchInputProps) {
14
+ return (
15
+ <div
16
+ data-slot="search-input"
17
+ className={cn("flex items-center gap-2 px-3", containerClassName)}
18
+ >
19
+ <SearchIcon
20
+ data-slot="search-input-icon"
21
+ className={cn("text-muted-foreground size-3.5 shrink-0", iconClassName)}
22
+ />
23
+ <input
24
+ aria-label={ariaLabel ?? (typeof placeholder === "string" ? placeholder : "Search")}
25
+ placeholder={placeholder}
26
+ ref={ref}
27
+ data-slot="search-input-control"
28
+ className={cn(
29
+ "placeholder:text-muted-foreground h-9 w-full min-w-0 bg-transparent pl-1.5 text-xs text-foreground outline-none disabled:cursor-not-allowed disabled:opacity-50",
30
+ className
31
+ )}
32
+ {...props}
33
+ />
34
+ {endAdornment}
35
+ </div>
36
+ )
37
+ }
39
38
 
40
39
  export { SearchInput }
@@ -20,6 +20,11 @@ function Slider({
20
20
  : [min, max],
21
21
  [value, defaultValue, min, max]
22
22
  )
23
+ const thumbKeysRef = React.useRef<string[]>([])
24
+ while (thumbKeysRef.current.length < _values.length) {
25
+ thumbKeysRef.current.push(`thumb-${thumbKeysRef.current.length}`)
26
+ }
27
+ const thumbKeys = thumbKeysRef.current.slice(0, _values.length)
23
28
 
24
29
  return (
25
30
  <SliderPrimitive.Root
@@ -47,10 +52,10 @@ function Slider({
47
52
  )}
48
53
  />
49
54
  </SliderPrimitive.Track>
50
- {Array.from({ length: _values.length }, (_, index) => (
55
+ {_values.map((_, index) => (
51
56
  <SliderPrimitive.Thumb
52
57
  data-slot="slider-thumb"
53
- key={index}
58
+ key={thumbKeys[index]}
54
59
  className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
55
60
  />
56
61
  ))}
@@ -2,9 +2,11 @@ import * as React from "react"
2
2
 
3
3
  import { cn } from "../../lib/utils"
4
4
 
5
- function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
5
+ function Textarea({ className, placeholder, "aria-label": ariaLabel, ...props }: React.ComponentProps<"textarea">) {
6
6
  return (
7
7
  <textarea
8
+ aria-label={ariaLabel ?? (typeof placeholder === "string" ? placeholder : undefined)}
9
+ placeholder={placeholder}
8
10
  data-slot="textarea"
9
11
  className={cn(
10
12
  "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
@@ -56,7 +56,7 @@ function ToggleGroupItem({
56
56
  ...props
57
57
  }: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
58
58
  VariantProps<typeof toggleVariants>) {
59
- const context = React.useContext(ToggleGroupContext)
59
+ const context = React.use(ToggleGroupContext)
60
60
 
61
61
  return (
62
62
  <ToggleGroupPrimitive.Item
@@ -20,7 +20,7 @@ const TreeContext = React.createContext<TreeContextValue>({
20
20
  });
21
21
 
22
22
  function useTreeContext<T = any>() {
23
- return React.useContext(TreeContext) as TreeContextValue<T>;
23
+ return React.use(TreeContext) as TreeContextValue<T>;
24
24
  }
25
25
 
26
26
  interface TreeProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -61,7 +61,7 @@ interface TreeItemProps<T = any>
61
61
  }
62
62
 
63
63
  /**
64
- * TreeItem full-width row button.
64
+ * TreeItem, full-width row button.
65
65
  *
66
66
  * The row carries hover / selected / drag-target backgrounds so the
67
67
  * highlight spans edge-to-edge (VSCode-style). Indentation is applied
@@ -140,7 +140,7 @@ interface TreeItemLabelProps<T = any>
140
140
  }
141
141
 
142
142
  /**
143
- * TreeItemLabel content wrapper inside a TreeItem.
143
+ * TreeItemLabel, content wrapper inside a TreeItem.
144
144
  *
145
145
  * Layout-only (chevron + children). Background lives on the parent
146
146
  * TreeItem so it spans the full row width.
package/src/index.ts CHANGED
@@ -14,6 +14,9 @@ export { cn } from "./lib/utils";
14
14
  // ── Hooks ──
15
15
  export { useIsMobile } from "./hooks/use-mobile";
16
16
 
17
+ // ── Theme ──
18
+ export * from "./theme";
19
+
17
20
  // ── UI Components ──
18
21
  export * from "./components/ui/accordion";
19
22
  export * from "./components/ui/alert-dialog";
@@ -22,7 +22,7 @@
22
22
  /* Border (light) */
23
23
  --border-subtle: #d4d8e0;
24
24
  --border-default: #bcc1cc;
25
- --border-focus: #6366f1;
25
+ --border-focus: #059669;
26
26
 
27
27
  /* Text (light) */
28
28
  --text-primary: #0f1117;
@@ -30,10 +30,26 @@
30
30
  --text-muted: #6b7280;
31
31
  --text-inverse: #fafafa;
32
32
 
33
- /* Accent (indigo)brand accent colour used by Sero components */
34
- --accent-primary: #6366f1;
35
- --accent-hover: #818cf8;
36
- --accent-muted: #6366f11a;
33
+ /* Brand coloursproduct identity, actions, active UI */
34
+ --brand-primary: #059669;
35
+ --brand-primary-hover: #047857;
36
+ --brand-primary-foreground: #ffffff;
37
+ --brand-primary-muted: color-mix(in srgb, var(--brand-primary) 10%, transparent);
38
+ --brand-primary-subtle: color-mix(in srgb, var(--brand-primary) 15%, transparent);
39
+ --brand-primary-faint: color-mix(in srgb, var(--brand-primary) 3%, transparent);
40
+ --brand-primary-border: color-mix(in srgb, var(--brand-primary) 20%, transparent);
41
+ --brand-secondary: #7c3aed;
42
+ --brand-secondary-hover: #6d28d9;
43
+ --brand-secondary-foreground: #ffffff;
44
+ --brand-secondary-muted: color-mix(in srgb, var(--brand-secondary) 10%, transparent);
45
+ --brand-secondary-subtle: color-mix(in srgb, var(--brand-secondary) 15%, transparent);
46
+ --brand-secondary-faint: color-mix(in srgb, var(--brand-secondary) 3%, transparent);
47
+ --brand-secondary-border: color-mix(in srgb, var(--brand-secondary) 20%, transparent);
48
+
49
+ /* Legacy accent / syntax accent */
50
+ --accent-primary: var(--brand-secondary);
51
+ --accent-hover: var(--brand-secondary-hover);
52
+ --accent-muted: var(--brand-secondary-muted);
37
53
  --accent-code: #7c3aed;
38
54
 
39
55
  /* Status — solid */
@@ -79,10 +95,10 @@
79
95
  --voice-processing-muted: rgba(6, 182, 212, 0.15);
80
96
 
81
97
  /* Semantic colours — indigo accent for notifications/banners */
82
- --banner-primary: #6366f1;
83
- --banner-primary-muted: rgba(99, 102, 241, 0.10);
84
- --banner-primary-subtle: rgba(99, 102, 241, 0.15);
85
- --banner-primary-border: rgba(99, 102, 241, 0.20);
98
+ --banner-primary: var(--brand-secondary);
99
+ --banner-primary-muted: color-mix(in srgb, var(--banner-primary) 10%, transparent);
100
+ --banner-primary-subtle: color-mix(in srgb, var(--banner-primary) 15%, transparent);
101
+ --banner-primary-border: color-mix(in srgb, var(--banner-primary) 20%, transparent);
86
102
 
87
103
  /* Thinking / reasoning gradients */
88
104
  --gradient-thinking-high: linear-gradient(135deg, rgba(245, 158, 11, 0.20), rgba(239, 68, 68, 0.20));
@@ -131,15 +147,15 @@
131
147
  shadcn component (Dialog, Command, Dropdown, Tooltip, …)
132
148
  automatically follows theme changes.
133
149
  NOTE: --accent here is the shadcn "hover/selection bg",
134
- NOT the brand accent colour (that's --accent-primary). */
150
+ NOT a brand colour (use --brand-primary/secondary). */
135
151
  --background: var(--bg-base);
136
152
  --foreground: var(--text-primary);
137
153
  --card: var(--bg-surface);
138
154
  --card-foreground: var(--text-primary);
139
155
  --popover: var(--bg-elevated);
140
156
  --popover-foreground: var(--text-primary);
141
- --primary: var(--text-primary);
142
- --primary-foreground: var(--text-inverse);
157
+ --primary: var(--brand-primary);
158
+ --primary-foreground: var(--brand-primary-foreground);
143
159
  --secondary: var(--bg-surface);
144
160
  --secondary-foreground: var(--text-primary);
145
161
  --muted: var(--bg-muted);
@@ -157,8 +173,8 @@
157
173
  --chart-5: oklch(0.769 0.188 70.08);
158
174
  --sidebar: var(--bg-surface);
159
175
  --sidebar-foreground: var(--text-primary);
160
- --sidebar-primary: var(--accent-primary);
161
- --sidebar-primary-foreground: var(--text-inverse);
176
+ --sidebar-primary: var(--brand-primary);
177
+ --sidebar-primary-foreground: var(--brand-primary-foreground);
162
178
  --sidebar-accent: var(--bg-elevated);
163
179
  --sidebar-accent-foreground: var(--text-primary);
164
180
  --sidebar-border: var(--border-subtle);
@@ -208,6 +224,38 @@
208
224
  --radius-4xl: calc(var(--radius) + 16px);
209
225
  --color-background: var(--background);
210
226
  --color-foreground: var(--foreground);
227
+ --color-brand-primary: var(--brand-primary);
228
+ --color-brand-primary-foreground: var(--brand-primary-foreground);
229
+ --color-brand-primary-muted: var(--brand-primary-muted);
230
+ --color-brand-primary-subtle: var(--brand-primary-subtle);
231
+ --color-brand-primary-faint: var(--brand-primary-faint);
232
+ --color-brand-primary-border: var(--brand-primary-border);
233
+ --color-brand-secondary: var(--brand-secondary);
234
+ --color-brand-secondary-foreground: var(--brand-secondary-foreground);
235
+ --color-brand-secondary-muted: var(--brand-secondary-muted);
236
+ --color-brand-secondary-subtle: var(--brand-secondary-subtle);
237
+ --color-brand-secondary-faint: var(--brand-secondary-faint);
238
+ --color-brand-secondary-border: var(--brand-secondary-border);
239
+ --color-status-success: var(--status-success);
240
+ --color-status-success-muted: var(--status-success-muted);
241
+ --color-status-success-subtle: var(--status-success-subtle);
242
+ --color-status-success-faint: var(--status-success-faint);
243
+ --color-status-success-border: var(--status-success-border);
244
+ --color-status-warning: var(--status-warning);
245
+ --color-status-warning-muted: var(--status-warning-muted);
246
+ --color-status-warning-subtle: var(--status-warning-subtle);
247
+ --color-status-warning-faint: var(--status-warning-faint);
248
+ --color-status-warning-border: var(--status-warning-border);
249
+ --color-status-error: var(--status-error);
250
+ --color-status-error-muted: var(--status-error-muted);
251
+ --color-status-error-subtle: var(--status-error-subtle);
252
+ --color-status-error-faint: var(--status-error-faint);
253
+ --color-status-error-border: var(--status-error-border);
254
+ --color-status-info: var(--status-info);
255
+ --color-status-info-muted: var(--status-info-muted);
256
+ --color-status-info-subtle: var(--status-info-subtle);
257
+ --color-status-info-faint: var(--status-info-faint);
258
+ --color-status-info-border: var(--status-info-border);
211
259
  --color-card: var(--card);
212
260
  --color-card-foreground: var(--card-foreground);
213
261
  --color-popover: var(--popover);
@@ -250,6 +298,7 @@
250
298
  /* Border (dark) */
251
299
  --border-subtle: #27272a;
252
300
  --border-default: #3f3f46;
301
+ --border-focus: #34d399;
253
302
 
254
303
  /* Text (dark) */
255
304
  --text-primary: #fafafa;
@@ -257,10 +306,26 @@
257
306
  --text-muted: #71717a;
258
307
  --text-inverse: #09090b;
259
308
 
260
- /* Accent (dark) */
261
- --accent-primary: #818cf8;
262
- --accent-hover: #a5b4fc;
263
- --accent-muted: #6366f133;
309
+ /* Brand colours (dark) */
310
+ --brand-primary: #34d399;
311
+ --brand-primary-hover: #6ee7b7;
312
+ --brand-primary-foreground: #052e1c;
313
+ --brand-primary-muted: color-mix(in srgb, var(--brand-primary) 10%, transparent);
314
+ --brand-primary-subtle: color-mix(in srgb, var(--brand-primary) 15%, transparent);
315
+ --brand-primary-faint: color-mix(in srgb, var(--brand-primary) 3%, transparent);
316
+ --brand-primary-border: color-mix(in srgb, var(--brand-primary) 20%, transparent);
317
+ --brand-secondary: #c4b5fd;
318
+ --brand-secondary-hover: #ddd6fe;
319
+ --brand-secondary-foreground: #1e1038;
320
+ --brand-secondary-muted: color-mix(in srgb, var(--brand-secondary) 10%, transparent);
321
+ --brand-secondary-subtle: color-mix(in srgb, var(--brand-secondary) 15%, transparent);
322
+ --brand-secondary-faint: color-mix(in srgb, var(--brand-secondary) 3%, transparent);
323
+ --brand-secondary-border: color-mix(in srgb, var(--brand-secondary) 20%, transparent);
324
+
325
+ /* Legacy accent / syntax accent (dark) */
326
+ --accent-primary: var(--brand-secondary);
327
+ --accent-hover: var(--brand-secondary-hover);
328
+ --accent-muted: var(--brand-secondary-muted);
264
329
  --accent-code: #c4b5fd;
265
330
 
266
331
  /* Status — solid (dark) */
@@ -306,10 +371,10 @@
306
371
  --voice-processing-muted: rgba(34, 211, 238, 0.15);
307
372
 
308
373
  /* Banner / notifications (dark) */
309
- --banner-primary: #818cf8;
310
- --banner-primary-muted: rgba(129, 140, 248, 0.10);
311
- --banner-primary-subtle: rgba(129, 140, 248, 0.15);
312
- --banner-primary-border: rgba(129, 140, 248, 0.20);
374
+ --banner-primary: var(--brand-secondary);
375
+ --banner-primary-muted: color-mix(in srgb, var(--banner-primary) 10%, transparent);
376
+ --banner-primary-subtle: color-mix(in srgb, var(--banner-primary) 15%, transparent);
377
+ --banner-primary-border: color-mix(in srgb, var(--banner-primary) 20%, transparent);
313
378
 
314
379
  /* Thinking gradients (dark) */
315
380
  --gradient-thinking-high: linear-gradient(135deg, rgba(245, 158, 11, 0.20), rgba(239, 68, 68, 0.20));
@@ -331,8 +396,8 @@
331
396
  --card-foreground: var(--text-primary);
332
397
  --popover: var(--bg-elevated);
333
398
  --popover-foreground: var(--text-primary);
334
- --primary: var(--text-primary);
335
- --primary-foreground: var(--text-inverse);
399
+ --primary: var(--brand-primary);
400
+ --primary-foreground: var(--brand-primary-foreground);
336
401
  --secondary: var(--bg-surface);
337
402
  --secondary-foreground: var(--text-primary);
338
403
  --muted: var(--bg-muted);
@@ -350,8 +415,8 @@
350
415
  --chart-5: oklch(0.645 0.246 16.439);
351
416
  --sidebar: var(--bg-surface);
352
417
  --sidebar-foreground: var(--text-primary);
353
- --sidebar-primary: var(--accent-primary);
354
- --sidebar-primary-foreground: var(--text-inverse);
418
+ --sidebar-primary: var(--brand-primary);
419
+ --sidebar-primary-foreground: var(--brand-primary-foreground);
355
420
  --sidebar-accent: var(--bg-elevated);
356
421
  --sidebar-accent-foreground: var(--text-primary);
357
422
  --sidebar-border: var(--border-subtle);
@@ -0,0 +1,4 @@
1
+ @import "./globals.css";
2
+
3
+ /* Federated Sero plugins should import this file, then add plugin-local
4
+ @source directives. It keeps Tailwind 4 token names aligned with the shell. */
@@ -0,0 +1,250 @@
1
+ import type {
2
+ ColorTokens,
3
+ ThemePreset,
4
+ } from './types';
5
+
6
+ // ── Token → CSS Variable Mapping ─────────────────────────────
7
+
8
+ /** Maps ColorTokens keys to their CSS custom property names. */
9
+ const COLOR_TOKEN_TO_CSS: Record<keyof ColorTokens, string> = {
10
+ bgBase: '--bg-base',
11
+ bgSurface: '--bg-surface',
12
+ bgElevated: '--bg-elevated',
13
+ bgOverlay: '--bg-overlay',
14
+ bgMuted: '--bg-muted',
15
+ borderSubtle: '--border-subtle',
16
+ borderDefault: '--border-default',
17
+ borderFocus: '--border-focus',
18
+ textPrimary: '--text-primary',
19
+ textSecondary: '--text-secondary',
20
+ textMuted: '--text-muted',
21
+ textInverse: '--text-inverse',
22
+ brandPrimary: '--brand-primary',
23
+ brandPrimaryHover: '--brand-primary-hover',
24
+ brandPrimaryForeground: '--brand-primary-foreground',
25
+ brandSecondary: '--brand-secondary',
26
+ brandSecondaryHover: '--brand-secondary-hover',
27
+ brandSecondaryForeground: '--brand-secondary-foreground',
28
+ accentPrimary: '--accent-primary',
29
+ accentHover: '--accent-hover',
30
+ accentMuted: '--accent-muted',
31
+ accentCode: '--accent-code',
32
+ statusSuccess: '--status-success',
33
+ statusWarning: '--status-warning',
34
+ statusError: '--status-error',
35
+ statusInfo: '--status-info',
36
+ collabPrimary: '--collab-primary',
37
+ voiceRecording: '--voice-recording',
38
+ voiceProcessing: '--voice-processing',
39
+ bannerPrimary: '--banner-primary',
40
+ };
41
+
42
+ /**
43
+ * Derived opacity variants generated from base semantic colours.
44
+ * Each entry is [css-var-name, base-token-key, percentage].
45
+ */
46
+ const DERIVED_OPACITY_VARS: Array<[string, keyof ColorTokens, number]> = [
47
+ ['--brand-primary-muted', 'brandPrimary', 10],
48
+ ['--brand-primary-subtle', 'brandPrimary', 15],
49
+ ['--brand-primary-faint', 'brandPrimary', 3],
50
+ ['--brand-primary-border', 'brandPrimary', 20],
51
+ ['--brand-secondary-muted', 'brandSecondary', 10],
52
+ ['--brand-secondary-subtle', 'brandSecondary', 15],
53
+ ['--brand-secondary-faint', 'brandSecondary', 3],
54
+ ['--brand-secondary-border', 'brandSecondary', 20],
55
+ ['--status-success-muted', 'statusSuccess', 10],
56
+ ['--status-success-subtle', 'statusSuccess', 15],
57
+ ['--status-success-faint', 'statusSuccess', 3],
58
+ ['--status-success-border', 'statusSuccess', 20],
59
+ ['--status-warning-muted', 'statusWarning', 10],
60
+ ['--status-warning-subtle', 'statusWarning', 15],
61
+ ['--status-warning-faint', 'statusWarning', 3],
62
+ ['--status-warning-border', 'statusWarning', 20],
63
+ ['--status-error-muted', 'statusError', 10],
64
+ ['--status-error-subtle', 'statusError', 15],
65
+ ['--status-error-faint', 'statusError', 3],
66
+ ['--status-error-border', 'statusError', 20],
67
+ ['--status-info-muted', 'statusInfo', 10],
68
+ ['--status-info-subtle', 'statusInfo', 15],
69
+ ['--status-info-faint', 'statusInfo', 3],
70
+ ['--status-info-border', 'statusInfo', 20],
71
+ ['--collab-primary-muted', 'collabPrimary', 10],
72
+ ['--collab-primary-subtle', 'collabPrimary', 15],
73
+ ['--collab-primary-border', 'collabPrimary', 20],
74
+ ['--voice-recording-muted', 'voiceRecording', 20],
75
+ ['--voice-processing-muted', 'voiceProcessing', 15],
76
+ ['--banner-primary-muted', 'bannerPrimary', 10],
77
+ ['--banner-primary-subtle', 'bannerPrimary', 15],
78
+ ['--banner-primary-border', 'bannerPrimary', 20],
79
+ ];
80
+
81
+ export interface ApplyThemeOptions {
82
+ loadFont?: (fontStack: string) => void;
83
+ }
84
+
85
+ function tint(color: string, percentage: number): string {
86
+ return `color-mix(in srgb, ${color} ${percentage}%, transparent)`;
87
+ }
88
+
89
+ // ── Apply / Reset ────────────────────────────────────────────
90
+
91
+ /** Apply a theme preset for the given mode. */
92
+ export function applyThemePreset(
93
+ preset: ThemePreset,
94
+ mode: 'light' | 'dark',
95
+ options: ApplyThemeOptions = {},
96
+ ): void {
97
+ const root = document.documentElement;
98
+ const colors = mode === 'dark' ? preset.colors.dark : preset.colors.light;
99
+
100
+ for (const [key, cssVar] of Object.entries(COLOR_TOKEN_TO_CSS)) {
101
+ const value = colors[key as keyof ColorTokens];
102
+ if (value) root.style.setProperty(cssVar, value);
103
+ }
104
+
105
+ for (const [cssVar, baseKey, percentage] of DERIVED_OPACITY_VARS) {
106
+ const baseColor = colors[baseKey];
107
+ if (baseColor) root.style.setProperty(cssVar, tint(baseColor, percentage));
108
+ }
109
+
110
+ if (preset.typography?.fontSans) {
111
+ options.loadFont?.(preset.typography.fontSans);
112
+ root.style.setProperty('--font-sans', preset.typography.fontSans);
113
+ }
114
+ if (preset.typography?.fontMono) {
115
+ options.loadFont?.(preset.typography.fontMono);
116
+ root.style.setProperty('--font-mono', preset.typography.fontMono);
117
+ }
118
+ if (preset.typography?.fontSizeBase) {
119
+ root.style.setProperty('--font-size-base', preset.typography.fontSizeBase);
120
+ }
121
+
122
+ if (preset.spacing?.md) {
123
+ const mdPx = parseFloat(preset.spacing.md);
124
+ if (Number.isFinite(mdPx) && mdPx > 0) {
125
+ root.style.setProperty('--spacing', `${(mdPx / 3).toFixed(3)}px`);
126
+ }
127
+ }
128
+
129
+ if (preset.radius?.md) {
130
+ root.style.setProperty('--radius', preset.radius.md);
131
+ }
132
+
133
+ root.classList.toggle('dark', mode === 'dark');
134
+ }
135
+
136
+ /** All CSS custom properties managed by the theme engine. */
137
+ const ALL_MANAGED_VARS = [
138
+ ...Object.values(COLOR_TOKEN_TO_CSS),
139
+ ...DERIVED_OPACITY_VARS.map(([v]) => v),
140
+ '--font-sans',
141
+ '--font-mono',
142
+ '--font-size-base',
143
+ '--spacing',
144
+ '--radius',
145
+ ];
146
+
147
+ /** Remove all inline theme overrides, reverting to CSS defaults. */
148
+ export function resetTheme(): void {
149
+ const root = document.documentElement;
150
+ for (const cssVar of ALL_MANAGED_VARS) {
151
+ root.style.removeProperty(cssVar);
152
+ }
153
+ }
154
+
155
+ // ── Validation ───────────────────────────────────────────────
156
+
157
+ const LEGACY_REQUIRED_COLOR_KEYS: Array<keyof ColorTokens> = [
158
+ 'bgBase', 'bgSurface', 'bgElevated', 'bgOverlay', 'bgMuted',
159
+ 'borderSubtle', 'borderDefault', 'borderFocus',
160
+ 'textPrimary', 'textSecondary', 'textMuted', 'textInverse',
161
+ 'accentPrimary', 'accentHover', 'accentMuted', 'accentCode',
162
+ 'statusSuccess', 'statusWarning', 'statusError', 'statusInfo',
163
+ 'collabPrimary', 'voiceRecording', 'voiceProcessing', 'bannerPrimary',
164
+ ];
165
+
166
+ /** Validate that a value looks like a CSS colour. */
167
+ function isColorValue(v: unknown): v is string {
168
+ if (typeof v !== 'string') return false;
169
+ return /^(#[0-9a-fA-F]{3,8}|rgba?\(|oklch\(|hsla?\(|color-mix\()/.test(v.trim());
170
+ }
171
+
172
+ function optionalColor(o: Record<string, unknown>, key: keyof ColorTokens): string | undefined {
173
+ const value = o[key];
174
+ return isColorValue(value) ? value : undefined;
175
+ }
176
+
177
+ function normaliseColorTokens(obj: unknown): ColorTokens | null {
178
+ if (!obj || typeof obj !== 'object') return null;
179
+ const o = obj as Record<string, unknown>;
180
+ if (!LEGACY_REQUIRED_COLOR_KEYS.every((k) => isColorValue(o[k]))) return null;
181
+
182
+ const legacy = o as Record<keyof ColorTokens, string>;
183
+ return {
184
+ ...legacy,
185
+ brandPrimary: optionalColor(o, 'brandPrimary') ?? legacy.statusSuccess,
186
+ brandPrimaryHover: optionalColor(o, 'brandPrimaryHover') ?? legacy.statusSuccess,
187
+ brandPrimaryForeground: optionalColor(o, 'brandPrimaryForeground') ?? legacy.textInverse,
188
+ brandSecondary: optionalColor(o, 'brandSecondary') ?? legacy.accentCode,
189
+ brandSecondaryHover: optionalColor(o, 'brandSecondaryHover') ?? legacy.accentHover,
190
+ brandSecondaryForeground: optionalColor(o, 'brandSecondaryForeground') ?? legacy.textInverse,
191
+ };
192
+ }
193
+
194
+ function sanitiseTypography(raw: unknown): ThemePreset['typography'] | undefined {
195
+ if (!raw || typeof raw !== 'object') return undefined;
196
+ const o = raw as Record<string, unknown>;
197
+ const result: Record<string, string> = {};
198
+ for (const key of ['fontSans', 'fontMono', 'fontSizeBase'] as const) {
199
+ if (typeof o[key] === 'string') result[key] = o[key];
200
+ }
201
+ return Object.keys(result).length > 0 ? result : undefined;
202
+ }
203
+
204
+ function sanitiseSpacing(raw: unknown): ThemePreset['spacing'] | undefined {
205
+ if (!raw || typeof raw !== 'object') return undefined;
206
+ const o = raw as Record<string, unknown>;
207
+ const result: Record<string, string> = {};
208
+ for (const key of ['xs', 'sm', 'md', 'lg', 'xl'] as const) {
209
+ if (typeof o[key] === 'string') result[key] = o[key];
210
+ }
211
+ return Object.keys(result).length > 0 ? result : undefined;
212
+ }
213
+
214
+ function sanitiseRadius(raw: unknown): ThemePreset['radius'] | undefined {
215
+ if (!raw || typeof raw !== 'object') return undefined;
216
+ const o = raw as Record<string, unknown>;
217
+ const result: Record<string, string> = {};
218
+ for (const key of ['sm', 'md', 'lg'] as const) {
219
+ if (typeof o[key] === 'string') result[key] = o[key];
220
+ }
221
+ return Object.keys(result).length > 0 ? result : undefined;
222
+ }
223
+
224
+ /** Validate and normalise an unknown value into a ThemePreset. */
225
+ export function validateThemePreset(data: unknown): ThemePreset | null {
226
+ if (!data || typeof data !== 'object') return null;
227
+ const d = data as Record<string, unknown>;
228
+
229
+ if (typeof d.id !== 'string' || !d.id) return null;
230
+ if (typeof d.name !== 'string' || !d.name) return null;
231
+ if (d.version !== 1) return null;
232
+
233
+ const colors = d.colors as Record<string, unknown> | undefined;
234
+ const light = normaliseColorTokens(colors?.light);
235
+ const dark = normaliseColorTokens(colors?.dark);
236
+ if (!light || !dark) return null;
237
+
238
+ return {
239
+ id: d.id,
240
+ name: d.name,
241
+ description: typeof d.description === 'string' ? d.description : undefined,
242
+ author: typeof d.author === 'string' ? d.author : undefined,
243
+ version: 1,
244
+ builtin: d.builtin === true,
245
+ colors: { light, dark },
246
+ typography: sanitiseTypography(d.typography),
247
+ spacing: sanitiseSpacing(d.spacing),
248
+ radius: sanitiseRadius(d.radius),
249
+ };
250
+ }
@@ -0,0 +1,2 @@
1
+ export * from './types';
2
+ export * from './apply-theme';