@sero-ai/ui 0.4.2 → 0.6.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 (91) hide show
  1. package/.turbo/turbo-build.log +636 -628
  2. package/.turbo/turbo-test.log +12 -11
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/CHANGELOG.md +20 -2
  5. package/dist/components/ai-elements/conversation-virtual.cjs +115 -0
  6. package/dist/components/ai-elements/conversation-virtual.d.cts +30 -0
  7. package/dist/components/ai-elements/conversation-virtual.d.ts +30 -0
  8. package/dist/components/ai-elements/conversation-virtual.js +115 -0
  9. package/dist/components/model-selection/available-model-picker.cjs +21 -77
  10. package/dist/components/model-selection/available-model-picker.js +22 -78
  11. package/dist/components/model-selection/model-picker-body.cjs +106 -0
  12. package/dist/components/model-selection/model-picker-body.d.cts +41 -0
  13. package/dist/components/model-selection/model-picker-body.d.ts +41 -0
  14. package/dist/components/model-selection/model-picker-body.js +106 -0
  15. package/dist/components/model-selection/thinking-level-picker.cjs +7 -3
  16. package/dist/components/model-selection/thinking-level-picker.js +7 -3
  17. package/dist/components/model-selection/thinking-picker.cjs +1 -1
  18. package/dist/components/model-selection/thinking-picker.js +1 -1
  19. package/dist/components/ui/calendar.cjs +1 -1
  20. package/dist/components/ui/calendar.js +1 -1
  21. package/dist/components/ui/combobox.cjs +1 -1
  22. package/dist/components/ui/combobox.js +1 -1
  23. package/dist/components/ui/form.d.cts +2 -2
  24. package/dist/components/ui/form.d.ts +2 -2
  25. package/dist/components/ui/input-group.cjs +1 -1
  26. package/dist/components/ui/input-group.js +1 -1
  27. package/dist/components/ui/input-otp.cjs +1 -1
  28. package/dist/components/ui/input-otp.js +1 -1
  29. package/dist/components/ui/input.cjs +3 -3
  30. package/dist/components/ui/input.js +3 -3
  31. package/dist/components/ui/native-select.cjs +2 -2
  32. package/dist/components/ui/native-select.js +2 -2
  33. package/dist/components/ui/select.cjs +1 -1
  34. package/dist/components/ui/select.js +1 -1
  35. package/dist/components/ui/slider.cjs +6 -0
  36. package/dist/components/ui/slider.d.cts +1 -1
  37. package/dist/components/ui/slider.d.ts +1 -1
  38. package/dist/components/ui/slider.js +6 -0
  39. package/dist/components/ui/spinner.d.cts +2 -1
  40. package/dist/components/ui/spinner.d.ts +2 -1
  41. package/dist/components/ui/textarea.cjs +1 -1
  42. package/dist/components/ui/textarea.js +1 -1
  43. package/dist/index.cjs +0 -55
  44. package/dist/index.d.cts +2 -66
  45. package/dist/index.d.ts +2 -66
  46. package/dist/index.js +0 -55
  47. package/dist/reference.cjs +56 -83
  48. package/dist/reference.js +56 -83
  49. package/dist/styles/ai-elements.css +2 -0
  50. package/dist/styles/context-editor.css +2 -0
  51. package/dist/styles/globals.css +32 -0
  52. package/dist/styles/model-selection.css +2 -0
  53. package/dist/styles/plugin.css +4 -1
  54. package/dist/theme/apply-theme.cjs +65 -12
  55. package/dist/theme/apply-theme.js +55 -2
  56. package/dist/theme/index.d.cts +1 -1
  57. package/dist/theme/index.d.ts +1 -1
  58. package/dist/theme/types.cjs +15 -2
  59. package/dist/theme/types.d.cts +21 -1
  60. package/dist/theme/types.d.ts +21 -1
  61. package/dist/theme/types.js +14 -1
  62. package/package.json +44 -30
  63. package/scripts/smoke-dist.mjs +106 -4
  64. package/src/components/ai-elements/conversation-virtual.test.tsx +126 -0
  65. package/src/components/ai-elements/conversation-virtual.tsx +162 -0
  66. package/src/components/model-selection/available-model-picker.tsx +26 -98
  67. package/src/components/model-selection/model-picker-body.test.tsx +152 -0
  68. package/src/components/model-selection/model-picker-body.tsx +188 -0
  69. package/src/components/model-selection/thinking-level-picker.tsx +9 -3
  70. package/src/components/model-selection/thinking-picker.test.tsx +41 -0
  71. package/src/components/model-selection/thinking-picker.tsx +1 -1
  72. package/src/components/ui/calendar.test.tsx +43 -0
  73. package/src/components/ui/calendar.tsx +1 -1
  74. package/src/components/ui/combobox.tsx +1 -1
  75. package/src/components/ui/input-group.tsx +1 -1
  76. package/src/components/ui/input-otp.tsx +1 -1
  77. package/src/components/ui/input.tsx +3 -3
  78. package/src/components/ui/native-select.tsx +2 -2
  79. package/src/components/ui/select.tsx +1 -1
  80. package/src/components/ui/slider.tsx +6 -0
  81. package/src/components/ui/spinner.tsx +1 -1
  82. package/src/components/ui/textarea.tsx +1 -1
  83. package/src/index.ts +4 -63
  84. package/src/styles/ai-elements.css +2 -0
  85. package/src/styles/context-editor.css +2 -0
  86. package/src/styles/globals.css +32 -0
  87. package/src/styles/model-selection.css +2 -0
  88. package/src/styles/plugin.css +4 -1
  89. package/src/theme/apply-theme.test.ts +126 -0
  90. package/src/theme/apply-theme.ts +66 -3
  91. package/src/theme/types.ts +33 -0
