@vobs/theme 1.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vobs contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # @vobs/theme
2
+
3
+ Token-based theming with light/dark/system modes, CSS variable output, and scoped theme boundaries.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @vobs/theme
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```ts
14
+ import { createTheme } from '@vobs/theme'
15
+
16
+ const theme = createTheme({
17
+ defaultMode: 'system',
18
+ themes: {
19
+ light: { brand: { primary: '#2563eb' } },
20
+ dark: { brand: { primary: '#60a5fa' } }
21
+ }
22
+ })
23
+
24
+ theme.resolvedMode.value // 'light' or 'dark', follows prefers-color-scheme
25
+ theme.setMode('dark')
26
+ theme.theme.value.brand.primary // '#60a5fa'
27
+
28
+ const unregister = theme.registerTheme('dark', { density: 'compact' })
29
+ theme.setBrand({ secondary: '#0f766e' })
30
+ unregister()
31
+ ```
32
+
33
+ ## API
34
+
35
+ | Signature | Description |
36
+ | --- | --- |
37
+ | `createTheme(options?: ThemeOptions): ThemeContext` | Creates a theme context; `defaultMode` defaults to `'light'`, themes merge over the built-in defaults. |
38
+ | `defaultLightTheme` / `defaultDarkTheme` | Built-in token sets covering brand, semantic, neutral, spacing, and radius tokens. |
39
+ | `theme.theme: Signal<ThemeTokens>` | Merged tokens for the resolved mode plus overrides. |
40
+ | `theme.brand: Signal<BrandTokens>` | The `brand` branch of the current theme. |
41
+ | `theme.mode: Signal<ThemeMode>` | `'light'`, `'dark'`, or `'system'`. |
42
+ | `theme.resolvedMode: Signal<ResolvedThemeMode>` | Concrete `'light'` or `'dark'`; follows the OS when mode is `'system'`. |
43
+ | `theme.setMode(mode: ThemeMode): void` | Switches the mode. |
44
+ | `theme.setTheme(tokens: ThemeTokens): void` | Replaces the user override tokens. |
45
+ | `theme.setBrand(brand: Partial<BrandTokens>): void` | Merges brand overrides into the current tokens. |
46
+ | `theme.registerTheme(mode: ResolvedThemeMode, tokens: ThemeTokens): () => void` | Extends a mode's tokens at runtime; returns a restore function. |
47
+ | `theme.dehydrate()` / `theme.hydrate(snapshot)` | Serializes and restores mode and overrides for SSR. |
48
+ | `theme.dispose(): void` | Stops the system preference listener and disposes signals. |
49
+ | `themePlugin(options?): VobsPlugin` | Provides the context through `THEME_KEY`. |
50
+ | `useTheme(): ThemeContext` | Injects the theme context inside components. |
51
+ | `ThemeBoundary(props?: ThemeBoundaryProps): VobsNode` | Renders a scoped element, writes its CSS variables, and overrides tokens for its subtree. |
52
+ | `flattenTheme(theme: ThemeTokens): Record<string, string>` | Flattens nested tokens into CSS variable names. |
53
+
54
+ With `mode` set to `'system'`, `resolvedMode` tracks the OS `prefers-color-scheme` through a live `matchMedia` listener. Nested tokens flatten to `--vobs-*` CSS variables (`brand.primary` becomes `--vobs-brand-primary`); keys that already start with `--` are kept as-is. A `ThemeBoundary` merges the parent theme with its own `theme`, `tokens`, and `brand` props, so overrides stay local to its subtree.
55
+
56
+ ## Types
57
+
58
+ `ThemeMode`, `ResolvedThemeMode`, `ThemePrimitive`, `ThemeTokenValue`, `ThemeTokens`, `BrandTokens`, `ThemeOptions`, `ThemeContext`, `ThemePluginOptions`, `ThemeBoundaryProps`, `ThemeDehydratedState`
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "license": "MIT",
3
+ "files": [
4
+ "src",
5
+ "README.md",
6
+ "LICENSE"
7
+ ],
8
+ "name": "@vobs/theme",
9
+ "version": "1.0.0",
10
+ "type": "module",
11
+ "main": "src/index.ts",
12
+ "types": "src/index.ts",
13
+ "exports": {
14
+ ".": "./src/index.ts"
15
+ },
16
+ "dependencies": {
17
+ "@vobs/vobs": "1.0.0",
18
+ "@vobs/reactivity": "1.0.0"
19
+ }
20
+ }
@@ -0,0 +1,131 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest'
2
+ import {
3
+ createComponent,
4
+ createDOMRenderer,
5
+ createElement,
6
+ createText,
7
+ createVobs,
8
+ bindText,
9
+ insertBefore,
10
+ setRenderer
11
+ } from '@vobs/vobs'
12
+ import {
13
+ createTheme,
14
+ flattenTheme,
15
+ THEME_KEY,
16
+ ThemeBoundary,
17
+ themePlugin,
18
+ useTheme
19
+ } from './index'
20
+
21
+ describe('@vobs/theme', () => {
22
+ beforeEach(() => {
23
+ setRenderer(createDOMRenderer())
24
+ })
25
+
26
+ it('嵌套令牌映射为 --vobs CSS 变量,并保留自定义变量名', () => {
27
+ expect(flattenTheme({
28
+ brand: { primary: '#123456' },
29
+ spacing: { md: '8px' },
30
+ '--custom-color': '#fff'
31
+ })).toEqual({
32
+ '--vobs-brand-primary': '#123456',
33
+ '--vobs-spacing-md': '8px',
34
+ '--custom-color': '#fff'
35
+ })
36
+ })
37
+
38
+ it('默认主题提供可供布局层使用的 surface token', () => {
39
+ const theme = createTheme()
40
+
41
+ expect(theme.theme.value.neutral).toMatchObject({
42
+ background: '#ffffff',
43
+ surface: '#f8fafc'
44
+ })
45
+ theme.setMode('dark')
46
+ expect(theme.theme.value.neutral).toMatchObject({
47
+ background: '#111827',
48
+ surface: '#1f2937'
49
+ })
50
+ theme.dispose()
51
+ })
52
+
53
+ it('ThemeBoundary 写入初始 CSS 变量,切换 mode 后更新变量', () => {
54
+ const theme = createTheme({
55
+ defaultMode: 'light',
56
+ themes: {
57
+ light: { '--color-primary': '#ffffff' },
58
+ dark: { '--color-primary': '#000000' }
59
+ }
60
+ })
61
+ const container = document.createElement('main')
62
+ const app = createVobs({
63
+ render: () => createComponent(ThemeBoundary, { children: () => createElement('span') }),
64
+ plugins: [themePlugin({ theme })]
65
+ })
66
+
67
+ app.mount(container)
68
+ const boundary = container.firstElementChild
69
+ expect(boundary?.getAttribute('style')).toContain('--color-primary: #ffffff')
70
+ theme.setMode('dark')
71
+ app.update()
72
+ expect(boundary?.getAttribute('style')).toContain('--color-primary: #000000')
73
+ app.destroy()
74
+ theme.dispose()
75
+ })
76
+
77
+ it('setBrand 更新当前主题,局部 Boundary 只覆盖自己的子树', () => {
78
+ const theme = createTheme({ defaultMode: 'light' })
79
+ function BrandText() {
80
+ const text = createText('')
81
+ bindText(text, () => useTheme().brand.value.primary ?? '')
82
+ return text
83
+ }
84
+ const container = document.createElement('main')
85
+ const app = createVobs({
86
+ render: () => {
87
+ const root = createElement('section')
88
+ insertBefore(root, createComponent(ThemeBoundary, {
89
+ children: () => createComponent(BrandText, {})
90
+ }), null)
91
+ insertBefore(root, createComponent(ThemeBoundary, {
92
+ brand: { primary: '#ff0000' },
93
+ children: () => createComponent(BrandText, {})
94
+ }), null)
95
+ return root
96
+ },
97
+ plugins: [themePlugin({ theme })]
98
+ })
99
+
100
+ app.mount(container)
101
+ expect(container.textContent).toBe('#2563eb#ff0000')
102
+ theme.setBrand({ primary: '#00ff00' })
103
+ app.update()
104
+ expect(container.textContent).toBe('#00ff00#ff0000')
105
+ const boundaries = container.querySelectorAll('div')
106
+ expect(boundaries[0]?.style.getPropertyValue('--vobs-brand-primary')).toBe('#00ff00')
107
+ expect(boundaries[1]?.style.getPropertyValue('--vobs-brand-primary')).toBe('#ff0000')
108
+ app.destroy()
109
+ theme.dispose()
110
+ })
111
+
112
+ it('支持 system mode 和动态注册主题', () => {
113
+ const theme = createTheme({ defaultMode: 'light' })
114
+ const unregister = theme.registerTheme('light', { '--custom': 'yes' })
115
+ expect(theme.theme.value['--custom']).toBe('yes')
116
+ unregister()
117
+ expect(theme.theme.value['--custom']).toBeUndefined()
118
+ theme.setMode('system')
119
+ expect(theme.resolvedMode.value === 'light' || theme.resolvedMode.value === 'dark').toBe(true)
120
+ theme.dispose()
121
+ })
122
+
123
+ it('未安装主题插件时给出明确错误', () => {
124
+ const app = createVobs({ render: () => {
125
+ void useTheme()
126
+ return createText('')
127
+ }})
128
+ expect(() => app.mount(document.createElement('div'))).toThrow('themePlugin')
129
+ expect(THEME_KEY).toBeDefined()
130
+ })
131
+ })
package/src/index.ts ADDED
@@ -0,0 +1,408 @@
1
+ import { getCurrentOwner, memo, onDispose, renderEffect, state, type Signal } from '@vobs/reactivity'
2
+ import {
3
+ createElement,
4
+ createInjectionKey,
5
+ inject,
6
+ insertBefore,
7
+ provide,
8
+ type InjectionKey,
9
+ type VobsNode,
10
+ type VobsPlugin
11
+ } from '@vobs/vobs'
12
+
13
+ export type ThemeMode = 'light' | 'dark' | 'system'
14
+ export type ResolvedThemeMode = 'light' | 'dark'
15
+ export type ThemePrimitive = string | number | boolean
16
+ export type ThemeTokenValue = ThemePrimitive | ThemeTokens | undefined
17
+
18
+ export interface ThemeTokens {
19
+ readonly [key: string]: ThemeTokenValue
20
+ }
21
+
22
+ export interface BrandTokens {
23
+ readonly primary?: string
24
+ readonly secondary?: string
25
+ readonly accent?: string
26
+ readonly [key: string]: string | undefined
27
+ }
28
+
29
+ export interface ThemeOptions {
30
+ readonly defaultMode?: ThemeMode
31
+ readonly themes?: Partial<Record<ResolvedThemeMode, ThemeTokens>>
32
+ readonly defaultTheme?: ThemeTokens
33
+ }
34
+
35
+ export interface ThemeDehydratedState {
36
+ readonly version: 1
37
+ readonly mode: ThemeMode
38
+ readonly overrides: ThemeTokens
39
+ }
40
+
41
+ export interface ThemeContext {
42
+ readonly theme: Signal<ThemeTokens>
43
+ readonly brand: Signal<BrandTokens>
44
+ readonly mode: Signal<ThemeMode>
45
+ readonly resolvedMode: Signal<ResolvedThemeMode>
46
+ dehydrate(): ThemeDehydratedState
47
+ hydrate(snapshot: unknown): void
48
+ setTheme(theme: ThemeTokens): void
49
+ setMode(mode: ThemeMode): void
50
+ setBrand(brand: Partial<BrandTokens>): void
51
+ registerTheme(mode: ResolvedThemeMode, theme: ThemeTokens): () => void
52
+ dispose(): void
53
+ }
54
+
55
+ export const THEME_KEY: InjectionKey<ThemeContext> = createInjectionKey<ThemeContext>('vobs.theme')
56
+
57
+ export interface ThemePluginOptions extends ThemeOptions {
58
+ readonly theme?: ThemeContext
59
+ }
60
+
61
+ export interface ThemeBoundaryProps {
62
+ readonly theme?: ThemeTokens
63
+ readonly tokens?: ThemeTokens
64
+ readonly brand?: Partial<BrandTokens>
65
+ readonly children?: VobsNode | (() => VobsNode | null | undefined)
66
+ }
67
+
68
+ export const defaultLightTheme: ThemeTokens = {
69
+ colorScheme: 'light',
70
+ brand: {
71
+ primary: '#2563eb',
72
+ secondary: '#0f766e',
73
+ accent: '#d97706'
74
+ },
75
+ semantic: {
76
+ success: '#15803d',
77
+ warning: '#b45309',
78
+ danger: '#b91c1c',
79
+ info: '#0369a1'
80
+ },
81
+ neutral: {
82
+ background: '#ffffff',
83
+ surface: '#f8fafc',
84
+ foreground: '#172033',
85
+ muted: '#64748b',
86
+ border: '#cbd5e1'
87
+ },
88
+ spacing: {
89
+ sm: '4px',
90
+ md: '8px',
91
+ lg: '16px'
92
+ },
93
+ radius: {
94
+ sm: '2px',
95
+ md: '4px',
96
+ lg: '6px'
97
+ }
98
+ }
99
+
100
+ export const defaultDarkTheme: ThemeTokens = {
101
+ colorScheme: 'dark',
102
+ brand: {
103
+ primary: '#60a5fa',
104
+ secondary: '#2dd4bf',
105
+ accent: '#fbbf24'
106
+ },
107
+ semantic: {
108
+ success: '#4ade80',
109
+ warning: '#fbbf24',
110
+ danger: '#f87171',
111
+ info: '#38bdf8'
112
+ },
113
+ neutral: {
114
+ background: '#111827',
115
+ surface: '#1f2937',
116
+ foreground: '#f8fafc',
117
+ muted: '#94a3b8',
118
+ border: '#334155'
119
+ },
120
+ spacing: {
121
+ sm: '4px',
122
+ md: '8px',
123
+ lg: '16px'
124
+ },
125
+ radius: {
126
+ sm: '2px',
127
+ md: '4px',
128
+ lg: '6px'
129
+ }
130
+ }
131
+
132
+ const appliedVariables = new WeakMap<HTMLElement, Set<string>>()
133
+
134
+ export function createTheme(options: ThemeOptions = {}): ThemeContext {
135
+ const mode = state<ThemeMode>(options.defaultMode ?? 'light')
136
+ const systemMode = state<ResolvedThemeMode>(readSystemMode())
137
+ const themes = state<Record<ResolvedThemeMode, ThemeTokens>>({
138
+ light: mergeThemes(defaultLightTheme, options.defaultTheme ?? {}, options.themes?.light ?? {}),
139
+ dark: mergeThemes(defaultDarkTheme, options.defaultTheme ?? {}, options.themes?.dark ?? {})
140
+ })
141
+ const overrides = state<ThemeTokens>({})
142
+ const resolvedMode = memo<ResolvedThemeMode>(() => mode.value === 'system' ? systemMode.value : mode.value)
143
+ const theme = memo<ThemeTokens>(() => mergeThemes(themes.value[resolvedMode.value], overrides.value))
144
+ const brand = memo<BrandTokens>(() => {
145
+ const value = theme.value.brand
146
+ return isThemeTokens(value) ? value as BrandTokens : {}
147
+ })
148
+ let disposed = false
149
+ let stopSystemListener: (() => void) | undefined
150
+
151
+ if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
152
+ const media = window.matchMedia('(prefers-color-scheme: dark)')
153
+ const listener = (event: MediaQueryListEvent): void => {
154
+ systemMode.value = event.matches ? 'dark' : 'light'
155
+ }
156
+ if (media.addEventListener) {
157
+ media.addEventListener('change', listener)
158
+ stopSystemListener = () => media.removeEventListener('change', listener)
159
+ } else {
160
+ media.addListener(listener)
161
+ stopSystemListener = () => media.removeListener(listener)
162
+ }
163
+ }
164
+
165
+ const context: ThemeContext = {
166
+ theme,
167
+ brand,
168
+ mode,
169
+ resolvedMode,
170
+
171
+ dehydrate(): ThemeDehydratedState {
172
+ ensureActive()
173
+ return { version: 1, mode: mode.value, overrides: overrides.value }
174
+ },
175
+
176
+ hydrate(snapshot: unknown): void {
177
+ ensureActive()
178
+ const restored = parseDehydratedState(snapshot)
179
+ mode.value = restored.mode
180
+ overrides.value = restored.overrides
181
+ },
182
+
183
+ setTheme(nextTheme: ThemeTokens): void {
184
+ ensureActive()
185
+ overrides.value = nextTheme
186
+ },
187
+
188
+ setMode(nextMode: ThemeMode): void {
189
+ ensureActive()
190
+ mode.value = validateMode(nextMode)
191
+ },
192
+
193
+ setBrand(nextBrand: Partial<BrandTokens>): void {
194
+ ensureActive()
195
+ overrides.value = mergeThemes(overrides.value, {
196
+ brand: {
197
+ ...brand.value,
198
+ ...nextBrand
199
+ }
200
+ })
201
+ },
202
+
203
+ registerTheme(nextMode: ResolvedThemeMode, nextTheme: ThemeTokens): () => void {
204
+ ensureActive()
205
+ const previous = themes.value[nextMode]
206
+ themes.value = {
207
+ ...themes.value,
208
+ [nextMode]: mergeThemes(previous, nextTheme)
209
+ }
210
+ return () => {
211
+ if (disposed) return
212
+ themes.value = { ...themes.value, [nextMode]: previous }
213
+ }
214
+ },
215
+
216
+ dispose(): void {
217
+ if (disposed) return
218
+ disposed = true
219
+ stopSystemListener?.()
220
+ stopSystemListener = undefined
221
+ theme.dispose()
222
+ brand.dispose()
223
+ resolvedMode.dispose()
224
+ mode.dispose()
225
+ systemMode.dispose()
226
+ themes.dispose()
227
+ overrides.dispose()
228
+ }
229
+ }
230
+
231
+ if (getCurrentOwner()) onDispose(context.dispose)
232
+ return context
233
+
234
+ function ensureActive(): void {
235
+ if (disposed) throw new Error('Vobs Theme: 已销毁的上下文不能继续使用')
236
+ }
237
+ }
238
+
239
+ export function themePlugin(options: ThemePluginOptions = {}): VobsPlugin {
240
+ return {
241
+ name: '@vobs/theme',
242
+ version: '0.1.0',
243
+ install(context) {
244
+ const ownedTheme = options.theme ? undefined : createTheme(options)
245
+ const theme = options.theme ?? ownedTheme!
246
+ context.provide(THEME_KEY, theme)
247
+ return () => ownedTheme?.dispose()
248
+ }
249
+ }
250
+ }
251
+
252
+ export function useTheme(): ThemeContext {
253
+ const theme = inject(THEME_KEY)
254
+ if (!theme) throw new Error('Vobs Theme: 找不到上下文,请安装 themePlugin')
255
+ return theme
256
+ }
257
+
258
+ export function ThemeBoundary(props: ThemeBoundaryProps = {}): VobsNode {
259
+ const parent = useTheme()
260
+ const local = createScopedTheme(parent, mergeThemes(
261
+ props.theme ?? {},
262
+ props.tokens ?? {},
263
+ props.brand ? { brand: props.brand } : {}
264
+ ))
265
+ provide(THEME_KEY, local)
266
+
267
+ const root = createElement('div') as HTMLElement
268
+ renderEffect(() => {
269
+ applyThemeVariables(root, local.theme.value)
270
+ root.dataset.vobsMode = local.resolvedMode.value
271
+ })
272
+ const child = typeof props.children === 'function' ? props.children() : props.children
273
+ if (child) insertBefore(root, child, null)
274
+ return root
275
+ }
276
+
277
+ export function flattenTheme(theme: ThemeTokens): Record<string, string> {
278
+ const variables: Record<string, string> = {}
279
+ flattenInto(theme, '', variables)
280
+ return variables
281
+ }
282
+
283
+ function createScopedTheme(parent: ThemeContext, initialOverrides: ThemeTokens): ThemeContext {
284
+ const overrides = state<ThemeTokens>(initialOverrides)
285
+ const theme = memo<ThemeTokens>(() => mergeThemes(parent.theme.value, overrides.value))
286
+ const brand = memo<BrandTokens>(() => {
287
+ const value = theme.value.brand
288
+ return isThemeTokens(value) ? value as BrandTokens : {}
289
+ })
290
+ let disposed = false
291
+
292
+ const context: ThemeContext = {
293
+ theme,
294
+ brand,
295
+ mode: parent.mode,
296
+ resolvedMode: parent.resolvedMode,
297
+ dehydrate: parent.dehydrate,
298
+ hydrate: parent.hydrate,
299
+ setTheme(nextTheme): void {
300
+ ensureActive()
301
+ overrides.value = nextTheme
302
+ },
303
+ setMode(nextMode): void {
304
+ ensureActive()
305
+ parent.setMode(nextMode)
306
+ },
307
+ setBrand(nextBrand): void {
308
+ ensureActive()
309
+ overrides.value = mergeThemes(overrides.value, {
310
+ brand: { ...brand.value, ...nextBrand }
311
+ })
312
+ },
313
+ registerTheme(nextMode, nextTheme): () => void {
314
+ ensureActive()
315
+ return parent.registerTheme(nextMode, nextTheme)
316
+ },
317
+ dispose(): void {
318
+ if (disposed) return
319
+ disposed = true
320
+ theme.dispose()
321
+ brand.dispose()
322
+ overrides.dispose()
323
+ }
324
+ }
325
+ if (getCurrentOwner()) onDispose(context.dispose)
326
+ return context
327
+
328
+ function ensureActive(): void {
329
+ if (disposed) throw new Error('Vobs Theme: 已销毁的局部上下文不能继续使用')
330
+ }
331
+ }
332
+
333
+ function parseDehydratedState(snapshot: unknown): ThemeDehydratedState {
334
+ let value: unknown = snapshot
335
+ if (typeof snapshot === 'string') {
336
+ try {
337
+ value = JSON.parse(snapshot)
338
+ } catch {
339
+ throw new Error('Vobs Theme: 初始状态不是有效 JSON')
340
+ }
341
+ }
342
+ if (!value || typeof value !== 'object') throw new Error('Vobs Theme: 初始状态格式无效')
343
+ const candidate = value as Partial<ThemeDehydratedState>
344
+ if (candidate.version !== 1 || !candidate.overrides || typeof candidate.overrides !== 'object') {
345
+ throw new Error('Vobs Theme: 初始状态版本或字段无效')
346
+ }
347
+ return {
348
+ version: 1,
349
+ mode: validateMode(candidate.mode as ThemeMode),
350
+ overrides: candidate.overrides
351
+ }
352
+ }
353
+
354
+ function applyThemeVariables(root: HTMLElement, theme: ThemeTokens): void {
355
+ const next = flattenTheme(theme)
356
+ const previous = appliedVariables.get(root) ?? new Set<string>()
357
+ for (const [key, value] of Object.entries(next)) {
358
+ root.style.setProperty(key, value)
359
+ previous.delete(key)
360
+ }
361
+ for (const key of previous) root.style.removeProperty(key)
362
+ appliedVariables.set(root, new Set(Object.keys(next)))
363
+ }
364
+
365
+ function flattenInto(value: ThemeTokenValue, path: string, output: Record<string, string>): void {
366
+ if (value === undefined) return
367
+ if (isThemeTokens(value)) {
368
+ for (const [key, nested] of Object.entries(value)) {
369
+ const nextPath = key.startsWith('--') ? key : path ? `${path}-${key}` : key
370
+ flattenInto(nested, nextPath, output)
371
+ }
372
+ return
373
+ }
374
+ const variable = path.startsWith('--') ? path : `--vobs-${path}`
375
+ output[variable] = String(value)
376
+ }
377
+
378
+ function mergeThemes(...themes: ThemeTokens[]): ThemeTokens {
379
+ const result: Record<string, ThemeTokenValue> = {}
380
+ for (const theme of themes) {
381
+ for (const [key, value] of Object.entries(theme)) {
382
+ const previous = result[key]
383
+ result[key] = isThemeTokens(previous) && isThemeTokens(value)
384
+ ? mergeThemes(previous, value)
385
+ : value
386
+ }
387
+ }
388
+ return result
389
+ }
390
+
391
+ function isThemeTokens(value: unknown): value is ThemeTokens {
392
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
393
+ }
394
+
395
+ function validateMode(mode: ThemeMode): ThemeMode {
396
+ if (mode !== 'light' && mode !== 'dark' && mode !== 'system') {
397
+ throw new Error('Vobs Theme: mode 必须是 light、dark 或 system')
398
+ }
399
+ return mode
400
+ }
401
+
402
+ function readSystemMode(): ResolvedThemeMode {
403
+ return typeof window !== 'undefined'
404
+ && typeof window.matchMedia === 'function'
405
+ && window.matchMedia('(prefers-color-scheme: dark)').matches
406
+ ? 'dark'
407
+ : 'light'
408
+ }