@sigx/lynx-zero 0.4.9 → 0.5.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.
@@ -1,492 +1,532 @@
1
- /**
2
- * `<ThemeProvider>` and `useTheme()` — the design-system-neutral theme engine.
3
- *
4
- * Themes are CSS classes containing scoped `--color-*` / `--radius-*`
5
- * variable definitions; descendants of an element with the class inherit
6
- * those variables (Lynx has `enableCSSInheritance: true` in its
7
- * layout-pipeline defaults), and design-system components are built to read
8
- * those vars directly.
9
- *
10
- * Theme *data* comes from the design-system package: it seeds the registry at
11
- * module load (`registerTheme()` in `./registry.ts`) and ships a generated CSS
12
- * class per static theme so the first frame paints correctly. Custom themes —
13
- * including tenant themes fetched at runtime — register the same way so
14
- * `followSystem` and `toggle()` know what to pick.
15
- *
16
- * Usage (here with `@sigx/lynx-daisyui`'s themes):
17
- *
18
- * ```tsx
19
- * import { ThemeProvider, useTheme } from '@sigx/lynx-daisyui';
20
- *
21
- * // System-aware (default): picks the first registered light/dark theme from
22
- * // the OS scheme, live-flips when the user toggles dark mode.
23
- * defineApp(() => () => (
24
- * <ThemeProvider>
25
- * <App />
26
- * </ThemeProvider>
27
- * ));
28
- *
29
- * // Pin a specific theme — ignores system appearance.
30
- * <ThemeProvider initial="daisy-light">…</ThemeProvider>
31
- *
32
- * // Custom light/dark pair under followSystem.
33
- * <ThemeProvider light="daisy-cupcake" dark="daisy-synthwave">…</ThemeProvider>
34
- * ```
35
- */
36
- import {
37
- component,
38
- defineInjectable,
39
- defineProvide,
40
- effect,
41
- onMounted,
42
- onUnmounted,
43
- signal,
44
- untrack,
45
- type Define,
46
- } from '@sigx/lynx';
47
- import { useSystemColorScheme } from '@sigx/lynx-appearance';
48
- import type { ColorScheme } from '@sigx/lynx-appearance';
49
- import type { ColorToken } from '../contract.js';
50
- import {
51
- colorsOf,
52
- fallbackPalette,
53
- hasStaticCss,
54
- pickThemeFor,
55
- radiusOf,
56
- sizesOf,
57
- variantOf,
58
- } from './registry.js';
59
- import type { ThemeSizes } from './registry.js';
60
- import {
61
- globalThemeState,
62
- makeThemeController,
63
- normalizeFontScale,
64
- themeController,
65
- type ThemeState,
66
- } from './theme-state.js';
67
-
68
- // Lynx background-thread runtime global (closure-injected by the runtime; not
69
- // typed in this package's tsconfig). We use only its CSS-variable setter — the
70
- // documented way to apply theme variables at runtime.
71
- declare const lynx: {
72
- // `setProperty` (runtime CSS-variable application) is optional: not every
73
- // host implements it for every element notably web (`@lynx-js/web-core`).
74
- getElementById(id: string): { setProperty?(props: Record<string, string>): void } | null;
75
- } | undefined;
76
-
77
- // Control dimensions are expressed as multiples of two base units
78
- // (`--size-field`, `--size-selector`). Lynx's runtime CSS engine is unproven
79
- // for `calc(var() * n)`, so when a theme overrides a base unit we do the
80
- // multiplication here and emit literal px. Bases must be px (engine-safe, like
81
- // colors); a non-px base sets only the base var and leaves the `.lynx-zero`
82
- // defaults in place. Multiples mirror the defaults in `styles/tokens.css`.
83
- const FIELD_STEPS: Record<string, number> = { xs: 6, sm: 8, md: 12, lg: 16 };
84
- const SELECTOR_STEPS: Record<string, number> = {
85
- 'checkbox-xs': 4, 'checkbox-sm': 5, 'checkbox-md': 6, 'checkbox-lg': 8,
86
- 'toggle-width-xs': 8, 'toggle-width-sm': 10, 'toggle-width-md': 12, 'toggle-width-lg': 14,
87
- 'toggle-height-xs': 6, 'toggle-height-sm': 6, 'toggle-height-md': 7, 'toggle-height-lg': 8,
88
- 'toggle-thumb-xs': 4, 'toggle-thumb-sm': 4, 'toggle-thumb-md': 5, 'toggle-thumb-lg': 6,
89
- 'badge-xs': 4, 'badge-sm': 5, 'badge-md': 6, 'badge-lg': 8,
90
- };
91
-
92
- // Default text ramp (px) MUST mirror `--text-*` in `styles/tokens.css`
93
- // (iOS-aligned, 17px base). The global `fontScale` multiplies these and emits
94
- // literal px (no `calc(var() * n)` — unproven in Lynx).
95
- const FONT_DEFAULTS: Record<string, number> = {
96
- 'xs': 12, 'sm': 14, 'base': 17, 'lg': 20, 'xl': 24, '2xl': 28, '3xl': 34,
97
- };
98
-
99
- const pxValue = (v: string): number | undefined => {
100
- const m = /^\s*(\d+(?:\.\d+)?)px\s*$/.exec(v);
101
- return m ? Number(m[1]) : undefined;
102
- };
103
-
104
- /** Emit a theme's `sizes` overrides as literal-px CSS custom properties. */
105
- function applySizeVars(
106
- style: Record<string, string | number>,
107
- sizes: ThemeSizes,
108
- ): void {
109
- if (sizes.field) {
110
- style['--size-field'] = sizes.field;
111
- const base = pxValue(sizes.field);
112
- if (base !== undefined) {
113
- for (const k in FIELD_STEPS) style[`--size-${k}`] = `${base * FIELD_STEPS[k]}px`;
114
- }
115
- }
116
- if (sizes.selector) {
117
- style['--size-selector'] = sizes.selector;
118
- const base = pxValue(sizes.selector);
119
- if (base !== undefined) {
120
- for (const k in SELECTOR_STEPS) style[`--${k}`] = `${base * SELECTOR_STEPS[k]}px`;
121
- }
122
- }
123
- }
124
-
125
- /**
126
- * Emit the text ramp scaled by `fontScale` as `--text-*` literal px. Always
127
- * emits every step — even at `1` (the literal defaults) because Lynx's
128
- * `setProperty` only merges and never clears: skipping at `1` would leave a
129
- * previously-emitted larger ramp stuck after a reset. A nested provider seeds
130
- * its scale from the inherited ambient value, so emitting here re-states the
131
- * same ramp rather than clobbering the root's scaled one.
132
- */
133
- function applyFontScale(
134
- style: Record<string, string | number>,
135
- fontScale: number,
136
- ): void {
137
- for (const k in FONT_DEFAULTS) {
138
- style[`--text-${k}`] = `${Math.round(FONT_DEFAULTS[k] * fontScale)}px`;
139
- }
140
- }
141
-
142
- /**
143
- * The full custom-property set for a theme — colors, any radius/size overrides,
144
- * and the `fontScale`-adjusted text ramp. Applied at runtime via the Lynx
145
- * `setProperty` API (see
146
- * `<ThemeProvider>`), NOT the inline `style` attribute: Lynx does not honor
147
- * custom properties declared inline in this toolchain, but `setProperty`
148
- * registers real, inheritable ones the documented way to theme via CSS
149
- * variables (https://lynxjs.org/guide/styling/custom-theming).
150
- */
151
- function buildThemeVars(name: string, fontScale: number): Record<string, string> {
152
- const palette = colorsOf(name) ?? fallbackPalette();
153
- const radius = radiusOf(name);
154
- const sizes = sizesOf(name);
155
- const vars: Record<string, string> = {};
156
- if (palette) {
157
- for (const key in palette) vars[`--color-${key}`] = palette[key as ColorToken];
158
- }
159
- if (radius) {
160
- if (radius.selector) vars['--radius-selector'] = radius.selector;
161
- if (radius.field) vars['--radius-field'] = radius.field;
162
- if (radius.box) vars['--radius-box'] = radius.box;
163
- }
164
- if (sizes) applySizeVars(vars, sizes);
165
- applyFontScale(vars, fontScale);
166
- return vars;
167
- }
168
-
169
- /** Unique host id per provider instance so `getElementById` targets its own subtree. */
170
- let themeIdSeq = 0;
171
-
172
- /**
173
- * Theme class applied to the provider's host view. Plain string — a design
174
- * system layers a literal union on top for autocomplete (e.g. daisyui's
175
- * `DaisyTheme`). Multi-class compositions like `'daisy-light daisy-rounded'`
176
- * are accepted.
177
- */
178
- // eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/ban-types
179
- export type ThemeName = string & {};
180
-
181
- export interface ThemeController {
182
- /** Current theme class. Reactive — read inside render/effect to track. */
183
- readonly name: ThemeName;
184
- /**
185
- * Whether the theme is currently being driven by the system color
186
- * scheme (true when no `initial` was passed and `set()` hasn't been
187
- * called since mount). UI like a settings screen can read this to show
188
- * a "Follow system" indicator.
189
- */
190
- readonly followingSystem: boolean;
191
- /**
192
- * Replace the active theme. Pins the choice subsequent system
193
- * appearance changes won't override it (until `followSystem()` is called).
194
- */
195
- set(name: ThemeName): void;
196
- /**
197
- * Flip to the paired theme — light ↔ dark by default; follows the `pair`
198
- * declared in `registerTheme()`, or the first theme of the opposite
199
- * variant.
200
- */
201
- toggle(): void;
202
- /**
203
- * Resume following system appearance. Equivalent to mounting fresh
204
- * with no `initial` prop. Useful for a "Reset to system" button.
205
- */
206
- followSystem(): void;
207
- /**
208
- * Current global text-scale multiplier (`1` = the theme's default ramp).
209
- * Reactive — read inside render/effect to track. Orthogonal to the theme:
210
- * `set()` / `toggle()` leave it untouched.
211
- */
212
- readonly fontScale: number;
213
- /**
214
- * Set the global text-scale multiplier — the `--text-*` ramp is re-emitted
215
- * at `defaultPx × scale`. Persists across theme switches, so it's the place
216
- * to wire a user accessibility preference or a backend-driven setting (e.g.
217
- * `setFontScale(1.25)`). Inherits into nested `<ThemeProvider>` subtrees.
218
- */
219
- setFontScale(scale: number): void;
220
- }
221
-
222
- /**
223
- * Access the active theme controller. Resolves to the nearest
224
- * `<ThemeProvider>`'s controller (a content sub-scope), or — at the app root
225
- * and in *headless* code with no provider mounted — the global controller
226
- * (`themeController`). Never throws: theme control is reachable from anywhere.
227
- * For control that must always target the app/OS theme regardless of scope
228
- * (e.g. a status-bar sync), import `themeController`.
229
- */
230
- export const useTheme = defineInjectable<ThemeController>(() => themeController);
231
-
232
- /**
233
- * Nesting-depth marker. The outermost `<ThemeProvider>` sees depth 0 and binds
234
- * the global singleton (so headless `themeController` mutations render and the
235
- * OS bars track it); a nested provider sees >= 1 and creates its own local
236
- * state a content sub-scope that recolors its subtree without touching the
237
- * global theme or the system bars.
238
- */
239
- const useThemeDepth = defineInjectable<number>(() => 0);
240
-
241
- /**
242
- * Ambient text scale inherited by nested providers. A nested `<ThemeProvider>`
243
- * with no explicit `fontScale` prop seeds from this (the enclosing scale,
244
- * default 1) instead of resetting to 1so the root's scale flows down through
245
- * color sub-scopes. Each provider re-provides its own current scale.
246
- */
247
- const useAmbientFontScale = defineInjectable<number>(() => 1);
248
-
249
- export type ThemeProviderProps =
250
- /**
251
- * Pin the initial theme. When set, the provider ignores system
252
- * appearance until `controller.followSystem()` is called. When
253
- * omitted, the provider follows the OS color scheme and live-flips
254
- * with it.
255
- */
256
- & Define.Prop<'initial', ThemeName, false>
257
- /**
258
- * Theme to use when the system color scheme is `'light'`. Defaults to
259
- * the first registered light theme. Only consulted while
260
- * `followingSystem` is true.
261
- */
262
- & Define.Prop<'light', ThemeName, false>
263
- /**
264
- * Theme to use when the system color scheme is `'dark'`. Defaults to
265
- * the first registered dark theme. Only consulted while
266
- * `followingSystem` is true.
267
- */
268
- & Define.Prop<'dark', ThemeName, false>
269
- /**
270
- * Initial global text-scale multiplier (`1` = default ramp). Seeds the
271
- * controller's `fontScale`; change it later via `controller.setFontScale()`.
272
- * On the root provider an explicit value wins over any scale a headless
273
- * caller set before mount.
274
- */
275
- & Define.Prop<'fontScale', number, false>
276
- /** Extra classes appended to the theme class on the host view. */
277
- & Define.Prop<'class', string, false>
278
- /** Extra inline style on the host view. Merged after the base flex-fill defaults. */
279
- & Define.Prop<'style', Record<string, string | number>, false>
280
- & Define.Slot<'default'>;
281
-
282
- /**
283
- * Wraps children in a `<view class={theme}>` so the CSS variables defined
284
- * inside the theme class inherit down to every descendant.
285
- *
286
- * Layout: the root provider defaults to flex-fill long-form so the wrapper
287
- * doesn't collapse between ancestors that flex (e.g. `<SafeAreaProvider>`)
288
- * and descendants that need a sized parent (`<SafeAreaView>`). A nested
289
- * provider is a content island and sizes to its content instead — flex-fill's
290
- * `flexBasis: 0` computes to height 0 inside scroll-view content, where
291
- * nothing grows it back (#269). Consumers override via `style`.
292
- *
293
- * Theme name is held in an *object* signal (not a primitive) so literal-union
294
- * types a DS layers on survive — `signal<T>` widens primitive literals to
295
- * plain `string` via `Widen<T>`.
296
- */
297
- export const ThemeProvider = component<ThemeProviderProps>(({ props, slots }) => {
298
- const systemScheme = useSystemColorScheme();
299
-
300
- // The underlying signal widens to PrimitiveSignal<string> via Widen<T>;
301
- // cast at read sites to keep the narrow union throughout the component.
302
- const readScheme = (): ColorScheme => systemScheme.value as ColorScheme;
303
-
304
- // Root vs. nested. The outermost provider (depth 0) binds the global
305
- // singleton so headless `themeController` mutations render here and the OS
306
- // bars (via StatusBarSync) follow this theme. A nested provider gets its own
307
- // local state: a content sub-scope that overrides its subtree only.
308
- const depth = useThemeDepth();
309
- const isRoot = depth === 0;
310
- defineProvide(useThemeDepth, () => depth + 1);
311
-
312
- // Stable id for the host view so the runtime `setProperty` call (below) can
313
- // target it. Unique per instance so nested providers theme their own subtree.
314
- const hostId = `zero-theme-${++themeIdSeq}`;
315
-
316
- // A nested provider with no explicit `fontScale` inherits the enclosing
317
- // scale (default 1 at the root), so the root's scale flows down through
318
- // color sub-scopes rather than resetting. An explicit prop overrides it.
319
- const ambientFontScale = useAmbientFontScale();
320
- const seedScale = normalizeFontScale(props.fontScale, ambientFontScale);
321
-
322
- const state: ThemeState = isRoot
323
- ? globalThemeState
324
- : signal<ThemeState>(
325
- props.initial
326
- ? { name: props.initial, following: false, fontScale: seedScale }
327
- : {
328
- name: readScheme() === 'dark'
329
- ? (props.dark ?? pickThemeFor('dark'))
330
- : (props.light ?? pickThemeFor('light')),
331
- following: true,
332
- fontScale: seedScale,
333
- },
334
- );
335
-
336
- // Seed the root from props/system. An explicit `initial` pin is author
337
- // intent and wins. With no `initial`, reflect the current system scheme into
338
- // the first render but only while `following`, so a theme a headless
339
- // caller set before this mounted is respected, not clobbered. The follow
340
- // effect below keeps it in sync afterwards.
341
- if (isRoot) {
342
- if (props.initial) {
343
- state.name = props.initial;
344
- state.following = false;
345
- } else if (state.following) {
346
- state.name = readScheme() === 'dark'
347
- ? (props.dark ?? pickThemeFor('dark'))
348
- : (props.light ?? pickThemeFor('light'));
349
- }
350
- // Explicit author intent wins; otherwise keep whatever scale a headless
351
- // caller may have set before this mounted (default 1).
352
- if (props.fontScale !== undefined) {
353
- state.fontScale = normalizeFontScale(props.fontScale, state.fontScale);
354
- }
355
- }
356
-
357
- const controller: ThemeController = isRoot
358
- ? themeController
359
- : makeThemeController(state);
360
- defineProvide(useTheme, () => controller);
361
- // Re-provide this scope's current scale so nested providers inherit it.
362
- defineProvide(useAmbientFontScale, () => state.fontScale);
363
-
364
- // Follow the system color scheme while `following`. Reactive: re-runs when
365
- // `following` flips true (e.g. `controller.followSystem()`, including the
366
- // headless `themeController`) or when the OS scheme changes, and writes the
367
- // matching theme. Reading `state.following` and `systemScheme.value` tracks
368
- // them; the `name` write is `untrack`ed so it can't re-trigger the effect.
369
- // Created on mount (the native publisher may populate the scheme between
370
- // setup and mount) and torn down on unmount.
371
- let follow: { stop: () => void } | undefined;
372
- let applyVars: { stop: () => void } | undefined;
373
- onMounted(() => {
374
- follow = effect(() => {
375
- const following = state.following;
376
- const scheme = readScheme();
377
- if (!following) return;
378
- const next = scheme === 'dark'
379
- ? (props.dark ?? pickThemeFor('dark'))
380
- : (props.light ?? pickThemeFor('light'));
381
- untrack(() => {
382
- if (state.name !== next) state.name = next;
383
- });
384
- });
385
-
386
- // Static themes are themed by their generated CSS class (applied on
387
- // the host below), which resolves on the very first frame. This
388
- // `setProperty` path additionally serves runtime-registered themes
389
- // (`registerTheme`, no shipped CSS class) — applied once they're
390
- // selected post-mount, where it lands reliably. Reading `state.name`
391
- // and `state.fontScale` (via buildThemeVars) tracks them, so this
392
- // re-runs on every theme change and every `setFontScale`.
393
- applyVars = effect(() => {
394
- const vars = buildThemeVars(state.name, state.fontScale);
395
- if (typeof lynx !== 'undefined') {
396
- // `setProperty` isn't implemented for every element on every
397
- // host notably web (`@lynx-js/web-core`), where it throws on
398
- // the background thread and aborts the whole card render.
399
- // Degrade gracefully: apply runtime-registered theme vars where
400
- // supported; static themes still resolve via their generated
401
- // CSS class (applied on the host element).
402
- const el = lynx.getElementById(hostId);
403
- if (el && typeof el.setProperty === 'function') {
404
- try {
405
- el.setProperty(vars);
406
- } catch {
407
- /* host rejected runtime setProperty (e.g. web) */
408
- }
409
- }
410
- }
411
- });
412
- });
413
-
414
- onUnmounted(() => {
415
- follow?.stop();
416
- follow = undefined;
417
- applyVars?.stop();
418
- applyVars = undefined;
419
- });
420
-
421
- return () => {
422
- // Theme COLORS and any radius/size overrides are applied as real,
423
- // inheritable CSS custom properties via the Lynx `setProperty` runtime
424
- // API (see the `applyVars` effect above) — Lynx does NOT honor custom
425
- // properties declared through the inline `style` attribute in this
426
- // toolchain. The root background/text are painted here from palette
427
- // literals (real properties, not custom props) so the surface is themed
428
- // on first paint; descendants resolve `var(--color-*)` once setProperty
429
- // has run. The `lynx-zero` base class supplies structural token defaults.
430
- const palette = colorsOf(state.name) ?? fallbackPalette();
431
-
432
- // Static themes ship a generated CSS class, so `state.name` alone
433
- // paints on the first frame. A runtime-registered theme has no class —
434
- // fall back to its variant's static class for the first frame; the
435
- // `setProperty` effect above then swaps in its exact palette post-mount.
436
- const themeClass = hasStaticCss(state.name)
437
- ? state.name
438
- : `${pickThemeFor(variantOf(state.name) ?? 'light')} ${state.name}`;
439
-
440
- // Root: flex-fill long-form (see the component doc comment). Nested:
441
- // content-sized a sub-scope inside scroll content would otherwise
442
- // collapse to zero height via `flexBasis: 0` (#269).
443
- const style: Record<string, string | number> = isRoot
444
- ? {
445
- flexGrow: 1,
446
- flexShrink: 1,
447
- flexBasis: 0,
448
- minHeight: 0,
449
- display: 'flex',
450
- flexDirection: 'column',
451
- }
452
- : {
453
- display: 'flex',
454
- flexDirection: 'column',
455
- };
456
- if (palette) {
457
- style.backgroundColor = palette['base-100'];
458
- style.color = palette['base-content'];
459
- }
460
- if (props.style) Object.assign(style, props.style);
461
-
462
- return (
463
- <view
464
- id={hostId}
465
- class={`lynx-zero ${themeClass}${props.class ? ' ' + props.class : ''}`}
466
- style={style}
467
- >
468
- {slots.default?.()}
469
- </view>
470
- );
471
- };
472
- });
473
-
474
- // Re-export registry helpers so consumers only need one import source.
475
- export {
476
- listThemes,
477
- registerTheme,
478
- extendTheme,
479
- pickThemeFor,
480
- pairOf,
481
- variantOf,
482
- colorsOf,
483
- radiusOf,
484
- sizesOf,
485
- } from './registry.js';
486
- export type {
487
- Theme,
488
- ThemePalette,
489
- ThemeRadius,
490
- ThemeSizes,
491
- ThemeVariant,
492
- } from './registry.js';
1
+ /**
2
+ * `<ThemeProvider>` and `useTheme()` — the design-system-neutral theme engine.
3
+ *
4
+ * Themes are CSS classes containing scoped `--color-*` / `--radius-*`
5
+ * variable definitions; descendants of an element with the class inherit
6
+ * those variables (Lynx has `enableCSSInheritance: true` in its
7
+ * layout-pipeline defaults), and design-system components are built to read
8
+ * those vars directly.
9
+ *
10
+ * Theme *data* comes from the design-system package: it seeds the registry at
11
+ * module load (`registerTheme()` in `./registry.ts`) and ships a generated CSS
12
+ * class per static theme so the first frame paints correctly. Custom themes —
13
+ * including tenant themes fetched at runtime — register the same way so
14
+ * `followSystem` and `toggle()` know what to pick.
15
+ *
16
+ * Usage (here with `@sigx/lynx-daisyui`'s themes):
17
+ *
18
+ * ```tsx
19
+ * import { ThemeProvider, useTheme } from '@sigx/lynx-daisyui';
20
+ *
21
+ * // System-aware (default): picks the first registered light/dark theme from
22
+ * // the OS scheme, live-flips when the user toggles dark mode.
23
+ * defineApp(() => () => (
24
+ * <ThemeProvider>
25
+ * <App />
26
+ * </ThemeProvider>
27
+ * ));
28
+ *
29
+ * // Pin a specific theme — ignores system appearance.
30
+ * <ThemeProvider initial="daisy-light">…</ThemeProvider>
31
+ *
32
+ * // Custom light/dark pair under followSystem.
33
+ * <ThemeProvider light="daisy-cupcake" dark="daisy-synthwave">…</ThemeProvider>
34
+ * ```
35
+ */
36
+ import {
37
+ component,
38
+ defineInjectable,
39
+ defineProvide,
40
+ effect,
41
+ onMounted,
42
+ onUnmounted,
43
+ signal,
44
+ untrack,
45
+ type Define,
46
+ } from '@sigx/lynx';
47
+ import { useSystemColorScheme } from '@sigx/lynx-appearance';
48
+ import type { ColorScheme } from '@sigx/lynx-appearance';
49
+ // CSS-free subpath: just the resolver DI key + type importing the icons
50
+ // barrel here would drag Icon's font-face/svg/codepoint assets into zero's barrel.
51
+ import { useIconColorResolver, type IconColorResolver } from '@sigx/lynx-icons/context';
52
+ import type { ColorToken } from '../contract.js';
53
+ import {
54
+ colorsOf,
55
+ fallbackPalette,
56
+ hasStaticCss,
57
+ pickThemeFor,
58
+ radiusOf,
59
+ sizesOf,
60
+ variantOf,
61
+ } from './registry.js';
62
+ import type { ThemeSizes } from './registry.js';
63
+ import {
64
+ globalThemeState,
65
+ makeThemeController,
66
+ normalizeFontScale,
67
+ themeController,
68
+ type ThemeState,
69
+ } from './theme-state.js';
70
+
71
+ /**
72
+ * Declaration-merge a typed `variant` prop onto `<Icon>` (and the pinned
73
+ * adapters). `@sigx/lynx-icons` has no notion of variants; the foundation owns
74
+ * the concept so every design system inherits it. The merge fires the moment
75
+ * any consumer imports anything from `@sigx/lynx-zero` (or a DS that re-exports
76
+ * it). `<ThemeProvider>` provides the resolver that turns the variant into the
77
+ * active theme's hex (below).
78
+ */
79
+ declare module '@sigx/lynx-icons' {
80
+ interface IconPropsExtensions {
81
+ /** Semantic color token applied as the icon's `fill`, resolved to the
82
+ * active theme's hex via `useIconColorResolver`. */
83
+ variant?: ColorToken;
84
+ }
85
+ }
86
+
87
+ // Lynx background-thread runtime global (closure-injected by the runtime; not
88
+ // typed in this package's tsconfig). We use only its CSS-variable setter — the
89
+ // documented way to apply theme variables at runtime.
90
+ declare const lynx: {
91
+ // `setProperty` (runtime CSS-variable application) is optional: not every
92
+ // host implements it for every element notably web (`@lynx-js/web-core`).
93
+ getElementById(id: string): { setProperty?(props: Record<string, string>): void } | null;
94
+ } | undefined;
95
+
96
+ // Control dimensions are expressed as multiples of two base units
97
+ // (`--size-field`, `--size-selector`). Lynx's runtime CSS engine is unproven
98
+ // for `calc(var() * n)`, so when a theme overrides a base unit we do the
99
+ // multiplication here and emit literal px. Bases must be px (engine-safe, like
100
+ // colors); a non-px base sets only the base var and leaves the `.lynx-zero`
101
+ // defaults in place. Multiples mirror the defaults in `styles/tokens.css`.
102
+ const FIELD_STEPS: Record<string, number> = { xs: 6, sm: 8, md: 12, lg: 16 };
103
+ const SELECTOR_STEPS: Record<string, number> = {
104
+ 'checkbox-xs': 4, 'checkbox-sm': 5, 'checkbox-md': 6, 'checkbox-lg': 8,
105
+ 'toggle-width-xs': 8, 'toggle-width-sm': 10, 'toggle-width-md': 12, 'toggle-width-lg': 14,
106
+ 'toggle-height-xs': 6, 'toggle-height-sm': 6, 'toggle-height-md': 7, 'toggle-height-lg': 8,
107
+ 'toggle-thumb-xs': 4, 'toggle-thumb-sm': 4, 'toggle-thumb-md': 5, 'toggle-thumb-lg': 6,
108
+ 'badge-xs': 4, 'badge-sm': 5, 'badge-md': 6, 'badge-lg': 8,
109
+ };
110
+
111
+ // Default text ramp (px) — MUST mirror `--text-*` in `styles/tokens.css`
112
+ // (iOS-aligned, 17px base). The global `fontScale` multiplies these and emits
113
+ // literal px (no `calc(var() * n)` unproven in Lynx).
114
+ const FONT_DEFAULTS: Record<string, number> = {
115
+ 'xs': 12, 'sm': 14, 'base': 17, 'lg': 20, 'xl': 24, '2xl': 28, '3xl': 34,
116
+ };
117
+
118
+ const pxValue = (v: string): number | undefined => {
119
+ const m = /^\s*(\d+(?:\.\d+)?)px\s*$/.exec(v);
120
+ return m ? Number(m[1]) : undefined;
121
+ };
122
+
123
+ /** Emit a theme's `sizes` overrides as literal-px CSS custom properties. */
124
+ function applySizeVars(
125
+ style: Record<string, string | number>,
126
+ sizes: ThemeSizes,
127
+ ): void {
128
+ if (sizes.field) {
129
+ style['--size-field'] = sizes.field;
130
+ const base = pxValue(sizes.field);
131
+ if (base !== undefined) {
132
+ for (const k in FIELD_STEPS) style[`--size-${k}`] = `${base * FIELD_STEPS[k]}px`;
133
+ }
134
+ }
135
+ if (sizes.selector) {
136
+ style['--size-selector'] = sizes.selector;
137
+ const base = pxValue(sizes.selector);
138
+ if (base !== undefined) {
139
+ for (const k in SELECTOR_STEPS) style[`--${k}`] = `${base * SELECTOR_STEPS[k]}px`;
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Emit the text ramp scaled by `fontScale` as `--text-*` literal px. Always
146
+ * emits every step even at `1` (the literal defaults) because Lynx's
147
+ * `setProperty` only merges and never clears: skipping at `1` would leave a
148
+ * previously-emitted larger ramp stuck after a reset. A nested provider seeds
149
+ * its scale from the inherited ambient value, so emitting here re-states the
150
+ * same ramp rather than clobbering the root's scaled one.
151
+ */
152
+ function applyFontScale(
153
+ style: Record<string, string | number>,
154
+ fontScale: number,
155
+ ): void {
156
+ for (const k in FONT_DEFAULTS) {
157
+ style[`--text-${k}`] = `${Math.round(FONT_DEFAULTS[k] * fontScale)}px`;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * The full custom-property set for a theme — colors, any radius/size overrides,
163
+ * and the `fontScale`-adjusted text ramp. Applied at runtime via the Lynx
164
+ * `setProperty` API (see
165
+ * `<ThemeProvider>`), NOT the inline `style` attribute: Lynx does not honor
166
+ * custom properties declared inline in this toolchain, but `setProperty`
167
+ * registers real, inheritable ones — the documented way to theme via CSS
168
+ * variables (https://lynxjs.org/guide/styling/custom-theming).
169
+ */
170
+ function buildThemeVars(name: string, fontScale: number): Record<string, string> {
171
+ const palette = colorsOf(name) ?? fallbackPalette();
172
+ const radius = radiusOf(name);
173
+ const sizes = sizesOf(name);
174
+ const vars: Record<string, string> = {};
175
+ if (palette) {
176
+ for (const key in palette) vars[`--color-${key}`] = palette[key as ColorToken];
177
+ }
178
+ if (radius) {
179
+ if (radius.selector) vars['--radius-selector'] = radius.selector;
180
+ if (radius.field) vars['--radius-field'] = radius.field;
181
+ if (radius.box) vars['--radius-box'] = radius.box;
182
+ }
183
+ if (sizes) applySizeVars(vars, sizes);
184
+ applyFontScale(vars, fontScale);
185
+ return vars;
186
+ }
187
+
188
+ /** Unique host id per provider instance so `getElementById` targets its own subtree. */
189
+ let themeIdSeq = 0;
190
+
191
+ /**
192
+ * Theme class applied to the provider's host view. Plain stringa design
193
+ * system layers a literal union on top for autocomplete (e.g. daisyui's
194
+ * `DaisyTheme`). Multi-class compositions like `'daisy-light daisy-rounded'`
195
+ * are accepted.
196
+ */
197
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/ban-types
198
+ export type ThemeName = string & {};
199
+
200
+ export interface ThemeController {
201
+ /** Current theme class. Reactive — read inside render/effect to track. */
202
+ readonly name: ThemeName;
203
+ /**
204
+ * Whether the theme is currently being driven by the system color
205
+ * scheme (true when no `initial` was passed and `set()` hasn't been
206
+ * called since mount). UI like a settings screen can read this to show
207
+ * a "Follow system" indicator.
208
+ */
209
+ readonly followingSystem: boolean;
210
+ /**
211
+ * Replace the active theme. Pins the choice — subsequent system
212
+ * appearance changes won't override it (until `followSystem()` is called).
213
+ */
214
+ set(name: ThemeName): void;
215
+ /**
216
+ * Flip to the paired theme light dark by default; follows the `pair`
217
+ * declared in `registerTheme()`, or the first theme of the opposite
218
+ * variant.
219
+ */
220
+ toggle(): void;
221
+ /**
222
+ * Resume following system appearance. Equivalent to mounting fresh
223
+ * with no `initial` prop. Useful for a "Reset to system" button.
224
+ */
225
+ followSystem(): void;
226
+ /**
227
+ * Current global text-scale multiplier (`1` = the theme's default ramp).
228
+ * Reactive — read inside render/effect to track. Orthogonal to the theme:
229
+ * `set()` / `toggle()` leave it untouched.
230
+ */
231
+ readonly fontScale: number;
232
+ /**
233
+ * Set the global text-scale multiplier the `--text-*` ramp is re-emitted
234
+ * at `defaultPx × scale`. Persists across theme switches, so it's the place
235
+ * to wire a user accessibility preference or a backend-driven setting (e.g.
236
+ * `setFontScale(1.25)`). Inherits into nested `<ThemeProvider>` subtrees.
237
+ */
238
+ setFontScale(scale: number): void;
239
+ }
240
+
241
+ /**
242
+ * Access the active theme controller. Resolves to the nearest
243
+ * `<ThemeProvider>`'s controller (a content sub-scope), or at the app root
244
+ * and in *headless* code with no provider mounted — the global controller
245
+ * (`themeController`). Never throws: theme control is reachable from anywhere.
246
+ * For control that must always target the app/OS theme regardless of scope
247
+ * (e.g. a status-bar sync), import `themeController`.
248
+ */
249
+ export const useTheme = defineInjectable<ThemeController>(() => themeController);
250
+
251
+ /**
252
+ * Nesting-depth marker. The outermost `<ThemeProvider>` sees depth 0 and binds
253
+ * the global singleton (so headless `themeController` mutations render and the
254
+ * OS bars track it); a nested provider sees >= 1 and creates its own local
255
+ * state — a content sub-scope that recolors its subtree without touching the
256
+ * global theme or the system bars.
257
+ */
258
+ const useThemeDepth = defineInjectable<number>(() => 0);
259
+
260
+ /**
261
+ * Ambient text scale inherited by nested providers. A nested `<ThemeProvider>`
262
+ * with no explicit `fontScale` prop seeds from this (the enclosing scale,
263
+ * default 1) instead of resetting to 1 — so the root's scale flows down through
264
+ * color sub-scopes. Each provider re-provides its own current scale.
265
+ */
266
+ const useAmbientFontScale = defineInjectable<number>(() => 1);
267
+
268
+ export type ThemeProviderProps =
269
+ /**
270
+ * Pin the initial theme. When set, the provider ignores system
271
+ * appearance until `controller.followSystem()` is called. When
272
+ * omitted, the provider follows the OS color scheme and live-flips
273
+ * with it.
274
+ */
275
+ & Define.Prop<'initial', ThemeName, false>
276
+ /**
277
+ * Theme to use when the system color scheme is `'light'`. Defaults to
278
+ * the first registered light theme. Only consulted while
279
+ * `followingSystem` is true.
280
+ */
281
+ & Define.Prop<'light', ThemeName, false>
282
+ /**
283
+ * Theme to use when the system color scheme is `'dark'`. Defaults to
284
+ * the first registered dark theme. Only consulted while
285
+ * `followingSystem` is true.
286
+ */
287
+ & Define.Prop<'dark', ThemeName, false>
288
+ /**
289
+ * Initial global text-scale multiplier (`1` = default ramp). Seeds the
290
+ * controller's `fontScale`; change it later via `controller.setFontScale()`.
291
+ * On the root provider an explicit value wins over any scale a headless
292
+ * caller set before mount.
293
+ */
294
+ & Define.Prop<'fontScale', number, false>
295
+ /** Extra classes appended to the theme class on the host view. */
296
+ & Define.Prop<'class', string, false>
297
+ /** Extra inline style on the host view. Merged after the base flex-fill defaults. */
298
+ & Define.Prop<'style', Record<string, string | number>, false>
299
+ /**
300
+ * Override the icon-color resolver this scope provides. By default the
301
+ * provider resolves an `<Icon variant>` to the active theme's palette hex
302
+ * design-system-agnostic, works for any registered theme. Supply this to
303
+ * customize the mapping (e.g. a DS-specific token aliasing).
304
+ */
305
+ & Define.Prop<'iconColorResolver', IconColorResolver, false>
306
+ & Define.Slot<'default'>;
307
+
308
+ /**
309
+ * Wraps children in a `<view class={theme}>` so the CSS variables defined
310
+ * inside the theme class inherit down to every descendant.
311
+ *
312
+ * Layout: the root provider defaults to flex-fill long-form so the wrapper
313
+ * doesn't collapse between ancestors that flex (e.g. `<SafeAreaProvider>`)
314
+ * and descendants that need a sized parent (`<SafeAreaView>`). A nested
315
+ * provider is a content island and sizes to its content instead — flex-fill's
316
+ * `flexBasis: 0` computes to height 0 inside scroll-view content, where
317
+ * nothing grows it back (#269). Consumers override via `style`.
318
+ *
319
+ * Theme name is held in an *object* signal (not a primitive) so literal-union
320
+ * types a DS layers on survive — `signal<T>` widens primitive literals to
321
+ * plain `string` via `Widen<T>`.
322
+ */
323
+ export const ThemeProvider = component<ThemeProviderProps>(({ props, slots }) => {
324
+ const systemScheme = useSystemColorScheme();
325
+
326
+ // The underlying signal widens to PrimitiveSignal<string> via Widen<T>;
327
+ // cast at read sites to keep the narrow union throughout the component.
328
+ const readScheme = (): ColorScheme => systemScheme.value as ColorScheme;
329
+
330
+ // Root vs. nested. The outermost provider (depth 0) binds the global
331
+ // singleton — so headless `themeController` mutations render here and the OS
332
+ // bars (via StatusBarSync) follow this theme. A nested provider gets its own
333
+ // local state: a content sub-scope that overrides its subtree only.
334
+ const depth = useThemeDepth();
335
+ const isRoot = depth === 0;
336
+ defineProvide(useThemeDepth, () => depth + 1);
337
+
338
+ // Stable id for the host view so the runtime `setProperty` call (below) can
339
+ // target it. Unique per instance so nested providers theme their own subtree.
340
+ const hostId = `zero-theme-${++themeIdSeq}`;
341
+
342
+ // A nested provider with no explicit `fontScale` inherits the enclosing
343
+ // scale (default 1 at the root), so the root's scale flows down through
344
+ // color sub-scopes rather than resetting. An explicit prop overrides it.
345
+ const ambientFontScale = useAmbientFontScale();
346
+ const seedScale = normalizeFontScale(props.fontScale, ambientFontScale);
347
+
348
+ const state: ThemeState = isRoot
349
+ ? globalThemeState
350
+ : signal<ThemeState>(
351
+ props.initial
352
+ ? { name: props.initial, following: false, fontScale: seedScale }
353
+ : {
354
+ name: readScheme() === 'dark'
355
+ ? (props.dark ?? pickThemeFor('dark'))
356
+ : (props.light ?? pickThemeFor('light')),
357
+ following: true,
358
+ fontScale: seedScale,
359
+ },
360
+ );
361
+
362
+ // Seed the root from props/system. An explicit `initial` pin is author
363
+ // intent and wins. With no `initial`, reflect the current system scheme into
364
+ // the first render but only while `following`, so a theme a headless
365
+ // caller set before this mounted is respected, not clobbered. The follow
366
+ // effect below keeps it in sync afterwards.
367
+ if (isRoot) {
368
+ if (props.initial) {
369
+ state.name = props.initial;
370
+ state.following = false;
371
+ } else if (state.following) {
372
+ state.name = readScheme() === 'dark'
373
+ ? (props.dark ?? pickThemeFor('dark'))
374
+ : (props.light ?? pickThemeFor('light'));
375
+ }
376
+ // Explicit author intent wins; otherwise keep whatever scale a headless
377
+ // caller may have set before this mounted (default 1).
378
+ if (props.fontScale !== undefined) {
379
+ state.fontScale = normalizeFontScale(props.fontScale, state.fontScale);
380
+ }
381
+ }
382
+
383
+ const controller: ThemeController = isRoot
384
+ ? themeController
385
+ : makeThemeController(state);
386
+ defineProvide(useTheme, () => controller);
387
+ // Re-provide this scope's current scale so nested providers inherit it.
388
+ defineProvide(useAmbientFontScale, () => state.fontScale);
389
+
390
+ // Icon-color resolver for this scope: map `<Icon variant>` to the active
391
+ // theme's palette hex (SVG fills can't read CSS vars, so the hex is
392
+ // substituted into `fill=` at render). Design-system-agnostic reads the
393
+ // registered palette for whatever theme this scope shows, so daisy and hero
394
+ // icons both theme correctly, per sub-scope. A consumer can override via the
395
+ // `iconColorResolver` prop. Reading `state.name` re-runs icons on theme flip.
396
+ const defaultIconResolver: IconColorResolver = (iconProps) => {
397
+ const variant = (iconProps as { variant?: ColorToken }).variant;
398
+ if (!variant) return undefined;
399
+ const palette = colorsOf(state.name) ?? fallbackPalette();
400
+ return palette?.[variant];
401
+ };
402
+ defineProvide(useIconColorResolver, () => props.iconColorResolver ?? defaultIconResolver);
403
+
404
+ // Follow the system color scheme while `following`. Reactive: re-runs when
405
+ // `following` flips true (e.g. `controller.followSystem()`, including the
406
+ // headless `themeController`) or when the OS scheme changes, and writes the
407
+ // matching theme. Reading `state.following` and `systemScheme.value` tracks
408
+ // them; the `name` write is `untrack`ed so it can't re-trigger the effect.
409
+ // Created on mount (the native publisher may populate the scheme between
410
+ // setup and mount) and torn down on unmount.
411
+ let follow: { stop: () => void } | undefined;
412
+ let applyVars: { stop: () => void } | undefined;
413
+ onMounted(() => {
414
+ follow = effect(() => {
415
+ const following = state.following;
416
+ const scheme = readScheme();
417
+ if (!following) return;
418
+ const next = scheme === 'dark'
419
+ ? (props.dark ?? pickThemeFor('dark'))
420
+ : (props.light ?? pickThemeFor('light'));
421
+ untrack(() => {
422
+ if (state.name !== next) state.name = next;
423
+ });
424
+ });
425
+
426
+ // Static themes are themed by their generated CSS class (applied on
427
+ // the host below), which resolves on the very first frame. This
428
+ // `setProperty` path additionally serves runtime-registered themes
429
+ // (`registerTheme`, no shipped CSS class) applied once they're
430
+ // selected post-mount, where it lands reliably. Reading `state.name`
431
+ // and `state.fontScale` (via buildThemeVars) tracks them, so this
432
+ // re-runs on every theme change and every `setFontScale`.
433
+ applyVars = effect(() => {
434
+ const vars = buildThemeVars(state.name, state.fontScale);
435
+ if (typeof lynx !== 'undefined') {
436
+ // `setProperty` isn't implemented for every element on every
437
+ // host — notably web (`@lynx-js/web-core`), where it throws on
438
+ // the background thread and aborts the whole card render.
439
+ // Degrade gracefully: apply runtime-registered theme vars where
440
+ // supported; static themes still resolve via their generated
441
+ // CSS class (applied on the host element).
442
+ const el = lynx.getElementById(hostId);
443
+ if (el && typeof el.setProperty === 'function') {
444
+ try {
445
+ el.setProperty(vars);
446
+ } catch {
447
+ /* host rejected runtime setProperty (e.g. web) */
448
+ }
449
+ }
450
+ }
451
+ });
452
+ });
453
+
454
+ onUnmounted(() => {
455
+ follow?.stop();
456
+ follow = undefined;
457
+ applyVars?.stop();
458
+ applyVars = undefined;
459
+ });
460
+
461
+ return () => {
462
+ // Theme COLORS and any radius/size overrides are applied as real,
463
+ // inheritable CSS custom properties via the Lynx `setProperty` runtime
464
+ // API (see the `applyVars` effect above) — Lynx does NOT honor custom
465
+ // properties declared through the inline `style` attribute in this
466
+ // toolchain. The root background/text are painted here from palette
467
+ // literals (real properties, not custom props) so the surface is themed
468
+ // on first paint; descendants resolve `var(--color-*)` once setProperty
469
+ // has run. The `lynx-zero` base class supplies structural token defaults.
470
+ const palette = colorsOf(state.name) ?? fallbackPalette();
471
+
472
+ // Static themes ship a generated CSS class, so `state.name` alone
473
+ // paints on the first frame. A runtime-registered theme has no class —
474
+ // fall back to its variant's static class for the first frame; the
475
+ // `setProperty` effect above then swaps in its exact palette post-mount.
476
+ const themeClass = hasStaticCss(state.name)
477
+ ? state.name
478
+ : `${pickThemeFor(variantOf(state.name) ?? 'light')} ${state.name}`;
479
+
480
+ // Root: flex-fill long-form (see the component doc comment). Nested:
481
+ // content-sized — a sub-scope inside scroll content would otherwise
482
+ // collapse to zero height via `flexBasis: 0` (#269).
483
+ const style: Record<string, string | number> = isRoot
484
+ ? {
485
+ flexGrow: 1,
486
+ flexShrink: 1,
487
+ flexBasis: 0,
488
+ minHeight: 0,
489
+ display: 'flex',
490
+ flexDirection: 'column',
491
+ }
492
+ : {
493
+ display: 'flex',
494
+ flexDirection: 'column',
495
+ };
496
+ if (palette) {
497
+ style.backgroundColor = palette['base-100'];
498
+ style.color = palette['base-content'];
499
+ }
500
+ if (props.style) Object.assign(style, props.style);
501
+
502
+ return (
503
+ <view
504
+ id={hostId}
505
+ class={`lynx-zero ${themeClass}${props.class ? ' ' + props.class : ''}`}
506
+ style={style}
507
+ >
508
+ {slots.default?.()}
509
+ </view>
510
+ );
511
+ };
512
+ });
513
+
514
+ // Re-export registry helpers so consumers only need one import source.
515
+ export {
516
+ listThemes,
517
+ registerTheme,
518
+ extendTheme,
519
+ pickThemeFor,
520
+ pairOf,
521
+ variantOf,
522
+ colorsOf,
523
+ radiusOf,
524
+ sizesOf,
525
+ } from './registry.js';
526
+ export type {
527
+ Theme,
528
+ ThemePalette,
529
+ ThemeRadius,
530
+ ThemeSizes,
531
+ ThemeVariant,
532
+ } from './registry.js';