package/src/index.ts CHANGED
@@ -1,12 +1,11 @@
1
1
  // @sero-ai/ui — Shared UI components, AI elements, and utilities
2
2
  //
3
- // Prefer importing common primitives from the barrel export:
3
+ // Import primitives, dashboard components, hooks, and utilities from the root:
4
4
  // import { Button, Card, Input, cn, useIsMobile } from "@sero-ai/ui"
5
5
  //
6
- // Deep subpath imports are still supported when needed. Because this package is
7
- // native ESM, consumers should use runtime .js extensions for deep imports:
8
- // import { Button } from "@sero-ai/ui/components/ui/button.js"
9
- // import { cn } from "@sero-ai/ui/lib/utils.js"
6
+ // AI elements have stable public subpaths so their editor and rendering
7
+ // dependencies are loaded only by consumers that use them:
8
+ // import { Message } from "@sero-ai/ui/ai-elements/message"
10
9
 
11
10
  // ── Utilities ──
12
11
  export { cn } from "./lib/utils";
@@ -77,60 +76,6 @@ export * from "./components/ui/toggle-group";
77
76
  export * from "./components/ui/toggle";
78
77
  export * from "./components/ui/tooltip";
79
78
  export * from "./components/ui/tree";
80
- // ── AI Elements ──
81
- export * from "./components/ai-elements/agent";
82
- export * from "./components/ai-elements/artifact";
83
- export * from "./components/ai-elements/attachments";
84
- export * from "./components/ai-elements/audio-player";
85
- export * from "./components/ai-elements/canvas";
86
- export * from "./components/ai-elements/chain-of-thought";
87
- export * from "./components/ai-elements/checkpoint";
88
- export * from "./components/ai-elements/code-block";
89
- export * from "./components/ai-elements/commit";
90
- export * from "./components/ai-elements/confirmation";
91
- export * from "./components/ai-elements/connection";
92
- export * from "./components/ai-elements/context";
93
- export * from "./components/ai-elements/controls";
94
- export * from "./components/ai-elements/conversation";
95
- export * from "./components/ai-elements/edge";
96
- export * from "./components/ai-elements/environment-variables";
97
- export * from "./components/ai-elements/file-tree";
98
- export * from "./components/ai-elements/image";
99
- export * from "./components/ai-elements/inline-citation";
100
- export * from "./components/ai-elements/jsx-preview";
101
- export * from "./components/ai-elements/message";
102
- export * from "./components/ai-elements/mic-selector";
103
- export * from "./components/ai-elements/model-selector";
104
- export * from "./components/ai-elements/node";
105
- export * from "./components/ai-elements/open-in-chat";
106
- export * from "./components/ai-elements/package-info";
107
- export * from "./components/ai-elements/panel";
108
- export * from "./components/ai-elements/persona";
109
- export * from "./components/ai-elements/plan";
110
- export * from "./components/ai-elements/prompt-input-context";
111
- export * from "./components/ai-elements/prompt-input-elements";
112
- export * from "./components/ai-elements/prompt-input-textarea";
113
- export * from "./components/ai-elements/prompt-input";
114
- export * from "./components/ai-elements/queue";
115
- export * from "./components/ai-elements/reasoning";
116
- export * from "./components/ai-elements/sandbox";
117
- export * from "./components/ai-elements/schema-display";
118
- export * from "./components/ai-elements/shimmer";
119
- export * from "./components/ai-elements/snippet";
120
- export * from "./components/ai-elements/sources";
121
- export * from "./components/ai-elements/speech-input";
122
- export * from "./components/ai-elements/stack-trace";
123
- export * from "./components/ai-elements/suggestion";
124
- export * from "./components/ai-elements/task";
125
- export * from "./components/ai-elements/terminal";
126
- export * from "./components/ai-elements/test-results";
127
- export * from "./components/ai-elements/tool";
128
- export * from "./components/ai-elements/toolbar";
129
- export * from "./components/ai-elements/transcription";
130
- export * from "./components/ai-elements/voice-selector-accent";
131
- export * from "./components/ai-elements/voice-selector";
132
- export * from "./components/ai-elements/web-preview";
133
-
134
79
  // ── Dashboard Components ──
