@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.
- package/LICENSE +21 -21
- package/README.md +25 -25
- package/dist/styles/tokens.css +98 -98
- package/dist/theme/ThemeProvider.d.ts +25 -1
- package/dist/theme/ThemeProvider.d.ts.map +1 -1
- package/dist/theme/ThemeProvider.js +17 -0
- package/dist/theme/ThemeProvider.js.map +1 -1
- package/package.json +13 -8
- package/src/components/SwiperIndicator.tsx +519 -519
- package/src/contract.ts +136 -136
- package/src/index.ts +101 -101
- package/src/layout/Center.tsx +41 -41
- package/src/layout/Col.tsx +53 -53
- package/src/layout/Row.tsx +53 -53
- package/src/layout/ScrollView.tsx +38 -38
- package/src/layout/Spacer.tsx +18 -18
- package/src/preset/index.ts +77 -77
- package/src/shared/press.ts +6 -6
- package/src/shared/styles.ts +82 -82
- package/src/shared/tabs-selection.ts +57 -57
- package/src/styles/tokens.css +98 -98
- package/src/theme/StatusBarSync.tsx +104 -104
- package/src/theme/ThemeProvider.tsx +532 -492
- package/src/theme/color-mix.ts +68 -68
- package/src/theme/registry.ts +290 -290
- package/src/theme/theme-state.ts +112 -112
- package/src/theme/use-screen-theme.ts +42 -42
- package/src/theme/use-theme-colors.ts +99 -99
package/src/theme/theme-state.ts
CHANGED
|
@@ -1,112 +1,112 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Global theme state — the headless DI singleton behind `useTheme()`.
|
|
3
|
-
*
|
|
4
|
-
* The active selection (current theme name + follow-system flag) lives here as
|
|
5
|
-
* a module-level signal, mirroring how `./registry.ts` is already a global
|
|
6
|
-
* module singleton. This is what makes theme control reachable from *headless*
|
|
7
|
-
* code — a store, a service, app-boot logic, an effect — not just from a
|
|
8
|
-
* component mounted under `<ThemeProvider>`.
|
|
9
|
-
*
|
|
10
|
-
* The root `<ThemeProvider>` (depth 0) binds to this state: it renders its host
|
|
11
|
-
* view from it, owns the system-color-scheme follow effect that writes to it
|
|
12
|
-
* while `following`, and seeds an `initial` prop into it. Nested providers
|
|
13
|
-
* (depth >= 1) build their own local state via `makeThemeController` so a
|
|
14
|
-
* subtree can be overridden without touching the global — see
|
|
15
|
-
* `./ThemeProvider.tsx`.
|
|
16
|
-
*
|
|
17
|
-
* `followSystem()` here only flips the flag; the actual re-apply on an OS color
|
|
18
|
-
* scheme change is driven by the root provider's follow effect (which has the
|
|
19
|
-
* appearance signal in scope).
|
|
20
|
-
*/
|
|
21
|
-
import { signal } from '@sigx/lynx';
|
|
22
|
-
import { pairOf, pickThemeFor } from './registry.js';
|
|
23
|
-
import type { ThemeController, ThemeName } from './ThemeProvider.js';
|
|
24
|
-
|
|
25
|
-
/** The mutable selection a `ThemeController` reads from and writes to. */
|
|
26
|
-
export interface ThemeState {
|
|
27
|
-
name: ThemeName;
|
|
28
|
-
following: boolean;
|
|
29
|
-
/**
|
|
30
|
-
* Global text-scale multiplier applied on top of the theme's `--text-*`
|
|
31
|
-
* ramp. Orthogonal to `name`: a theme switch / `toggle()` leaves it
|
|
32
|
-
* untouched, so a user/accessibility scale persists across appearance
|
|
33
|
-
* changes. `1` = the default ramp.
|
|
34
|
-
*/
|
|
35
|
-
fontScale: number;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Coerce a font-scale input to a valid positive, finite multiplier. Rejects
|
|
40
|
-
* `NaN`, `±Infinity`, and non-positive values — which would otherwise emit
|
|
41
|
-
* invalid CSS (`NaNpx`, negative font sizes) and break `fontScale === 1`
|
|
42
|
-
* comparisons — by returning `fallback` instead.
|
|
43
|
-
*/
|
|
44
|
-
export function normalizeFontScale(value: unknown, fallback = 1): number {
|
|
45
|
-
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
46
|
-
? value
|
|
47
|
-
: fallback;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Build a `ThemeController` over a given state object. Used for both the global
|
|
52
|
-
* singleton (below) and each nested `<ThemeProvider>`'s local state — same
|
|
53
|
-
* behaviour, different backing store. `followSystem()` only flips the flag; the
|
|
54
|
-
* owning provider's follow effect performs the re-apply.
|
|
55
|
-
*/
|
|
56
|
-
export function makeThemeController(state: ThemeState): ThemeController {
|
|
57
|
-
return {
|
|
58
|
-
get name() {
|
|
59
|
-
return state.name;
|
|
60
|
-
},
|
|
61
|
-
get followingSystem() {
|
|
62
|
-
return state.following;
|
|
63
|
-
},
|
|
64
|
-
get fontScale() {
|
|
65
|
-
return state.fontScale;
|
|
66
|
-
},
|
|
67
|
-
set(next) {
|
|
68
|
-
state.name = next;
|
|
69
|
-
state.following = false;
|
|
70
|
-
},
|
|
71
|
-
toggle() {
|
|
72
|
-
state.name = pairOf(state.name);
|
|
73
|
-
state.following = false;
|
|
74
|
-
},
|
|
75
|
-
followSystem() {
|
|
76
|
-
state.following = true;
|
|
77
|
-
},
|
|
78
|
-
setFontScale(scale) {
|
|
79
|
-
// Ignore invalid input (keep the current scale) so state stays valid.
|
|
80
|
-
state.fontScale = normalizeFontScale(scale, state.fontScale);
|
|
81
|
-
},
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Object signal (not primitive) so theme-name literal unions a DS layers on
|
|
86
|
-
// top survive — `signal<T>` widens primitive literals to plain `string` via
|
|
87
|
-
// `Widen<T>`. Seeded from whatever is registered at first import (a DS package
|
|
88
|
-
// seeds the registry at its own module load; until then the name is '') —
|
|
89
|
-
// the root <ThemeProvider> re-seeds from the system color scheme + its props
|
|
90
|
-
// on mount.
|
|
91
|
-
const state = signal<ThemeState>({
|
|
92
|
-
name: pickThemeFor('light'),
|
|
93
|
-
following: true,
|
|
94
|
-
fontScale: 1,
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* The backing signal for the global theme. Read/written by the root
|
|
99
|
-
* `<ThemeProvider>` and shared with `themeController`; not part of the public
|
|
100
|
-
* API.
|
|
101
|
-
* @internal
|
|
102
|
-
*/
|
|
103
|
-
export const globalThemeState = state;
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* The global theme controller — the headless handle. Import and call from
|
|
107
|
-
* anywhere (no `<ThemeProvider>` ancestor required); `useTheme()`'s default
|
|
108
|
-
* factory returns this same instance, and the root `<ThemeProvider>` provides
|
|
109
|
-
* it to its subtree. `StatusBarSync` binds to it so the OS bars always follow
|
|
110
|
-
* the global/screen theme, never a content sub-scope.
|
|
111
|
-
*/
|
|
112
|
-
export const themeController: ThemeController = makeThemeController(state);
|
|
1
|
+
/**
|
|
2
|
+
* Global theme state — the headless DI singleton behind `useTheme()`.
|
|
3
|
+
*
|
|
4
|
+
* The active selection (current theme name + follow-system flag) lives here as
|
|
5
|
+
* a module-level signal, mirroring how `./registry.ts` is already a global
|
|
6
|
+
* module singleton. This is what makes theme control reachable from *headless*
|
|
7
|
+
* code — a store, a service, app-boot logic, an effect — not just from a
|
|
8
|
+
* component mounted under `<ThemeProvider>`.
|
|
9
|
+
*
|
|
10
|
+
* The root `<ThemeProvider>` (depth 0) binds to this state: it renders its host
|
|
11
|
+
* view from it, owns the system-color-scheme follow effect that writes to it
|
|
12
|
+
* while `following`, and seeds an `initial` prop into it. Nested providers
|
|
13
|
+
* (depth >= 1) build their own local state via `makeThemeController` so a
|
|
14
|
+
* subtree can be overridden without touching the global — see
|
|
15
|
+
* `./ThemeProvider.tsx`.
|
|
16
|
+
*
|
|
17
|
+
* `followSystem()` here only flips the flag; the actual re-apply on an OS color
|
|
18
|
+
* scheme change is driven by the root provider's follow effect (which has the
|
|
19
|
+
* appearance signal in scope).
|
|
20
|
+
*/
|
|
21
|
+
import { signal } from '@sigx/lynx';
|
|
22
|
+
import { pairOf, pickThemeFor } from './registry.js';
|
|
23
|
+
import type { ThemeController, ThemeName } from './ThemeProvider.js';
|
|
24
|
+
|
|
25
|
+
/** The mutable selection a `ThemeController` reads from and writes to. */
|
|
26
|
+
export interface ThemeState {
|
|
27
|
+
name: ThemeName;
|
|
28
|
+
following: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Global text-scale multiplier applied on top of the theme's `--text-*`
|
|
31
|
+
* ramp. Orthogonal to `name`: a theme switch / `toggle()` leaves it
|
|
32
|
+
* untouched, so a user/accessibility scale persists across appearance
|
|
33
|
+
* changes. `1` = the default ramp.
|
|
34
|
+
*/
|
|
35
|
+
fontScale: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Coerce a font-scale input to a valid positive, finite multiplier. Rejects
|
|
40
|
+
* `NaN`, `±Infinity`, and non-positive values — which would otherwise emit
|
|
41
|
+
* invalid CSS (`NaNpx`, negative font sizes) and break `fontScale === 1`
|
|
42
|
+
* comparisons — by returning `fallback` instead.
|
|
43
|
+
*/
|
|
44
|
+
export function normalizeFontScale(value: unknown, fallback = 1): number {
|
|
45
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
46
|
+
? value
|
|
47
|
+
: fallback;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build a `ThemeController` over a given state object. Used for both the global
|
|
52
|
+
* singleton (below) and each nested `<ThemeProvider>`'s local state — same
|
|
53
|
+
* behaviour, different backing store. `followSystem()` only flips the flag; the
|
|
54
|
+
* owning provider's follow effect performs the re-apply.
|
|
55
|
+
*/
|
|
56
|
+
export function makeThemeController(state: ThemeState): ThemeController {
|
|
57
|
+
return {
|
|
58
|
+
get name() {
|
|
59
|
+
return state.name;
|
|
60
|
+
},
|
|
61
|
+
get followingSystem() {
|
|
62
|
+
return state.following;
|
|
63
|
+
},
|
|
64
|
+
get fontScale() {
|
|
65
|
+
return state.fontScale;
|
|
66
|
+
},
|
|
67
|
+
set(next) {
|
|
68
|
+
state.name = next;
|
|
69
|
+
state.following = false;
|
|
70
|
+
},
|
|
71
|
+
toggle() {
|
|
72
|
+
state.name = pairOf(state.name);
|
|
73
|
+
state.following = false;
|
|
74
|
+
},
|
|
75
|
+
followSystem() {
|
|
76
|
+
state.following = true;
|
|
77
|
+
},
|
|
78
|
+
setFontScale(scale) {
|
|
79
|
+
// Ignore invalid input (keep the current scale) so state stays valid.
|
|
80
|
+
state.fontScale = normalizeFontScale(scale, state.fontScale);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Object signal (not primitive) so theme-name literal unions a DS layers on
|
|
86
|
+
// top survive — `signal<T>` widens primitive literals to plain `string` via
|
|
87
|
+
// `Widen<T>`. Seeded from whatever is registered at first import (a DS package
|
|
88
|
+
// seeds the registry at its own module load; until then the name is '') —
|
|
89
|
+
// the root <ThemeProvider> re-seeds from the system color scheme + its props
|
|
90
|
+
// on mount.
|
|
91
|
+
const state = signal<ThemeState>({
|
|
92
|
+
name: pickThemeFor('light'),
|
|
93
|
+
following: true,
|
|
94
|
+
fontScale: 1,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The backing signal for the global theme. Read/written by the root
|
|
99
|
+
* `<ThemeProvider>` and shared with `themeController`; not part of the public
|
|
100
|
+
* API.
|
|
101
|
+
* @internal
|
|
102
|
+
*/
|
|
103
|
+
export const globalThemeState = state;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The global theme controller — the headless handle. Import and call from
|
|
107
|
+
* anywhere (no `<ThemeProvider>` ancestor required); `useTheme()`'s default
|
|
108
|
+
* factory returns this same instance, and the root `<ThemeProvider>` provides
|
|
109
|
+
* it to its subtree. `StatusBarSync` binds to it so the OS bars always follow
|
|
110
|
+
* the global/screen theme, never a content sub-scope.
|
|
111
|
+
*/
|
|
112
|
+
export const themeController: ThemeController = makeThemeController(state);
|
|
@@ -1,42 +1,42 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `useScreenTheme(name)` — pin the **global** theme while a navigation
|
|
3
|
-
* screen is focused, restoring the previous selection when it blurs.
|
|
4
|
-
*
|
|
5
|
-
* This is the right tool for *per-screen* theming — "this screen is dark, that
|
|
6
|
-
* one is light." Because it drives the global theme (not a content sub-scope),
|
|
7
|
-
* the OS status/navigation bars follow automatically via `<StatusBarSync>`, so
|
|
8
|
-
* the bar icons stay legible against each screen's background. For recoloring a
|
|
9
|
-
* *region within* a screen without touching the bars, nest a `<ThemeProvider>`
|
|
10
|
-
* instead.
|
|
11
|
-
*
|
|
12
|
-
* Built on `useFocusEffect` from `@sigx/lynx-navigation` (an optional peer
|
|
13
|
-
* dependency): it must be called from inside a component rendered as a route by
|
|
14
|
-
* `<Stack>` / `<Tabs>` — the same constraint as `useFocusEffect`/`useIsFocused`.
|
|
15
|
-
*
|
|
16
|
-
* Save/restore composes with the stack (LIFO focus/blur): pushing a themed
|
|
17
|
-
* screen saves whatever was live, applies its own theme, and restores on pop —
|
|
18
|
-
* including resuming follow-system if that's what was active.
|
|
19
|
-
*
|
|
20
|
-
* ```tsx
|
|
21
|
-
* const Gallery = component(() => {
|
|
22
|
-
* useScreenTheme('daisy-dark'); // dark while this screen is on top
|
|
23
|
-
* return () => <view>…</view>;
|
|
24
|
-
* });
|
|
25
|
-
* ```
|
|
26
|
-
*/
|
|
27
|
-
import { useFocusEffect } from '@sigx/lynx-navigation';
|
|
28
|
-
import { themeController } from './theme-state.js';
|
|
29
|
-
import type { ThemeName } from './ThemeProvider.js';
|
|
30
|
-
|
|
31
|
-
/** Pin the global theme to `name` while this screen is focused; restore on blur. */
|
|
32
|
-
export function useScreenTheme(name: ThemeName): void {
|
|
33
|
-
useFocusEffect(() => {
|
|
34
|
-
const prevName = themeController.name;
|
|
35
|
-
const prevFollowing = themeController.followingSystem;
|
|
36
|
-
themeController.set(name);
|
|
37
|
-
return () => {
|
|
38
|
-
if (prevFollowing) themeController.followSystem();
|
|
39
|
-
else themeController.set(prevName);
|
|
40
|
-
};
|
|
41
|
-
});
|
|
42
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* `useScreenTheme(name)` — pin the **global** theme while a navigation
|
|
3
|
+
* screen is focused, restoring the previous selection when it blurs.
|
|
4
|
+
*
|
|
5
|
+
* This is the right tool for *per-screen* theming — "this screen is dark, that
|
|
6
|
+
* one is light." Because it drives the global theme (not a content sub-scope),
|
|
7
|
+
* the OS status/navigation bars follow automatically via `<StatusBarSync>`, so
|
|
8
|
+
* the bar icons stay legible against each screen's background. For recoloring a
|
|
9
|
+
* *region within* a screen without touching the bars, nest a `<ThemeProvider>`
|
|
10
|
+
* instead.
|
|
11
|
+
*
|
|
12
|
+
* Built on `useFocusEffect` from `@sigx/lynx-navigation` (an optional peer
|
|
13
|
+
* dependency): it must be called from inside a component rendered as a route by
|
|
14
|
+
* `<Stack>` / `<Tabs>` — the same constraint as `useFocusEffect`/`useIsFocused`.
|
|
15
|
+
*
|
|
16
|
+
* Save/restore composes with the stack (LIFO focus/blur): pushing a themed
|
|
17
|
+
* screen saves whatever was live, applies its own theme, and restores on pop —
|
|
18
|
+
* including resuming follow-system if that's what was active.
|
|
19
|
+
*
|
|
20
|
+
* ```tsx
|
|
21
|
+
* const Gallery = component(() => {
|
|
22
|
+
* useScreenTheme('daisy-dark'); // dark while this screen is on top
|
|
23
|
+
* return () => <view>…</view>;
|
|
24
|
+
* });
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
import { useFocusEffect } from '@sigx/lynx-navigation';
|
|
28
|
+
import { themeController } from './theme-state.js';
|
|
29
|
+
import type { ThemeName } from './ThemeProvider.js';
|
|
30
|
+
|
|
31
|
+
/** Pin the global theme to `name` while this screen is focused; restore on blur. */
|
|
32
|
+
export function useScreenTheme(name: ThemeName): void {
|
|
33
|
+
useFocusEffect(() => {
|
|
34
|
+
const prevName = themeController.name;
|
|
35
|
+
const prevFollowing = themeController.followingSystem;
|
|
36
|
+
themeController.set(name);
|
|
37
|
+
return () => {
|
|
38
|
+
if (prevFollowing) themeController.followSystem();
|
|
39
|
+
else themeController.set(prevName);
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -1,99 +1,99 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `useThemeColors()` — resolve the *active, scoped* theme palette to concrete
|
|
3
|
-
* color values for consumers that can't read CSS custom properties.
|
|
4
|
-
*
|
|
5
|
-
* Native widgets are the audience: platform text inputs, `<sigx-richtext>`,
|
|
6
|
-
* SVG fills — anything where `var(--color-*)` never resolves because the
|
|
7
|
-
* value is consumed outside Lynx's CSS pipeline. Components pass these
|
|
8
|
-
* resolved literals via inline `style` or native props instead.
|
|
9
|
-
*
|
|
10
|
-
* Scoped + reactive: resolves through `useTheme()` (the nearest
|
|
11
|
-
* `<ThemeProvider>`'s controller, falling back to the global one), and the
|
|
12
|
-
* getters read `theme.name` — call them inside render and a theme switch
|
|
13
|
-
* recolors the consumer.
|
|
14
|
-
*
|
|
15
|
-
* ```tsx
|
|
16
|
-
* const colors = useThemeColors();
|
|
17
|
-
* return () => (
|
|
18
|
-
* <input style={{
|
|
19
|
-
* color: colors.colorOf('base-content'),
|
|
20
|
-
* '-x-placeholder-color': colors.colorOf('base-content', 0.45),
|
|
21
|
-
* }} />
|
|
22
|
-
* );
|
|
23
|
-
* ```
|
|
24
|
-
*/
|
|
25
|
-
import type { ColorToken } from '../contract.js';
|
|
26
|
-
import { colorsOf, fallbackPalette } from './registry.js';
|
|
27
|
-
import { useTheme } from './ThemeProvider.js';
|
|
28
|
-
|
|
29
|
-
export interface ThemeColors {
|
|
30
|
-
/**
|
|
31
|
-
* The active palette's value for `token`, normalized to hex — optionally
|
|
32
|
-
* with `alpha` (0–1) appended as a hex byte (`#RRGGBBAA`). Returns `''`
|
|
33
|
-
* when no theme is registered yet (pre-DS-import edge case).
|
|
34
|
-
*/
|
|
35
|
-
colorOf(token: ColorToken, alpha?: number): string;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function useThemeColors(): ThemeColors {
|
|
39
|
-
const theme = useTheme();
|
|
40
|
-
return {
|
|
41
|
-
colorOf(token, alpha) {
|
|
42
|
-
// Reading `theme.name` here is what makes call sites reactive.
|
|
43
|
-
const palette = colorsOf(theme.name) ?? fallbackPalette();
|
|
44
|
-
const raw = palette?.[token];
|
|
45
|
-
if (!raw) return '';
|
|
46
|
-
const hex = toHexColor(raw);
|
|
47
|
-
return alpha === undefined ? hex : withAlpha(hex, alpha);
|
|
48
|
-
},
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Normalize an engine-safe palette color to full-form hex
|
|
54
|
-
* (`#RRGGBB`/`#RRGGBBAA`) — the registry allows `rgb()`/`rgba()` entries and
|
|
55
|
-
* shorthand hex, but native color parsers may accept only the full forms.
|
|
56
|
-
* Unknown notations pass through unchanged.
|
|
57
|
-
*/
|
|
58
|
-
export function toHexColor(color: string): string {
|
|
59
|
-
const c = color.trim();
|
|
60
|
-
if (c.startsWith('#')) {
|
|
61
|
-
const h = c.slice(1);
|
|
62
|
-
// Expand #RGB / #RGBA to the full form; leave other lengths as-is.
|
|
63
|
-
if (h.length === 3 || h.length === 4) {
|
|
64
|
-
return `#${h.split('').map((ch) => ch + ch).join('')}`;
|
|
65
|
-
}
|
|
66
|
-
return c;
|
|
67
|
-
}
|
|
68
|
-
const m = /^rgba?\(\s*(\d+)\s*[, ]\s*(\d+)\s*[, ]\s*(\d+)\s*(?:[,/]\s*([\d.]+%?)\s*)?\)$/i.exec(c);
|
|
69
|
-
if (!m) return c;
|
|
70
|
-
const byte = (v: number): string =>
|
|
71
|
-
Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0');
|
|
72
|
-
let hex = `#${byte(Number(m[1]))}${byte(Number(m[2]))}${byte(Number(m[3]))}`;
|
|
73
|
-
if (m[4] !== undefined) {
|
|
74
|
-
const a = m[4].endsWith('%') ? Number(m[4].slice(0, -1)) / 100 : Number(m[4]);
|
|
75
|
-
hex += byte(Math.max(0, Math.min(1, a)) * 255);
|
|
76
|
-
}
|
|
77
|
-
return hex;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Append an alpha channel (0–1) to a hex color
|
|
82
|
-
* (`#RGB`/`#RRGGBB`/`#RRGGBBAA` → `#RRGGBBAA`). Non-hex input passes
|
|
83
|
-
* through unchanged; non-finite alpha is treated as opaque.
|
|
84
|
-
*/
|
|
85
|
-
export function withAlpha(hex: string, alpha: number): string {
|
|
86
|
-
const input = hex.trim();
|
|
87
|
-
if (!input.startsWith('#')) return input;
|
|
88
|
-
if (!Number.isFinite(alpha)) return input;
|
|
89
|
-
let h = input.slice(1);
|
|
90
|
-
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
|
91
|
-
if (h.length === 8) h = h.slice(0, 6);
|
|
92
|
-
// Anything that isn't #RRGGBB by now (#RGBA, bad lengths) is not a shape
|
|
93
|
-
// we can safely re-alpha — return it unchanged rather than corrupt it.
|
|
94
|
-
if (h.length !== 6) return input;
|
|
95
|
-
const byte = Math.round(Math.max(0, Math.min(1, alpha)) * 255)
|
|
96
|
-
.toString(16)
|
|
97
|
-
.padStart(2, '0');
|
|
98
|
-
return `#${h}${byte}`;
|
|
99
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* `useThemeColors()` — resolve the *active, scoped* theme palette to concrete
|
|
3
|
+
* color values for consumers that can't read CSS custom properties.
|
|
4
|
+
*
|
|
5
|
+
* Native widgets are the audience: platform text inputs, `<sigx-richtext>`,
|
|
6
|
+
* SVG fills — anything where `var(--color-*)` never resolves because the
|
|
7
|
+
* value is consumed outside Lynx's CSS pipeline. Components pass these
|
|
8
|
+
* resolved literals via inline `style` or native props instead.
|
|
9
|
+
*
|
|
10
|
+
* Scoped + reactive: resolves through `useTheme()` (the nearest
|
|
11
|
+
* `<ThemeProvider>`'s controller, falling back to the global one), and the
|
|
12
|
+
* getters read `theme.name` — call them inside render and a theme switch
|
|
13
|
+
* recolors the consumer.
|
|
14
|
+
*
|
|
15
|
+
* ```tsx
|
|
16
|
+
* const colors = useThemeColors();
|
|
17
|
+
* return () => (
|
|
18
|
+
* <input style={{
|
|
19
|
+
* color: colors.colorOf('base-content'),
|
|
20
|
+
* '-x-placeholder-color': colors.colorOf('base-content', 0.45),
|
|
21
|
+
* }} />
|
|
22
|
+
* );
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
import type { ColorToken } from '../contract.js';
|
|
26
|
+
import { colorsOf, fallbackPalette } from './registry.js';
|
|
27
|
+
import { useTheme } from './ThemeProvider.js';
|
|
28
|
+
|
|
29
|
+
export interface ThemeColors {
|
|
30
|
+
/**
|
|
31
|
+
* The active palette's value for `token`, normalized to hex — optionally
|
|
32
|
+
* with `alpha` (0–1) appended as a hex byte (`#RRGGBBAA`). Returns `''`
|
|
33
|
+
* when no theme is registered yet (pre-DS-import edge case).
|
|
34
|
+
*/
|
|
35
|
+
colorOf(token: ColorToken, alpha?: number): string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function useThemeColors(): ThemeColors {
|
|
39
|
+
const theme = useTheme();
|
|
40
|
+
return {
|
|
41
|
+
colorOf(token, alpha) {
|
|
42
|
+
// Reading `theme.name` here is what makes call sites reactive.
|
|
43
|
+
const palette = colorsOf(theme.name) ?? fallbackPalette();
|
|
44
|
+
const raw = palette?.[token];
|
|
45
|
+
if (!raw) return '';
|
|
46
|
+
const hex = toHexColor(raw);
|
|
47
|
+
return alpha === undefined ? hex : withAlpha(hex, alpha);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Normalize an engine-safe palette color to full-form hex
|
|
54
|
+
* (`#RRGGBB`/`#RRGGBBAA`) — the registry allows `rgb()`/`rgba()` entries and
|
|
55
|
+
* shorthand hex, but native color parsers may accept only the full forms.
|
|
56
|
+
* Unknown notations pass through unchanged.
|
|
57
|
+
*/
|
|
58
|
+
export function toHexColor(color: string): string {
|
|
59
|
+
const c = color.trim();
|
|
60
|
+
if (c.startsWith('#')) {
|
|
61
|
+
const h = c.slice(1);
|
|
62
|
+
// Expand #RGB / #RGBA to the full form; leave other lengths as-is.
|
|
63
|
+
if (h.length === 3 || h.length === 4) {
|
|
64
|
+
return `#${h.split('').map((ch) => ch + ch).join('')}`;
|
|
65
|
+
}
|
|
66
|
+
return c;
|
|
67
|
+
}
|
|
68
|
+
const m = /^rgba?\(\s*(\d+)\s*[, ]\s*(\d+)\s*[, ]\s*(\d+)\s*(?:[,/]\s*([\d.]+%?)\s*)?\)$/i.exec(c);
|
|
69
|
+
if (!m) return c;
|
|
70
|
+
const byte = (v: number): string =>
|
|
71
|
+
Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0');
|
|
72
|
+
let hex = `#${byte(Number(m[1]))}${byte(Number(m[2]))}${byte(Number(m[3]))}`;
|
|
73
|
+
if (m[4] !== undefined) {
|
|
74
|
+
const a = m[4].endsWith('%') ? Number(m[4].slice(0, -1)) / 100 : Number(m[4]);
|
|
75
|
+
hex += byte(Math.max(0, Math.min(1, a)) * 255);
|
|
76
|
+
}
|
|
77
|
+
return hex;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Append an alpha channel (0–1) to a hex color
|
|
82
|
+
* (`#RGB`/`#RRGGBB`/`#RRGGBBAA` → `#RRGGBBAA`). Non-hex input passes
|
|
83
|
+
* through unchanged; non-finite alpha is treated as opaque.
|
|
84
|
+
*/
|
|
85
|
+
export function withAlpha(hex: string, alpha: number): string {
|
|
86
|
+
const input = hex.trim();
|
|
87
|
+
if (!input.startsWith('#')) return input;
|
|
88
|
+
if (!Number.isFinite(alpha)) return input;
|
|
89
|
+
let h = input.slice(1);
|
|
90
|
+
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
|
91
|
+
if (h.length === 8) h = h.slice(0, 6);
|
|
92
|
+
// Anything that isn't #RRGGBB by now (#RGBA, bad lengths) is not a shape
|
|
93
|
+
// we can safely re-alpha — return it unchanged rather than corrupt it.
|
|
94
|
+
if (h.length !== 6) return input;
|
|
95
|
+
const byte = Math.round(Math.max(0, Math.min(1, alpha)) * 255)
|
|
96
|
+
.toString(16)
|
|
97
|
+
.padStart(2, '0');
|
|
98
|
+
return `#${h}${byte}`;
|
|
99
|
+
}
|