@sigx/lynx-zero 0.4.9

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 (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +25 -0
  3. package/dist/components/SwiperIndicator.d.ts +43 -0
  4. package/dist/components/SwiperIndicator.d.ts.map +1 -0
  5. package/dist/components/SwiperIndicator.js +272 -0
  6. package/dist/components/SwiperIndicator.js.map +1 -0
  7. package/dist/contract.d.ts +95 -0
  8. package/dist/contract.d.ts.map +1 -0
  9. package/dist/contract.js +30 -0
  10. package/dist/contract.js.map +1 -0
  11. package/dist/index.d.ts +30 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +41 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/layout/Center.d.ts +7 -0
  16. package/dist/layout/Center.d.ts.map +1 -0
  17. package/dist/layout/Center.js +25 -0
  18. package/dist/layout/Center.js.map +1 -0
  19. package/dist/layout/Col.d.ts +8 -0
  20. package/dist/layout/Col.d.ts.map +1 -0
  21. package/dist/layout/Col.js +34 -0
  22. package/dist/layout/Col.js.map +1 -0
  23. package/dist/layout/Row.d.ts +8 -0
  24. package/dist/layout/Row.d.ts.map +1 -0
  25. package/dist/layout/Row.js +34 -0
  26. package/dist/layout/Row.js.map +1 -0
  27. package/dist/layout/ScrollView.d.ts +6 -0
  28. package/dist/layout/ScrollView.d.ts.map +1 -0
  29. package/dist/layout/ScrollView.js +19 -0
  30. package/dist/layout/ScrollView.js.map +1 -0
  31. package/dist/layout/Spacer.d.ts +4 -0
  32. package/dist/layout/Spacer.d.ts.map +1 -0
  33. package/dist/layout/Spacer.js +12 -0
  34. package/dist/layout/Spacer.js.map +1 -0
  35. package/dist/preset/index.d.ts +31 -0
  36. package/dist/preset/index.d.ts.map +1 -0
  37. package/dist/preset/index.js +72 -0
  38. package/dist/preset/index.js.map +1 -0
  39. package/dist/shared/press.d.ts +3 -0
  40. package/dist/shared/press.d.ts.map +1 -0
  41. package/dist/shared/press.js +7 -0
  42. package/dist/shared/press.js.map +1 -0
  43. package/dist/shared/styles.d.ts +27 -0
  44. package/dist/shared/styles.d.ts.map +1 -0
  45. package/dist/shared/styles.js +62 -0
  46. package/dist/shared/styles.js.map +1 -0
  47. package/dist/shared/tabs-selection.d.ts +25 -0
  48. package/dist/shared/tabs-selection.d.ts.map +1 -0
  49. package/dist/shared/tabs-selection.js +45 -0
  50. package/dist/shared/tabs-selection.js.map +1 -0
  51. package/dist/styles/tokens.css +98 -0
  52. package/dist/theme/StatusBarSync.d.ts +42 -0
  53. package/dist/theme/StatusBarSync.d.ts.map +1 -0
  54. package/dist/theme/StatusBarSync.js +89 -0
  55. package/dist/theme/StatusBarSync.js.map +1 -0
  56. package/dist/theme/ThemeProvider.d.ts +144 -0
  57. package/dist/theme/ThemeProvider.d.ts.map +1 -0
  58. package/dist/theme/ThemeProvider.js +328 -0
  59. package/dist/theme/ThemeProvider.js.map +1 -0
  60. package/dist/theme/color-mix.d.ts +21 -0
  61. package/dist/theme/color-mix.d.ts.map +1 -0
  62. package/dist/theme/color-mix.js +65 -0
  63. package/dist/theme/color-mix.js.map +1 -0
  64. package/dist/theme/registry.d.ts +182 -0
  65. package/dist/theme/registry.d.ts.map +1 -0
  66. package/dist/theme/registry.js +182 -0
  67. package/dist/theme/registry.js.map +1 -0
  68. package/dist/theme/theme-state.d.ts +43 -0
  69. package/dist/theme/theme-state.d.ts.map +1 -0
  70. package/dist/theme/theme-state.js +94 -0
  71. package/dist/theme/theme-state.js.map +1 -0
  72. package/dist/theme/use-screen-theme.d.ts +4 -0
  73. package/dist/theme/use-screen-theme.d.ts.map +1 -0
  74. package/dist/theme/use-screen-theme.js +43 -0
  75. package/dist/theme/use-screen-theme.js.map +1 -0
  76. package/dist/theme/use-theme-colors.d.ts +48 -0
  77. package/dist/theme/use-theme-colors.d.ts.map +1 -0
  78. package/dist/theme/use-theme-colors.js +69 -0
  79. package/dist/theme/use-theme-colors.js.map +1 -0
  80. package/package.json +80 -0
  81. package/src/components/SwiperIndicator.tsx +519 -0
  82. package/src/contract.ts +136 -0
  83. package/src/index.ts +101 -0
  84. package/src/layout/Center.tsx +41 -0
  85. package/src/layout/Col.tsx +53 -0
  86. package/src/layout/Row.tsx +53 -0
  87. package/src/layout/ScrollView.tsx +38 -0
  88. package/src/layout/Spacer.tsx +18 -0
  89. package/src/preset/index.ts +77 -0
  90. package/src/shared/press.ts +6 -0
  91. package/src/shared/styles.ts +82 -0
  92. package/src/shared/tabs-selection.ts +57 -0
  93. package/src/styles/tokens.css +98 -0
  94. package/src/theme/StatusBarSync.tsx +104 -0
  95. package/src/theme/ThemeProvider.tsx +492 -0
  96. package/src/theme/color-mix.ts +68 -0
  97. package/src/theme/registry.ts +290 -0
  98. package/src/theme/theme-state.ts +112 -0
  99. package/src/theme/use-screen-theme.ts +42 -0
  100. package/src/theme/use-theme-colors.ts +99 -0
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `<StatusBarSync />` — keeps the device status-bar (and Android's
3
+ * navigation-bar) tint legible against the active theme.
4
+ *
5
+ * Reads the current theme via `useTheme()`, looks up its variant in the
6
+ * theme registry, and pushes the appropriate tint to the OS via
7
+ * `@sigx/lynx-appearance`:
8
+ *
9
+ * light theme → dark status-bar icons (legible against light bg)
10
+ * dark theme → light status-bar icons (legible against dark bg)
11
+ *
12
+ * Mount once, inside `<ThemeProvider>`:
13
+ *
14
+ * ```tsx
15
+ * <ThemeProvider>
16
+ * <StatusBarSync />
17
+ * <App />
18
+ * </ThemeProvider>
19
+ * ```
20
+ *
21
+ * Renders nothing — it's a side-effect-only component that drives a
22
+ * reactive `effect()` reading `theme.name`. The `matchBackground` prop is
23
+ * reserved for a follow-up that pushes the active theme's
24
+ * `--color-base-100` as the Android system-bar background; today it's a
25
+ * declared no-op so the API surface is stable across the rev that wires
26
+ * CSS-var resolution.
27
+ */
28
+ import { component, effect, onMounted, onUnmounted, type Define } from '@sigx/lynx';
29
+ import { isAvailable, setSystemBarsStyle } from '@sigx/lynx-appearance';
30
+ import type { SystemBarStyle } from '@sigx/lynx-appearance';
31
+ import { themeController } from './theme-state.js';
32
+ import { variantOf } from './registry.js';
33
+
34
+ export type StatusBarSyncProps =
35
+ /**
36
+ * Reserved — will (in a follow-up) push the active theme's
37
+ * `--color-base-100` as the Android status- and navigation-bar
38
+ * background. Currently a no-op; the prop ships so consumers can opt
39
+ * in without an API break later. iOS and Android 15+ ignore the
40
+ * background regardless (no equivalent on iOS; edge-to-edge on
41
+ * Android 15+).
42
+ */
43
+ & Define.Prop<'matchBackground', boolean, false>;
44
+
45
+ export const StatusBarSync = component<StatusBarSyncProps>(({ props }) => {
46
+ // Bind to the *global* theme — not `useTheme()` — so the OS bars always
47
+ // track the app/screen theme and can't be hijacked by a content sub-scope
48
+ // (a nested `<ThemeProvider>` recolors its subtree but leaves the bars put).
49
+ const theme = themeController;
50
+ let lastApplied: string | null = null;
51
+ let runner: { stop: () => void } | undefined;
52
+
53
+ function apply(name: string): void {
54
+ if (name === lastApplied) return;
55
+ lastApplied = name;
56
+ const variant = variantOf(name);
57
+ // For unregistered themes we can't infer a variant — leave the
58
+ // system bars alone. Consumers can register their custom theme via
59
+ // `registerTheme()` to opt in.
60
+ if (!variant) return;
61
+ const style: SystemBarStyle = variant === 'dark' ? 'light' : 'dark';
62
+ // Fire-and-forget: `setSystemBarsStyle` is non-throwing (it
63
+ // resolves `{ ok: false, reason: 'unsupported' }` when the native
64
+ // module isn't registered, and silently filters per-leg
65
+ // `unsupported` results — e.g. nav-bar on iOS — so an aggregate
66
+ // `ok: true` is still reachable on partial platforms). Either way,
67
+ // void-discarding the promise here can't surface as an unhandled
68
+ // rejection.
69
+ void setSystemBarsStyle({
70
+ statusBar: style,
71
+ navigationBar: { style },
72
+ });
73
+ }
74
+
75
+ onMounted(() => {
76
+ if (!isAvailable()) return;
77
+ // A reactive effect that reads `theme.name` so the effect re-runs
78
+ // whenever the theme controller's underlying signal changes —
79
+ // including the live system-flip path inside ThemeProvider. No
80
+ // side effects in render; nothing to subscribe/unsubscribe by
81
+ // hand.
82
+ runner = effect(() => {
83
+ apply(theme.name);
84
+ });
85
+ });
86
+
87
+ onUnmounted(() => {
88
+ runner?.stop();
89
+ runner = undefined;
90
+ });
91
+
92
+ // Reference the prop so the type checker doesn't flag it as unused
93
+ // while it's still reserved. Drop this when the matchBackground
94
+ // implementation lands.
95
+ void props.matchBackground;
96
+
97
+ // Zero-size, out-of-flow placeholder. Avoids `display: none` —
98
+ // Lynx can leak unstyled text paint through display:none overlays in
99
+ // some builds (see lynx-display-none caveat); zero-size + absolute is
100
+ // the safer shape.
101
+ return () => (
102
+ <view style={{ position: 'absolute', width: '0px', height: '0px', opacity: 0 }} />
103
+ );
104
+ });
@@ -0,0 +1,492 @@
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 1 — so 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';
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Engine-side color mixing.
3
+ *
4
+ * Lynx's CSS engine has no `color-mix()` and can't alpha-compose `var()`
5
+ * colors — but theme palettes are plain JS data, so tints can be computed
6
+ * where the palette lives instead of in CSS. `registerTheme()` uses this to
7
+ * materialize the `*-soft` tokens (see `./registry.ts`).
8
+ *
9
+ * Accepted inputs are the same "engine-safe" color strings themes already
10
+ * use: `#rgb`, `#rrggbb`, `#rrggbbaa`, `rgb(r, g, b)`, `rgba(r, g, b, a)`.
11
+ * Anything else (named colors, `oklch()`, `var()`) is not parseable here —
12
+ * `mixColors` then falls back to the base color unchanged, which degrades to
13
+ * a neutral surface rather than a wrong tint.
14
+ */
15
+
16
+ type Rgb = readonly [number, number, number];
17
+
18
+ function parseColor(value: string): Rgb | undefined {
19
+ const v = value.trim();
20
+
21
+ if (v.startsWith('#')) {
22
+ const hex = v.slice(1);
23
+ if (hex.length === 3) {
24
+ const r = parseInt(hex[0] + hex[0], 16);
25
+ const g = parseInt(hex[1] + hex[1], 16);
26
+ const b = parseInt(hex[2] + hex[2], 16);
27
+ if ([r, g, b].some(Number.isNaN)) return undefined;
28
+ return [r, g, b];
29
+ }
30
+ if (hex.length === 6 || hex.length === 8) {
31
+ const r = parseInt(hex.slice(0, 2), 16);
32
+ const g = parseInt(hex.slice(2, 4), 16);
33
+ const b = parseInt(hex.slice(4, 6), 16);
34
+ if ([r, g, b].some(Number.isNaN)) return undefined;
35
+ return [r, g, b];
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ const m = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*[\d.]+\s*)?\)$/i.exec(v);
41
+ if (m) {
42
+ const r = Number(m[1]);
43
+ const g = Number(m[2]);
44
+ const b = Number(m[3]);
45
+ if ([r, g, b].some((n) => n > 255)) return undefined;
46
+ return [r, g, b];
47
+ }
48
+
49
+ return undefined;
50
+ }
51
+
52
+ const toHex = (n: number): string => Math.round(n).toString(16).padStart(2, '0');
53
+
54
+ /**
55
+ * Mix `ratio` of `color` into `base` (linear sRGB per-channel, like
56
+ * `color-mix(in srgb, color ratio, base)`), returning a hex string. If either
57
+ * input can't be parsed, returns `base` unchanged.
58
+ */
59
+ export function mixColors(color: string, base: string, ratio: number): string {
60
+ const fg = parseColor(color);
61
+ const bg = parseColor(base);
62
+ // Non-finite ratios (NaN softMix etc.) are unmixable — fall back to the
63
+ // base rather than emitting `#NaNNaNNaN`.
64
+ if (!fg || !bg || !Number.isFinite(ratio)) return base;
65
+ const t = Math.min(1, Math.max(0, ratio));
66
+ const mix = (i: 0 | 1 | 2) => fg[i] * t + bg[i] * (1 - t);
67
+ return `#${toHex(mix(0))}${toHex(mix(1))}${toHex(mix(2))}`;
68
+ }