@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.
- package/LICENSE +21 -0
- package/README.md +25 -0
- package/dist/components/SwiperIndicator.d.ts +43 -0
- package/dist/components/SwiperIndicator.d.ts.map +1 -0
- package/dist/components/SwiperIndicator.js +272 -0
- package/dist/components/SwiperIndicator.js.map +1 -0
- package/dist/contract.d.ts +95 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +30 -0
- package/dist/contract.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -0
- package/dist/layout/Center.d.ts +7 -0
- package/dist/layout/Center.d.ts.map +1 -0
- package/dist/layout/Center.js +25 -0
- package/dist/layout/Center.js.map +1 -0
- package/dist/layout/Col.d.ts +8 -0
- package/dist/layout/Col.d.ts.map +1 -0
- package/dist/layout/Col.js +34 -0
- package/dist/layout/Col.js.map +1 -0
- package/dist/layout/Row.d.ts +8 -0
- package/dist/layout/Row.d.ts.map +1 -0
- package/dist/layout/Row.js +34 -0
- package/dist/layout/Row.js.map +1 -0
- package/dist/layout/ScrollView.d.ts +6 -0
- package/dist/layout/ScrollView.d.ts.map +1 -0
- package/dist/layout/ScrollView.js +19 -0
- package/dist/layout/ScrollView.js.map +1 -0
- package/dist/layout/Spacer.d.ts +4 -0
- package/dist/layout/Spacer.d.ts.map +1 -0
- package/dist/layout/Spacer.js +12 -0
- package/dist/layout/Spacer.js.map +1 -0
- package/dist/preset/index.d.ts +31 -0
- package/dist/preset/index.d.ts.map +1 -0
- package/dist/preset/index.js +72 -0
- package/dist/preset/index.js.map +1 -0
- package/dist/shared/press.d.ts +3 -0
- package/dist/shared/press.d.ts.map +1 -0
- package/dist/shared/press.js +7 -0
- package/dist/shared/press.js.map +1 -0
- package/dist/shared/styles.d.ts +27 -0
- package/dist/shared/styles.d.ts.map +1 -0
- package/dist/shared/styles.js +62 -0
- package/dist/shared/styles.js.map +1 -0
- package/dist/shared/tabs-selection.d.ts +25 -0
- package/dist/shared/tabs-selection.d.ts.map +1 -0
- package/dist/shared/tabs-selection.js +45 -0
- package/dist/shared/tabs-selection.js.map +1 -0
- package/dist/styles/tokens.css +98 -0
- package/dist/theme/StatusBarSync.d.ts +42 -0
- package/dist/theme/StatusBarSync.d.ts.map +1 -0
- package/dist/theme/StatusBarSync.js +89 -0
- package/dist/theme/StatusBarSync.js.map +1 -0
- package/dist/theme/ThemeProvider.d.ts +144 -0
- package/dist/theme/ThemeProvider.d.ts.map +1 -0
- package/dist/theme/ThemeProvider.js +328 -0
- package/dist/theme/ThemeProvider.js.map +1 -0
- package/dist/theme/color-mix.d.ts +21 -0
- package/dist/theme/color-mix.d.ts.map +1 -0
- package/dist/theme/color-mix.js +65 -0
- package/dist/theme/color-mix.js.map +1 -0
- package/dist/theme/registry.d.ts +182 -0
- package/dist/theme/registry.d.ts.map +1 -0
- package/dist/theme/registry.js +182 -0
- package/dist/theme/registry.js.map +1 -0
- package/dist/theme/theme-state.d.ts +43 -0
- package/dist/theme/theme-state.d.ts.map +1 -0
- package/dist/theme/theme-state.js +94 -0
- package/dist/theme/theme-state.js.map +1 -0
- package/dist/theme/use-screen-theme.d.ts +4 -0
- package/dist/theme/use-screen-theme.d.ts.map +1 -0
- package/dist/theme/use-screen-theme.js +43 -0
- package/dist/theme/use-screen-theme.js.map +1 -0
- package/dist/theme/use-theme-colors.d.ts +48 -0
- package/dist/theme/use-theme-colors.d.ts.map +1 -0
- package/dist/theme/use-theme-colors.js +69 -0
- package/dist/theme/use-theme-colors.js.map +1 -0
- package/package.json +80 -0
- package/src/components/SwiperIndicator.tsx +519 -0
- package/src/contract.ts +136 -0
- package/src/index.ts +101 -0
- package/src/layout/Center.tsx +41 -0
- package/src/layout/Col.tsx +53 -0
- package/src/layout/Row.tsx +53 -0
- package/src/layout/ScrollView.tsx +38 -0
- package/src/layout/Spacer.tsx +18 -0
- package/src/preset/index.ts +77 -0
- package/src/shared/press.ts +6 -0
- package/src/shared/styles.ts +82 -0
- package/src/shared/tabs-selection.ts +57 -0
- package/src/styles/tokens.css +98 -0
- package/src/theme/StatusBarSync.tsx +104 -0
- package/src/theme/ThemeProvider.tsx +492 -0
- package/src/theme/color-mix.ts +68 -0
- package/src/theme/registry.ts +290 -0
- package/src/theme/theme-state.ts +112 -0
- package/src/theme/use-screen-theme.ts +42 -0
- 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
|
+
}
|