135
80
  // Compact presentation components for dashboard widgets and full plugin views.
136
81
  // The discovery catalogue is plain data at "@sero-ai/ui/dashboard-catalog.json";
@@ -138,9 +83,5 @@ export * from "./components/ai-elements/web-preview";
138
83
  export * from "./components/dashboard";
139
84
  export type { DashboardComponentCatalogEntry } from "./components/dashboard/catalog";
140
85
 
141
- // ── Model Selection Components ──
142
- export * from "./components/model-selection/available-model-picker";
143
- export * from "./components/model-selection/model-warning-list";
144
- export * from "./components/model-selection/thinking-level-picker";
145
86
  export { PluginStyleScope, type PluginStyleScopeProps } from './plugin-style-scope';
146
87
  export { usePluginPortalContainer } from './plugin-style-scope-context';
@@ -0,0 +1,2 @@
1
+ /* Import after plugin.css when a federated plugin uses @sero-ai/ui/ai-elements/*. */
2
+ @source "../components/ai-elements";
@@ -0,0 +1,2 @@
1
+ /* Import after plugin.css when a federated plugin uses components/context-editor/*. */
2
+ @source "../components/context-editor";
@@ -504,6 +504,38 @@ html {
504
504
  }
505
505
  }
506
506
 
507
+ /* Native window glass belongs behind the application. Keep the document
508
+ transparent, then restore an opaque base for blocking work surfaces. */
509
+ html.theme-glass body {
510
+ background-color: transparent;
511
+ }
512
+
513
+ .theme-glass :is(.window-glass-sidebar, [data-slot="sidebar"]) {
514
+ background-color: var(--window-glass-sidebar);
515
+ box-shadow: inset -1px 0 0 var(--border-subtle);
516
+ }
517
+
518
+ .theme-glass :is(
519
+ [data-slot="dialog-content"],
520
+ [data-slot="alert-dialog-content"],
521
+ [data-slot="sheet-content"],
522
+ [data-slot="drawer-content"],
523
+ [data-slot="popover-content"],
524
+ [data-slot="dropdown-menu-content"],
525
+ [data-slot="context-menu-content"],
526
+ [data-slot="select-content"]
527
+ ) {
528
+ --background: var(--window-glass-opaque-base);
529
+ --card: var(--window-glass-opaque-surface);
530
+ --secondary: var(--window-glass-opaque-elevated);
531
+ --sidebar: var(--window-glass-opaque-surface);
532
+ --sidebar-accent: var(--window-glass-opaque-elevated);
533
+ --bg-base: var(--window-glass-opaque-base);
534
+ --bg-surface: var(--window-glass-opaque-surface);
535
+ --bg-elevated: var(--window-glass-opaque-elevated);
536
+ background-color: var(--window-glass-opaque-base);
537
+ }
538
+
507
539
  @media (prefers-reduced-motion: reduce) {
508
540
  *,
509
541
  *::before,
@@ -0,0 +1,2 @@
1
+ /* Import after plugin.css when a federated plugin uses @sero-ai/ui/model-selection/*. */
2
+ @source "../components/model-selection";
@@ -3,7 +3,10 @@
3
3
  @import "shadcn/tailwind.css";
4
4
  @import "./plugin-theme.css";
5
5
 
6
- @source "../components";
6
+ /* Match the lightweight package root. Specialized components opt into their
7
+ own source files through a matching stylesheet under styles/. */
8
+ @source "../components/ui";
9
+ @source "../components/dashboard";
7
10
 
8
11
  @custom-variant dark (&:is(.dark *));
9
12
 
@@ -0,0 +1,126 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it } from 'vitest';
4
+ import { applyThemePreset, resetTheme, validateThemePreset } from './apply-theme';
5
+ import {
6
+ DEFAULT_DARK_COLORS,
7
+ DEFAULT_GLASS_EFFECT,
8
+ DEFAULT_LIGHT_COLORS,
9
+ type ThemePreset,
10
+ } from './types';
11
+
12
+ function createPreset(overrides: Partial<ThemePreset> = {}): ThemePreset {
13
+ return {
14
+ id: 'glass-test',
15
+ name: 'Glass test',
16
+ version: 1,
17
+ colors: {
18
+ light: { ...DEFAULT_LIGHT_COLORS },
19
+ dark: { ...DEFAULT_DARK_COLORS },
20
+ },
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ describe('theme glass effect', () => {
26
+ afterEach(() => resetTheme());
27
+
28
+ it('separates translucent layers and retains solid popup colors', () => {
29
+ applyThemePreset(createPreset({
30
+ glass: { enabled: true, opacity: 0.64 },
31
+ }), 'dark');
32
+
33
+ const root = document.documentElement;
34
+ expect(root.classList.contains('theme-glass')).toBe(true);
35
+ expect(root.style.getPropertyValue('--bg-base')).toBe(
36
+ 'color-mix(in srgb, #0a0a0b 64%, transparent)',
37
+ );
38
+ expect(root.style.getPropertyValue('--bg-surface')).toBe(
39
+ 'color-mix(in srgb, #111113 18%, transparent)',
40
+ );
41
+ expect(root.style.getPropertyValue('--window-glass-opaque-surface')).toBe('#111113');
42
+ expect(root.style.getPropertyValue('--bg-elevated')).toBe(
43
+ 'color-mix(in srgb, #fafafa 8%, transparent)',
44
+ );
45
+ expect(root.style.getPropertyValue('--window-glass-opaque-elevated')).toBe('#18181b');
46
+ expect(root.style.getPropertyValue('--window-glass-sidebar')).toBe(
47
+ 'color-mix(in srgb, #111113 8%, transparent)',
48
+ );
49
+ expect(root.style.getPropertyValue('--window-glass-opaque-base')).toBe(
50
+ '#0a0a0b',
51
+ );
52
+ });
53
+
54
+ it('restores opaque surfaces when the next theme disables glass', () => {
55
+ applyThemePreset(createPreset({
56
+ glass: { enabled: true, opacity: 0.64 },
57
+ }), 'dark');
58
+ applyThemePreset(createPreset(), 'dark');
59
+
60
+ const root = document.documentElement;
61
+ expect(root.classList.contains('theme-glass')).toBe(false);
62
+ expect(root.style.getPropertyValue('--bg-base')).toBe('#0a0a0b');
63
+ expect(root.style.getPropertyValue('--bg-surface')).toBe('#111113');
64
+ expect(root.style.getPropertyValue('--bg-elevated')).toBe('#18181b');
65
+ expect(root.style.getPropertyValue('--window-glass-opaque-surface')).toBe('');
66
+ expect(root.style.getPropertyValue('--window-glass-sidebar')).toBe('');
67
+ expect(root.style.getPropertyValue('--window-glass-opaque-base')).toBe('');
68
+ });
69
+
70
+ it('keeps panel boundaries and soft selections at zero window tint in light mode', () => {
71
+ applyThemePreset(createPreset({
72
+ glass: { enabled: true, opacity: 0 },
73
+ }), 'light');
74
+
75
+ const root = document.documentElement;
76
+ expect(root.style.getPropertyValue('--bg-base')).toBe(
77
+ 'color-mix(in srgb, #ffffff 0%, transparent)',
78
+ );
79
+ expect(root.style.getPropertyValue('--window-glass-sidebar')).toBe(
80
+ 'color-mix(in srgb, #f4f5f7 8%, transparent)',
81
+ );
82
+ });
83
+
84
+ it.each([
85
+ [-1, 0],
86
+ [2, 1],
87
+ ])('clamps persisted glass opacity %s to %s', (opacity, expected) => {
88
+ const preset = createPreset({ glass: { enabled: true, opacity } });
89
+
90
+ expect(validateThemePreset(preset)?.glass).toEqual({
91
+ ...DEFAULT_GLASS_EFFECT,
92
+ enabled: true,
93
+ opacity: expected,
94
+ });
95
+ });
96
+ it.each(['light', 'dark'] as const)('can clear every local layer in %s mode independently of native material', (mode) => {
97
+ const preset = createPreset({ glass: {
98
+ enabled: true, opacity: 0, blurRadius: 32, windowsMaterial: 'acrylic', sidebarOpacity: 0,
99
+ surfaceOpacity: 0, selectionOpacity: 0, borderOpacity: 0,
100
+ } });
101
+ const savedTheme = JSON.stringify(preset);
102
+ const validated = validateThemePreset(JSON.parse(savedTheme));
103
+ expect(validated?.glass).toEqual(preset.glass);
104
+ if (!validated) throw new Error('Expected valid theme');
105
+ applyThemePreset(validated, mode);
106
+ const style = document.documentElement.style;
107
+ for (const key of ['--bg-base', '--window-glass-sidebar', '--bg-surface', '--bg-elevated', '--border-default']) {
108
+ expect(style.getPropertyValue(key)).toContain(' 0%, transparent)');
109
+ }
110
+ applyThemePreset({ ...validated, glass: { ...preset.glass!, surfaceOpacity: 0.4 } }, mode);
111
+ expect(style.getPropertyValue('--bg-surface')).toContain(' 40%, transparent)');
112
+ expect(style.getPropertyValue('--window-glass-sidebar')).toContain(' 0%, transparent)');
113
+ });
114
+
115
+ it('normalises invalid saved glass controls', () => {
116
+ const preset = createPreset();
117
+ const validated = validateThemePreset({ ...preset, glass: {
118
+ enabled: true, opacity: NaN, sidebarOpacity: -1, surfaceOpacity: 4,
119
+ selectionOpacity: Infinity, borderOpacity: 'bad', blurRadius: Infinity, windowsMaterial: 'invalid',
120
+ } });
121
+ expect(validated?.glass).toEqual({
122
+ ...DEFAULT_GLASS_EFFECT, enabled: true, sidebarOpacity: 0, surfaceOpacity: 1,
123
+ });
124
+ });
125
+
126
+ });
@@ -1,6 +1,9 @@
1
- import type {
2
- ColorTokens,
3
- ThemePreset,
1
+ import {
2
+ DEFAULT_GLASS_EFFECT,
3
+ WINDOWS_GLASS_MATERIALS,
4
+ type ColorTokens,
5
+ type ThemeGlassEffect,
6
+ type ThemePreset,
4
7
  } from './types';
5
8
 
6
9
  // ── Token → CSS Variable Mapping ─────────────────────────────
@@ -86,6 +89,38 @@ function tint(color: string, percentage: number): string {
86
89
  return `color-mix(in srgb, ${color} ${percentage}%, transparent)`;
87
90
  }
88
91
 
92
+ function applyGlassEffect(
93
+ root: HTMLElement,
94
+ glass: ThemeGlassEffect | undefined,
95
+ colors: ColorTokens,
96
+ ): void {
97
+ const enabled = glass?.enabled === true;
98
+ root.classList.toggle('theme-glass', enabled);
99
+ root.style.removeProperty('--window-glass-opaque-base');
100
+ root.style.removeProperty('--window-glass-sidebar');
101
+ root.style.removeProperty('--window-glass-opaque-surface');
102
+ root.style.removeProperty('--window-glass-opaque-elevated');
103
+ if (!enabled) return;
104
+
105
+ const opacity = Math.min(1, Math.max(0, glass.opacity));
106
+ const percentage = Math.round(opacity * 100);
107
+ root.style.setProperty('--bg-base', tint(colors.bgBase, percentage));
108
+ root.style.setProperty(
109
+ '--window-glass-sidebar',
110
+ tint(colors.bgSurface, (glass.sidebarOpacity ?? DEFAULT_GLASS_EFFECT.sidebarOpacity) * 100),
111
+ );
112
+ root.style.setProperty('--window-glass-opaque-base', colors.bgBase);
113
+ root.style.setProperty('--window-glass-opaque-surface', colors.bgSurface);
114
+ root.style.setProperty('--window-glass-opaque-elevated', colors.bgElevated);
115
+ // Thin local layers preserve the wallpaper while separating nested content.
116
+ root.style.setProperty('--bg-surface', tint(colors.bgSurface, (glass.surfaceOpacity ?? DEFAULT_GLASS_EFFECT.surfaceOpacity) * 100));
117
+ root.style.setProperty('--bg-elevated', tint(colors.textPrimary, (glass.selectionOpacity ?? DEFAULT_GLASS_EFFECT.selectionOpacity) * 100));
118
+ root.style.setProperty('--text-muted', colors.textSecondary);
119
+ const border = (glass.borderOpacity ?? DEFAULT_GLASS_EFFECT.borderOpacity) * 100;
120
+ root.style.setProperty('--border-subtle', tint(colors.textPrimary, border * 2 / 3));
121
+ root.style.setProperty('--border-default', tint(colors.textPrimary, border));
122
+ }
123
+
89
124
  // ── Apply / Reset ────────────────────────────────────────────
90
125
 
91
126
  /** Apply a theme preset for the given mode. */
@@ -130,6 +165,8 @@ export function applyThemePreset(
130
165
  root.style.setProperty('--radius', preset.radius.md);
131
166
  }
132
167
 
168
+ applyGlassEffect(root, preset.glass, colors);
169
+
133
170
  root.classList.toggle('dark', mode === 'dark');
134
171
  }
135
172
 
@@ -142,6 +179,10 @@ const ALL_MANAGED_VARS = [
142
179
  '--font-size-base',
143
180
  '--spacing',
144
181
  '--radius',
182
+ '--window-glass-opaque-base',
183
+ '--window-glass-sidebar',
184
+ '--window-glass-opaque-surface',
185
+ '--window-glass-opaque-elevated',
145
186
  ];
146
187
 
147
188
  /** Remove all inline theme overrides, reverting to CSS defaults. */
@@ -150,6 +191,7 @@ export function resetTheme(): void {
150
191
  for (const cssVar of ALL_MANAGED_VARS) {
151
192
  root.style.removeProperty(cssVar);
152
193
  }
194
+ root.classList.remove('theme-glass');
153
195
  }
154
196
 
155
197
  // ── Validation ───────────────────────────────────────────────
@@ -221,6 +263,26 @@ function sanitiseRadius(raw: unknown): ThemePreset['radius'] | undefined {
221
263
  return Object.keys(result).length > 0 ? result : undefined;
222
264
  }
223
265
 
266
+ function sanitiseGlass(raw: unknown): ThemeGlassEffect | undefined {
267
+ if (!raw || typeof raw !== 'object') return undefined;
268
+ const value = raw as Record<string, unknown>;
269
+ if (typeof value.enabled !== 'boolean') return undefined;
270
+
271
+ const result = { ...DEFAULT_GLASS_EFFECT, enabled: value.enabled };
272
+ for (const key of ['opacity', 'sidebarOpacity', 'surfaceOpacity', 'selectionOpacity', 'borderOpacity'] as const) {
273
+ const amount = value[key];
274
+ if (typeof amount === 'number' && Number.isFinite(amount)) {
275
+ result[key] = Math.min(1, Math.max(0, amount));
276
+ }
277
+ }
278
+ if (typeof value.blurRadius === 'number' && Number.isFinite(value.blurRadius)) {
279
+ result.blurRadius = Math.round(Math.min(64, Math.max(0, value.blurRadius)));
280
+ }
281
+ result.windowsMaterial = WINDOWS_GLASS_MATERIALS.find((material) => material === value.windowsMaterial)
282
+ ?? DEFAULT_GLASS_EFFECT.windowsMaterial;
283
+ return result;
284
+ }
285
+
224
286
  /** Validate and normalise an unknown value into a ThemePreset. */
225
287
  export function validateThemePreset(data: unknown): ThemePreset | null {
226
288
  if (!data || typeof data !== 'object') return null;
@@ -246,5 +308,6 @@ export function validateThemePreset(data: unknown): ThemePreset | null {
246
308
  typography: sanitiseTypography(d.typography),
247
309
  spacing: sanitiseSpacing(d.spacing),
248
310
  radius: sanitiseRadius(d.radius),
311
+ glass: sanitiseGlass(d.glass),
249
312
  };
250
313
  }
@@ -105,6 +105,25 @@ export interface RadiusTokens {
105
105
  lg?: string;
106
106
  }
107
107
 
108
+ export const WINDOWS_GLASS_MATERIALS = ['acrylic', 'mica', 'tabbed'] as const;
109
+ export type WindowsGlassMaterial = typeof WINDOWS_GLASS_MATERIALS[number];
110
+
111
+ export interface ThemeGlassEffect {
112
+ /** Show the desktop through Sero's surfaces. */
113
+ enabled: boolean;
114
+ /** Window tint opacity from 0 to 1. */
115
+ opacity: number;
116
+ /** Direct macOS desktop blur radius, from 0 to 64 pixels. */
117
+ blurRadius?: number;
118
+ /** Windows 11 system backdrop. */
119
+ windowsMaterial?: WindowsGlassMaterial;
120
+ /** Independent local layer opacities from 0 to 1. */
121
+ sidebarOpacity?: number;
122
+ surfaceOpacity?: number;
123
+ selectionOpacity?: number;
124
+ borderOpacity?: number;
125
+ }
126
+
108
127
  // ── Theme Preset ─────────────────────────────────────────────
109
128
 
110
129
  export interface ThemePreset {
@@ -141,6 +160,9 @@ export interface ThemePreset {
141
160
 
142
161
  /** Border radius overrides. */
143
162
  radius?: RadiusTokens;
163
+
164
+ /** Optional translucent desktop window treatment. */
165
+ glass?: ThemeGlassEffect;
144
166
  }
145
167
 
146
168
  /** Lightweight metadata for listing presets without loading full data. */
@@ -218,6 +240,17 @@ export const DEFAULT_RADIUS: Required<RadiusTokens> = {
218
240
  lg: '12px',
219
241
  };
220
242
 
243
+ export const DEFAULT_GLASS_EFFECT: Required<ThemeGlassEffect> = {
244
+ enabled: false,
245
+ opacity: 0.78,
246
+ blurRadius: 24,
247
+ windowsMaterial: 'acrylic',
248
+ sidebarOpacity: 0.08,
249
+ surfaceOpacity: 0.18,
250
+ selectionOpacity: 0.08,
251
+ borderOpacity: 0.18,
252
+ };
253
+
221
254
  /** Default colour tokens matching globals.css .dark values. */
222
255
  export const DEFAULT_DARK_COLORS: ColorTokens = {
223
256
  bgBase: '#0a0a0b',