@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.
- package/.turbo/turbo-typecheck.log +1 -1
- package/LICENSE +201 -0
- package/package.json +19 -16
- package/src/components/ai-elements/attachments.tsx +4 -4
- package/src/components/ai-elements/audio-player.tsx +1 -1
- package/src/components/ai-elements/chain-of-thought.tsx +2 -2
- package/src/components/ai-elements/confirmation.tsx +2 -2
- package/src/components/ai-elements/context.tsx +2 -2
- package/src/components/ai-elements/environment-variables.tsx +6 -6
- package/src/components/ai-elements/file-tree.tsx +3 -3
- package/src/components/ai-elements/inline-citation.tsx +2 -2
- package/src/components/ai-elements/jsx-preview.tsx +2 -2
- package/src/components/ai-elements/message.tsx +2 -2
- package/src/components/ai-elements/mic-selector.tsx +8 -8
- package/src/components/ai-elements/open-in-chat.tsx +2 -2
- package/src/components/ai-elements/package-info.tsx +4 -4
- package/src/components/ai-elements/plan.tsx +2 -2
- package/src/components/ai-elements/prompt-input-context.tsx +8 -8
- package/src/components/ai-elements/queue.tsx +1 -1
- package/src/components/ai-elements/reasoning.tsx +3 -3
- package/src/components/ai-elements/schema-display.tsx +7 -7
- package/src/components/ai-elements/snippet.tsx +3 -3
- package/src/components/ai-elements/sources.tsx +2 -2
- package/src/components/ai-elements/terminal.tsx +5 -5
- package/src/components/ai-elements/test-results.tsx +8 -8
- package/src/components/ai-elements/transcription.tsx +2 -2
- package/src/components/ai-elements/voice-selector.tsx +2 -2
- package/src/components/ai-elements/web-preview.tsx +5 -6
- package/src/components/model-selection/available-model-picker.tsx +1 -1
- package/src/components/ui/calendar.tsx +1 -1
- package/src/components/ui/carousel.tsx +1 -1
- package/src/components/ui/chart.tsx +3 -3
- package/src/components/ui/command.tsx +1 -1
- package/src/components/ui/field.tsx +2 -2
- package/src/components/ui/form.tsx +2 -2
- package/src/components/ui/input-otp.tsx +2 -2
- package/src/components/ui/input.tsx +3 -1
- package/src/components/ui/menubar.tsx +1 -1
- package/src/components/ui/native-select.tsx +2 -0
- package/src/components/ui/navigation-menu.tsx +1 -1
- package/src/components/ui/plugin-safety-disclaimer.tsx +3 -3
- package/src/components/ui/progress.tsx +1 -1
- package/src/components/ui/resizable.tsx +1 -1
- package/src/components/ui/search-input.tsx +26 -27
- package/src/components/ui/slider.tsx +7 -2
- package/src/components/ui/textarea.tsx +3 -1
- package/src/components/ui/toggle-group.tsx +1 -1
- package/src/components/ui/tree.tsx +3 -3
- package/src/index.ts +3 -0
- package/src/styles/globals.css +91 -26
- package/src/styles/plugin.css +4 -0
- package/src/theme/apply-theme.ts +250 -0
- package/src/theme/index.ts +2 -0
- 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
|
-
|
|
13
|
-
(
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
{
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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";
|
package/src/styles/globals.css
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
/* Border (light) */
|
|
23
23
|
--border-subtle: #d4d8e0;
|
|
24
24
|
--border-default: #bcc1cc;
|
|
25
|
-
--border-focus: #
|
|
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
|
-
/*
|
|
34
|
-
--
|
|
35
|
-
--
|
|
36
|
-
--
|
|
33
|
+
/* Brand colours — product 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:
|
|
83
|
-
--banner-primary-muted:
|
|
84
|
-
--banner-primary-subtle:
|
|
85
|
-
--banner-primary-border:
|
|
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
|
|
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(--
|
|
142
|
-
--primary-foreground: var(--
|
|
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(--
|
|
161
|
-
--sidebar-primary-foreground: var(--
|
|
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
|
-
/*
|
|
261
|
-
--
|
|
262
|
-
--
|
|
263
|
-
--
|
|
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:
|
|
310
|
-
--banner-primary-muted:
|
|
311
|
-
--banner-primary-subtle:
|
|
312
|
-
--banner-primary-border:
|
|
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(--
|
|
335
|
-
--primary-foreground: var(--
|
|
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(--
|
|
354
|
-
--sidebar-primary-foreground: var(--
|
|
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,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
|
+
}
|