@sigx/lynx-daisyui 0.4.2 → 0.4.4

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/README.md CHANGED
@@ -83,10 +83,88 @@ layout role. For multi-class compositions (color + modifier),
83
83
  `theme.set('daisy-light daisy-rounded')` works — the class string is
84
84
  applied verbatim to the host view.
85
85
 
86
+ ### Two layers: content vs. OS chrome
87
+
88
+ A theme drives two different things, and they scope differently:
89
+
90
+ 1. **In-app content** — the `--color-*` / radius variables and icon
91
+ tints. These live on a host view and inherit down a subtree, so they
92
+ are genuinely *scopable*.
93
+ 2. **OS chrome** — the status- and navigation-bar tint (pushed by
94
+ `<StatusBarSync>`). This is a global OS singleton; it can only reflect
95
+ one theme at a time.
96
+
97
+ The rule:
98
+
99
+ > `useTheme()` is the theme for the **content you render** — the nearest
100
+ > `<ThemeProvider>`, or the app-global theme at the root / in headless
101
+ > code. **System chrome always follows the global theme.** `StatusBarSync`
102
+ > binds to the global controller, so a nested provider can't hijack the
103
+ > bars.
104
+ >
105
+ > *Scopes recolor pixels you draw; only the global theme touches the OS.*
106
+
107
+ This mirrors Flutter, where `Theme` nests freely for content while system
108
+ chrome goes through a separate channel (`AnnotatedRegion`/`SystemChrome`).
109
+
110
+ ### Headless control (no provider required)
111
+
112
+ The active theme lives in a module-level singleton, so you can read and
113
+ set it from anywhere — a store, a service, app-boot logic, an effect —
114
+ without a mounted `<ThemeProvider>` ancestor. `useTheme()` resolves to
115
+ this same controller when no provider is in scope (it never throws).
116
+
117
+ ```tsx
118
+ import { themeController } from '@sigx/lynx-daisyui';
119
+
120
+ // From any non-component module:
121
+ themeController.set('daisy-dark');
122
+ themeController.toggle();
123
+ themeController.followSystem();
124
+ themeController.name; // current selection
125
+ ```
126
+
127
+ A mounted root `<ThemeProvider>` binds this singleton, so headless
128
+ mutations render and the OS bars follow.
129
+
130
+ ### Per-screen themes
131
+
132
+ Different screens can use different themes — and the status-bar icons
133
+ follow the active screen so they stay legible. Because this drives the
134
+ **global** theme, the bars update automatically:
135
+
136
+ ```tsx
137
+ import { useScreenTheme } from '@sigx/lynx-daisyui';
138
+
139
+ const Gallery = component(() => {
140
+ useScreenTheme('daisy-dark'); // dark (incl. status bar) while focused; restored on blur
141
+ return () => <view>…</view>;
142
+ });
143
+ ```
144
+
145
+ `useScreenTheme` is built on `@sigx/lynx-navigation`'s `useFocusEffect`
146
+ (an optional peer) and must be called from a routed screen.
147
+
148
+ ### Scoped sub-overrides
149
+
150
+ To recolor just a **region** without touching the OS bars, nest a
151
+ `<ThemeProvider>`. Its subtree (content + icons) re-themes; the status
152
+ bar stays on the global theme.
153
+
154
+ ```tsx
155
+ <ThemeProvider initial="daisy-light">
156
+ <App />
157
+ {/* this card renders synthwave; the status bar stays light */}
158
+ <ThemeProvider initial="daisy-synthwave">
159
+ <PreviewCard />
160
+ </ThemeProvider>
161
+ </ThemeProvider>
162
+ ```
163
+
86
164
  ## Navigation chrome
87
165
 
88
166
  Two daisy-themed components that pair with
89
- [`@sigx/lynx-navigation`](../lynx-navigation). Both read state via the
167
+ [`@sigx/lynx-navigation`](https://github.com/signalxjs/lynx/tree/main/packages/lynx-navigation). Both read state via the
90
168
  navigation package's hooks (no internal-module imports), so swapping
91
169
  in custom designs later is a one-component change.
92
170
 
@@ -146,7 +224,7 @@ one consumer of that hook.
146
224
  ### `<SwiperIndicator>`
147
225
 
148
226
  Themed wrapper around the headless `useSwiperDot*` hooks from
149
- [`@sigx/lynx-gestures`](../lynx-gestures#swiper-and-headless-dot-hooks).
227
+ [`@sigx/lynx-gestures`](https://github.com/signalxjs/lynx/tree/main/packages/lynx-gestures#swiper-and-headless-dot-hooks).
150
228
  Reads colours from the active daisy theme so the indicator follows light
151
229
  / dark mode automatically.
152
230
 
package/dist/index.d.ts CHANGED
@@ -54,10 +54,12 @@ export { NavDrawer } from './navigation/NavDrawer.js';
54
54
  export type { NavDrawerProps, NavDrawerSide, } from './navigation/NavDrawer.js';
55
55
  export { SwiperIndicator } from './navigation/SwiperIndicator.js';
56
56
  export type { SwiperIndicatorProps, SwiperIndicatorVariant, SwiperIndicatorSize, } from './navigation/SwiperIndicator.js';
57
- export { ThemeProvider, useTheme, listThemes, registerTheme, extendTheme, pickThemeFor, pairOf, variantOf, colorsOf, radiusOf, } from './theme/ThemeProvider.js';
58
- export type { DaisyTheme, ThemeController, ThemeProviderProps, Theme, ThemePalette, ThemeRadius, ThemeVariant, } from './theme/ThemeProvider.js';
57
+ export { ThemeProvider, useTheme, listThemes, registerTheme, extendTheme, pickThemeFor, pairOf, variantOf, colorsOf, radiusOf, sizesOf, } from './theme/ThemeProvider.js';
58
+ export type { DaisyTheme, ThemeController, ThemeProviderProps, Theme, ThemePalette, ThemeRadius, ThemeSizes, ThemeVariant, } from './theme/ThemeProvider.js';
59
59
  export { StatusBarSync } from './theme/StatusBarSync.js';
60
60
  export type { StatusBarSyncProps } from './theme/StatusBarSync.js';
61
+ export { themeController } from './theme/theme-state.js';
62
+ export { useScreenTheme } from './theme/use-screen-theme.js';
61
63
  export { Avatar } from './data/Avatar.js';
62
64
  export type { AvatarProps, AvatarSize } from './data/Avatar.js';
63
65
  export { Text } from './typography/Text.js';
package/dist/index.js CHANGED
@@ -32,8 +32,15 @@ export { NavHeader } from './navigation/NavHeader.js';
32
32
  export { NavDrawer } from './navigation/NavDrawer.js';
33
33
  export { SwiperIndicator } from './navigation/SwiperIndicator.js';
34
34
  // Theme
35
- export { ThemeProvider, useTheme, listThemes, registerTheme, extendTheme, pickThemeFor, pairOf, variantOf, colorsOf, radiusOf, } from './theme/ThemeProvider.js';
35
+ export { ThemeProvider, useTheme, listThemes, registerTheme, extendTheme, pickThemeFor, pairOf, variantOf, colorsOf, radiusOf, sizesOf, } from './theme/ThemeProvider.js';
36
36
  export { StatusBarSync } from './theme/StatusBarSync.js';
37
+ // Headless theme handle (issue #113): import and call from anywhere — stores,
38
+ // services, effects, app-boot — with no `<ThemeProvider>` ancestor required.
39
+ // `useTheme()` resolves to this when no provider is in scope.
40
+ export { themeController } from './theme/theme-state.js';
41
+ // Per-screen theming: pin the global theme while a navigation screen is focused
42
+ // (requires the optional `@sigx/lynx-navigation` peer).
43
+ export { useScreenTheme } from './theme/use-screen-theme.js';
37
44
  // Data
38
45
  export { Avatar } from './data/Avatar.js';
39
46
  // Typography
@@ -5,7 +5,7 @@
5
5
  flex-direction: row;
6
6
  align-items: center;
7
7
  padding: var(--padding-box);
8
- border-radius: var(--rounded-box);
8
+ border-radius: var(--radius-box);
9
9
  gap: 12px;
10
10
  background-color: var(--color-base-200);
11
11
  color: var(--color-base-content);
@@ -7,7 +7,7 @@
7
7
  height: var(--badge-md);
8
8
  padding-left: 10px;
9
9
  padding-right: 10px;
10
- border-radius: var(--rounded-badge);
10
+ border-radius: var(--radius-selector);
11
11
  font-size: var(--font-md);
12
12
  font-weight: 600;
13
13
  background-color: var(--color-base-200);
@@ -8,7 +8,7 @@
8
8
  min-height: var(--size-md);
9
9
  padding-left: var(--padding-btn-md);
10
10
  padding-right: var(--padding-btn-md);
11
- border-radius: var(--rounded-btn);
11
+ border-radius: var(--radius-field);
12
12
  font-size: var(--font-md);
13
13
  font-weight: 600;
14
14
  line-height: 1;
@@ -3,7 +3,7 @@
3
3
  .card {
4
4
  background-color: var(--color-base-200);
5
5
  color: var(--color-base-content);
6
- border-radius: var(--rounded-box);
6
+ border-radius: var(--radius-box);
7
7
  overflow: hidden;
8
8
  }
9
9
 
@@ -3,7 +3,7 @@
3
3
  .checkbox {
4
4
  width: var(--checkbox-md);
5
5
  height: var(--checkbox-md);
6
- border-radius: var(--rounded-selector);
6
+ border-radius: var(--radius-selector);
7
7
  border-width: 1px;
8
8
  border-color: var(--color-base-300);
9
9
  background-color: transparent;
@@ -4,7 +4,7 @@
4
4
  height: var(--size-md);
5
5
  padding-left: var(--padding-btn-md);
6
6
  padding-right: var(--padding-btn-md);
7
- border-radius: var(--rounded-btn);
7
+ border-radius: var(--radius-field);
8
8
  font-size: var(--font-md);
9
9
  background-color: var(--color-base-100);
10
10
  color: var(--color-base-content);
@@ -15,7 +15,7 @@
15
15
  .modal-box {
16
16
  max-width: var(--modal-max-width);
17
17
  width: 90%;
18
- border-radius: var(--rounded-box);
18
+ border-radius: var(--radius-box);
19
19
  background-color: var(--color-base-200);
20
20
  padding: 0;
21
21
  overflow: hidden;
@@ -7,7 +7,7 @@
7
7
  height: var(--size-md);
8
8
  padding-left: var(--padding-btn-md);
9
9
  padding-right: var(--padding-btn-md);
10
- border-radius: var(--rounded-btn);
10
+ border-radius: var(--radius-field);
11
11
  font-size: var(--font-md);
12
12
  background-color: var(--color-base-100);
13
13
  color: var(--color-base-content);
@@ -38,7 +38,7 @@
38
38
  background-color: var(--color-base-100);
39
39
  border-width: var(--border-btn);
40
40
  border-color: var(--color-base-300);
41
- border-radius: var(--rounded-btn);
41
+ border-radius: var(--radius-field);
42
42
  overflow: hidden;
43
43
  }
44
44
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  .skeleton {
4
4
  background-color: var(--color-base-300);
5
- border-radius: var(--rounded-btn);
5
+ border-radius: var(--radius-field);
6
6
  }
@@ -4,7 +4,7 @@
4
4
  padding: 12px;
5
5
  padding-left: var(--padding-btn-md);
6
6
  padding-right: var(--padding-btn-md);
7
- border-radius: var(--rounded-btn);
7
+ border-radius: var(--radius-field);
8
8
  font-size: var(--font-md);
9
9
  background-color: var(--color-base-100);
10
10
  color: var(--color-base-content);
@@ -8,10 +8,10 @@
8
8
  }
9
9
 
10
10
  /* Size variants — track dimensions */
11
- .toggle-xs { width: var(--toggle-width-xs); height: var(--toggle-height-xs); border-radius: var(--rounded-toggle); }
12
- .toggle-sm { width: var(--toggle-width-sm); height: var(--toggle-height-sm); border-radius: var(--rounded-toggle); }
13
- .toggle-md { width: var(--toggle-width-md); height: var(--toggle-height-md); border-radius: var(--rounded-toggle); }
14
- .toggle-lg { width: var(--toggle-width-lg); height: var(--toggle-height-lg); border-radius: var(--rounded-toggle); }
11
+ .toggle-xs { width: var(--toggle-width-xs); height: var(--toggle-height-xs); border-radius: var(--radius-selector); }
12
+ .toggle-sm { width: var(--toggle-width-sm); height: var(--toggle-height-sm); border-radius: var(--radius-selector); }
13
+ .toggle-md { width: var(--toggle-width-md); height: var(--toggle-height-md); border-radius: var(--radius-selector); }
14
+ .toggle-lg { width: var(--toggle-width-lg); height: var(--toggle-height-lg); border-radius: var(--radius-selector); }
15
15
 
16
16
  /* Thumb */
17
17
  .toggle-thumb {
@@ -8,6 +8,11 @@
8
8
  /* Structural design tokens (.daisy) + composable shape modifiers */
9
9
  @import './themes/tokens.css';
10
10
  @import './themes/shapes.css';
11
+ /* Per-theme color tokens for the built-in themes — generated from the theme
12
+ * registry at build time (scripts/gen-theme-css.mjs). Declared as CSS rules so
13
+ * `var(--color-*)` resolves on the first paint; the runtime `setProperty` path
14
+ * can't set inheritable custom properties before descendants have painted. */
15
+ @import './themes/builtins.generated.css';
11
16
 
12
17
  /* Base reset */
13
18
  @import './base.css';
@@ -0,0 +1,140 @@
1
+ /* AUTO-GENERATED from src/theme/registry.ts — do not edit.
2
+ * Regenerated by scripts/gen-theme-css.mjs on every build. */
3
+
4
+ .daisy-light {
5
+ --color-primary: #491dff;
6
+ --color-primary-content: #d3dbff;
7
+ --color-secondary: #ff20cc;
8
+ --color-secondary-content: #fff8fc;
9
+ --color-accent: #00cfbd;
10
+ --color-accent-content: #00100d;
11
+ --color-neutral: #2b3440;
12
+ --color-neutral-content: #d7dde4;
13
+ --color-base-100: #ffffff;
14
+ --color-base-200: #f2f2f2;
15
+ --color-base-300: #e5e6e6;
16
+ --color-base-content: #1f2937;
17
+ --color-info: #00b4fa;
18
+ --color-info-content: #000000;
19
+ --color-success: #00a96e;
20
+ --color-success-content: #000000;
21
+ --color-warning: #ffc100;
22
+ --color-warning-content: #000000;
23
+ --color-error: #ff676a;
24
+ --color-error-content: #000000;
25
+ }
26
+
27
+ .daisy-cupcake {
28
+ --color-primary: #65c3c8;
29
+ --color-primary-content: #052124;
30
+ --color-secondary: #ef9fbc;
31
+ --color-secondary-content: #2d0a16;
32
+ --color-accent: #eeaf3a;
33
+ --color-accent-content: #2d1c00;
34
+ --color-neutral: #291334;
35
+ --color-neutral-content: #f5f1f8;
36
+ --color-base-100: #faf7f5;
37
+ --color-base-200: #efeae6;
38
+ --color-base-300: #e7e2df;
39
+ --color-base-content: #291334;
40
+ --color-info: #00b4fa;
41
+ --color-info-content: #000000;
42
+ --color-success: #00a96e;
43
+ --color-success-content: #000000;
44
+ --color-warning: #ffc100;
45
+ --color-warning-content: #000000;
46
+ --color-error: #ff676a;
47
+ --color-error-content: #000000;
48
+ }
49
+
50
+ .daisy-emerald {
51
+ --color-primary: #66cc8a;
52
+ --color-primary-content: #06200f;
53
+ --color-secondary: #377cfb;
54
+ --color-secondary-content: #02112d;
55
+ --color-accent: #f68067;
56
+ --color-accent-content: #2d0a02;
57
+ --color-neutral: #333c4d;
58
+ --color-neutral-content: #e9eaed;
59
+ --color-base-100: #ffffff;
60
+ --color-base-200: #f3f4f6;
61
+ --color-base-300: #e5e7eb;
62
+ --color-base-content: #333c4d;
63
+ --color-info: #1c92f2;
64
+ --color-info-content: #000a14;
65
+ --color-success: #00a96e;
66
+ --color-success-content: #000a05;
67
+ --color-warning: #ff9900;
68
+ --color-warning-content: #261600;
69
+ --color-error: #ff5724;
70
+ --color-error-content: #000000;
71
+ }
72
+
73
+ .daisy-dark {
74
+ --color-primary: #7582ff;
75
+ --color-primary-content: #050617;
76
+ --color-secondary: #ff71cf;
77
+ --color-secondary-content: #190211;
78
+ --color-accent: #00e7d0;
79
+ --color-accent-content: #001210;
80
+ --color-neutral: #2a323c;
81
+ --color-neutral-content: #a6adbb;
82
+ --color-base-100: #1d232a;
83
+ --color-base-200: #191e24;
84
+ --color-base-300: #343b46;
85
+ --color-base-content: #a6adbb;
86
+ --color-info: #00b4fa;
87
+ --color-info-content: #000000;
88
+ --color-success: #00a96e;
89
+ --color-success-content: #000000;
90
+ --color-warning: #ffc100;
91
+ --color-warning-content: #000000;
92
+ --color-error: #ff676a;
93
+ --color-error-content: #000000;
94
+ }
95
+
96
+ .daisy-synthwave {
97
+ --color-primary: #e779c1;
98
+ --color-primary-content: #2a0a1f;
99
+ --color-secondary: #58c7f3;
100
+ --color-secondary-content: #02141d;
101
+ --color-accent: #f3cc30;
102
+ --color-accent-content: #2a1f00;
103
+ --color-neutral: #20134e;
104
+ --color-neutral-content: #e3e0f5;
105
+ --color-base-100: #2d1b69;
106
+ --color-base-200: #261159;
107
+ --color-base-300: #1f0f4a;
108
+ --color-base-content: #f9f7fd;
109
+ --color-info: #53c0f3;
110
+ --color-info-content: #02151e;
111
+ --color-success: #71ead2;
112
+ --color-success-content: #002721;
113
+ --color-warning: #f3cc30;
114
+ --color-warning-content: #2a1f00;
115
+ --color-error: #e24056;
116
+ --color-error-content: #ffffff;
117
+ }
118
+
119
+ .daisy-dracula {
120
+ --color-primary: #ff79c6;
121
+ --color-primary-content: #2d0414;
122
+ --color-secondary: #bd93f9;
123
+ --color-secondary-content: #160226;
124
+ --color-accent: #50fa7b;
125
+ --color-accent-content: #002a0e;
126
+ --color-neutral: #414558;
127
+ --color-neutral-content: #f8f8f2;
128
+ --color-base-100: #282a36;
129
+ --color-base-200: #21222c;
130
+ --color-base-300: #181920;
131
+ --color-base-content: #f8f8f2;
132
+ --color-info: #8be9fd;
133
+ --color-info-content: #002a31;
134
+ --color-success: #50fa7b;
135
+ --color-success-content: #002a0e;
136
+ --color-warning: #f1fa8c;
137
+ --color-warning-content: #2a2900;
138
+ --color-error: #ff5555;
139
+ --color-error-content: #2a0000;
140
+ }
@@ -3,19 +3,13 @@
3
3
  <ThemeProvider class="daisy-rounded"> → host class="daisy daisy-rounded" */
4
4
 
5
5
  .daisy-flat {
6
- --rounded-box: 0px;
7
- --rounded-btn: 0px;
8
- --rounded-badge: 0px;
9
- --rounded-tab: 0px;
10
- --rounded-selector: 0px;
11
- --rounded-toggle: 0px;
6
+ --radius-selector: 0px;
7
+ --radius-field: 0px;
8
+ --radius-box: 0px;
12
9
  }
13
10
 
14
11
  .daisy-rounded {
15
- --rounded-box: 24px;
16
- --rounded-btn: 9999px;
17
- --rounded-badge: 9999px;
18
- --rounded-tab: 9999px;
19
- --rounded-selector: 9999px;
20
- --rounded-toggle: 9999px;
12
+ --radius-selector: 9999px;
13
+ --radius-field: 9999px;
14
+ --radius-box: 24px;
21
15
  }
@@ -8,15 +8,20 @@
8
8
  * may still override roundness via its `radius` field. */
9
9
 
10
10
  .daisy {
11
- /* ── Roundness ── */
12
- --rounded-box: 16px;
13
- --rounded-btn: 8px;
14
- --rounded-badge: 9999px;
15
- --rounded-tab: 8px;
16
- --rounded-selector: 8px;
17
- --rounded-toggle: 9999px;
11
+ /* ── Roundness (DaisyUI v5 contract) ── */
12
+ --radius-selector: 8px;
13
+ --radius-field: 8px;
14
+ --radius-box: 16px;
18
15
 
19
- /* ── Sizing scale ── */
16
+ /* ── Base size units (DaisyUI v5 contract) ──
17
+ * A theme's `sizes` ({ field, selector }) overrides these; <ThemeProvider>
18
+ * re-derives the component dimensions below from them in JS. We deliberately
19
+ * do NOT use `calc(var() * n)` in CSS — it's unproven in Lynx's runtime CSS
20
+ * engine. Defaults below are base 4px × the per-token multiples shown. */
21
+ --size-selector: 4px;
22
+ --size-field: 4px;
23
+
24
+ /* ── Field sizing scale (button / input / select) — field × 6/8/12/16 ── */
20
25
  --size-xs: 24px;
21
26
  --size-sm: 32px;
22
27
  --size-md: 48px;
@@ -41,27 +46,28 @@
41
46
  --border-btn: 1px;
42
47
  --border-card: 1px;
43
48
 
44
- /* ── Component-specific sizes ── */
45
- --checkbox-xs: 16px;
46
- --checkbox-sm: 20px;
47
- --checkbox-md: 24px;
48
- --checkbox-lg: 32px;
49
- --toggle-width-xs: 32px;
50
- --toggle-width-sm: 40px;
51
- --toggle-width-md: 48px;
52
- --toggle-width-lg: 56px;
53
- --toggle-height-xs: 22px;
54
- --toggle-height-sm: 24px;
55
- --toggle-height-md: 28px;
56
- --toggle-height-lg: 32px;
57
- --toggle-thumb-xs: 14px;
58
- --toggle-thumb-sm: 16px;
59
- --toggle-thumb-md: 20px;
60
- --toggle-thumb-lg: 24px;
61
- --badge-xs: 16px;
62
- --badge-sm: 20px;
63
- --badge-md: 24px;
64
- --badge-lg: 32px;
49
+ /* ── Selector-driven sizes (checkbox / toggle / badge) — selector × the
50
+ * multiples shown; <ThemeProvider> re-derives these from `sizes.selector`. ── */
51
+ --checkbox-xs: 16px; /* ×4 */
52
+ --checkbox-sm: 20px; /* ×5 */
53
+ --checkbox-md: 24px; /* ×6 */
54
+ --checkbox-lg: 32px; /* ×8 */
55
+ --toggle-width-xs: 32px; /* ×8 */
56
+ --toggle-width-sm: 40px; /* ×10 */
57
+ --toggle-width-md: 48px; /* ×12 */
58
+ --toggle-width-lg: 56px; /* ×14 */
59
+ --toggle-height-xs: 24px; /* ×6 */
60
+ --toggle-height-sm: 24px; /* ×6 */
61
+ --toggle-height-md: 28px; /* ×7 */
62
+ --toggle-height-lg: 32px; /* ×8 */
63
+ --toggle-thumb-xs: 16px; /* ×4 */
64
+ --toggle-thumb-sm: 16px; /* ×4 */
65
+ --toggle-thumb-md: 20px; /* ×5 */
66
+ --toggle-thumb-lg: 24px; /* ×6 */
67
+ --badge-xs: 16px; /* ×4 */
68
+ --badge-sm: 20px; /* ×5 */
69
+ --badge-md: 24px; /* ×6 */
70
+ --badge-lg: 32px; /* ×8 */
65
71
  --step-indicator: 32px;
66
72
  --progress-height: 8px;
67
73
  --modal-max-width: 400px;
@@ -28,10 +28,13 @@ import { jsx as _jsx } from "@sigx/lynx/jsx-runtime";
28
28
  */
29
29
  import { component, effect, onMounted, onUnmounted } from '@sigx/lynx';
30
30
  import { isAvailable, setSystemBarsStyle } from '@sigx/lynx-appearance';
31
- import { useTheme } from './ThemeProvider.js';
31
+ import { themeController } from './theme-state.js';
32
32
  import { variantOf } from './registry.js';
33
33
  export const StatusBarSync = component(({ props }) => {
34
- const theme = useTheme();
34
+ // Bind to the *global* theme not `useTheme()` — so the OS bars always
35
+ // track the app/screen theme and can't be hijacked by a content sub-scope
36
+ // (a nested `<ThemeProvider>` recolors its subtree but leaves the bars put).
37
+ const theme = themeController;
35
38
  let lastApplied = null;
36
39
  let runner;
37
40
  function apply(name) {
@@ -90,8 +90,12 @@ export interface ThemeController {
90
90
  followSystem(): void;
91
91
  }
92
92
  /**
93
- * Access the enclosing daisyui theme controller. Throws when used
94
- * outside `<ThemeProvider>` install a provider at your app root.
93
+ * Access the active daisyui theme controller. Resolves to the nearest
94
+ * `<ThemeProvider>`'s controller (a content sub-scope), or — at the app root
95
+ * and in *headless* code with no provider mounted — the global controller
96
+ * (`themeController`). Never throws: theme control is reachable from anywhere
97
+ * (issue #113). For control that must always target the app/OS theme
98
+ * regardless of scope (e.g. a status-bar sync), import `themeController`.
95
99
  */
96
100
  export declare const useTheme: import("@sigx/runtime-core").InjectableFunction<ThemeController>;
97
101
  export type ThemeProviderProps =
@@ -134,5 +138,5 @@ Define.Prop<'initial', DaisyTheme, false>
134
138
  export declare const ThemeProvider: import("@sigx/runtime-core").ComponentFactory<ThemeProviderProps, void, {
135
139
  default: () => import("@sigx/runtime-core").JSXElement | import("@sigx/runtime-core").JSXElement[] | null;
136
140
  }>;
137
- export { listThemes, registerTheme, extendTheme, pickThemeFor, pairOf, variantOf, colorsOf, radiusOf, } from './registry.js';
138
- export type { Theme, ThemePalette, ThemeRadius, ThemeVariant } from './registry.js';
141
+ export { listThemes, registerTheme, extendTheme, pickThemeFor, pairOf, variantOf, colorsOf, radiusOf, sizesOf, } from './registry.js';
142
+ export type { Theme, ThemePalette, ThemeRadius, ThemeSizes, ThemeVariant, } from './registry.js';
@@ -35,17 +35,94 @@ import { jsx as _jsx } from "@sigx/lynx/jsx-runtime";
35
35
  * <ThemeProvider light="daisy-cupcake" dark="daisy-synthwave">…</ThemeProvider>
36
36
  * ```
37
37
  */
38
- import { component, defineInjectable, defineProvide, onMounted, onUnmounted, signal, } from '@sigx/lynx';
38
+ import { component, defineInjectable, defineProvide, effect, onMounted, onUnmounted, signal, untrack, } from '@sigx/lynx';
39
39
  import { useIconColorResolver } from '@sigx/lynx-icons';
40
40
  import { useSystemColorScheme } from '@sigx/lynx-appearance';
41
- import { colorsOf, pairOf, pickThemeFor, radiusOf } from './registry.js';
41
+ import { colorsOf, isBuiltInTheme, pickThemeFor, radiusOf, sizesOf, variantOf, } from './registry.js';
42
+ import { globalThemeState, makeThemeController, themeController, } from './theme-state.js';
43
+ // DaisyUI v5 expresses control dimensions as multiples of two base units
44
+ // (`--size-field`, `--size-selector`). Lynx's runtime CSS engine is unproven
45
+ // for `calc(var() * n)`, so when a theme overrides a base unit we do the
46
+ // multiplication here and emit literal px. Bases must be px (engine-safe, like
47
+ // colors); a non-px base sets only the base var and leaves the `.daisy`
48
+ // defaults in place. Multiples mirror the defaults in `styles/themes/tokens.css`.
49
+ const FIELD_STEPS = { xs: 6, sm: 8, md: 12, lg: 16 };
50
+ const SELECTOR_STEPS = {
51
+ 'checkbox-xs': 4, 'checkbox-sm': 5, 'checkbox-md': 6, 'checkbox-lg': 8,
52
+ 'toggle-width-xs': 8, 'toggle-width-sm': 10, 'toggle-width-md': 12, 'toggle-width-lg': 14,
53
+ 'toggle-height-xs': 6, 'toggle-height-sm': 6, 'toggle-height-md': 7, 'toggle-height-lg': 8,
54
+ 'toggle-thumb-xs': 4, 'toggle-thumb-sm': 4, 'toggle-thumb-md': 5, 'toggle-thumb-lg': 6,
55
+ 'badge-xs': 4, 'badge-sm': 5, 'badge-md': 6, 'badge-lg': 8,
56
+ };
57
+ const pxValue = (v) => {
58
+ const m = /^\s*(\d+(?:\.\d+)?)px\s*$/.exec(v);
59
+ return m ? Number(m[1]) : undefined;
60
+ };
61
+ /** Emit a theme's `sizes` overrides as literal-px CSS custom properties. */
62
+ function applySizeVars(style, sizes) {
63
+ if (sizes.field) {
64
+ style['--size-field'] = sizes.field;
65
+ const base = pxValue(sizes.field);
66
+ if (base !== undefined) {
67
+ for (const k in FIELD_STEPS)
68
+ style[`--size-${k}`] = `${base * FIELD_STEPS[k]}px`;
69
+ }
70
+ }
71
+ if (sizes.selector) {
72
+ style['--size-selector'] = sizes.selector;
73
+ const base = pxValue(sizes.selector);
74
+ if (base !== undefined) {
75
+ for (const k in SELECTOR_STEPS)
76
+ style[`--${k}`] = `${base * SELECTOR_STEPS[k]}px`;
77
+ }
78
+ }
79
+ }
42
80
  /**
43
- * Access the enclosing daisyui theme controller. Throws when used
44
- * outside `<ThemeProvider>` install a provider at your app root.
81
+ * The full custom-property set for a theme colors plus any radius/size
82
+ * overrides. Applied at runtime via the Lynx `setProperty` API (see
83
+ * `<ThemeProvider>`), NOT the inline `style` attribute: Lynx does not honor
84
+ * custom properties declared inline in this toolchain, but `setProperty`
85
+ * registers real, inheritable ones — the documented way to theme via CSS
86
+ * variables (https://lynxjs.org/guide/styling/custom-theming).
45
87
  */
46
- export const useTheme = defineInjectable(() => {
47
- throw new Error('[lynx-daisyui] useTheme() called outside <ThemeProvider>. Wrap your app root with `<ThemeProvider>…</ThemeProvider>`.');
48
- });
88
+ function buildThemeVars(name) {
89
+ const palette = colorsOf(name) ?? colorsOf('daisy-light');
90
+ const radius = radiusOf(name);
91
+ const sizes = sizesOf(name);
92
+ const vars = {};
93
+ for (const key in palette)
94
+ vars[`--color-${key}`] = palette[key];
95
+ if (radius) {
96
+ if (radius.selector)
97
+ vars['--radius-selector'] = radius.selector;
98
+ if (radius.field)
99
+ vars['--radius-field'] = radius.field;
100
+ if (radius.box)
101
+ vars['--radius-box'] = radius.box;
102
+ }
103
+ if (sizes)
104
+ applySizeVars(vars, sizes);
105
+ return vars;
106
+ }
107
+ /** Unique host id per provider instance so `getElementById` targets its own subtree. */
108
+ let themeIdSeq = 0;
109
+ /**
110
+ * Access the active daisyui theme controller. Resolves to the nearest
111
+ * `<ThemeProvider>`'s controller (a content sub-scope), or — at the app root
112
+ * and in *headless* code with no provider mounted — the global controller
113
+ * (`themeController`). Never throws: theme control is reachable from anywhere
114
+ * (issue #113). For control that must always target the app/OS theme
115
+ * regardless of scope (e.g. a status-bar sync), import `themeController`.
116
+ */
117
+ export const useTheme = defineInjectable(() => themeController);
118
+ /**
119
+ * Nesting-depth marker. The outermost `<ThemeProvider>` sees depth 0 and binds
120
+ * the global singleton (so headless `themeController` mutations render and the
121
+ * OS bars track it); a nested provider sees >= 1 and creates its own local
122
+ * state — a content sub-scope that recolors its subtree without touching the
123
+ * global theme or the system bars.
124
+ */
125
+ const useThemeDepth = defineInjectable(() => 0);
49
126
  /**
50
127
  * Wraps children in a `<view class={theme}>` so the daisyui CSS variables
51
128
  * defined inside the theme class inherit down to every descendant.
@@ -64,44 +141,45 @@ export const ThemeProvider = component(({ props, slots }) => {
64
141
  // The underlying signal widens to PrimitiveSignal<string> via Widen<T>;
65
142
  // cast at read sites to keep the narrow union throughout the component.
66
143
  const readScheme = () => systemScheme.value;
67
- // Seed: pin to `initial` if set, otherwise follow system.
68
- const initialState = props.initial
69
- ? { name: props.initial, following: false }
70
- : {
71
- name: readScheme() === 'dark'
144
+ // Root vs. nested. The outermost provider (depth 0) binds the global
145
+ // singleton so headless `themeController` mutations render here and the OS
146
+ // bars (via StatusBarSync) follow this theme. A nested provider gets its own
147
+ // local state: a content sub-scope that overrides its subtree only.
148
+ const depth = useThemeDepth();
149
+ const isRoot = depth === 0;
150
+ defineProvide(useThemeDepth, () => depth + 1);
151
+ // Stable id for the host view so the runtime `setProperty` call (below) can
152
+ // target it. Unique per instance so nested providers theme their own subtree.
153
+ const hostId = `daisy-theme-${++themeIdSeq}`;
154
+ const state = isRoot
155
+ ? globalThemeState
156
+ : signal(props.initial
157
+ ? { name: props.initial, following: false }
158
+ : {
159
+ name: readScheme() === 'dark'
160
+ ? (props.dark ?? pickThemeFor('dark'))
161
+ : (props.light ?? pickThemeFor('light')),
162
+ following: true,
163
+ });
164
+ // Seed the root from props/system. An explicit `initial` pin is author
165
+ // intent and wins. With no `initial`, reflect the current system scheme into
166
+ // the first render — but only while `following`, so a theme a headless
167
+ // caller set before this mounted is respected, not clobbered. The follow
168
+ // effect below keeps it in sync afterwards.
169
+ if (isRoot) {
170
+ if (props.initial) {
171
+ state.name = props.initial;
172
+ state.following = false;
173
+ }
174
+ else if (state.following) {
175
+ state.name = readScheme() === 'dark'
72
176
  ? (props.dark ?? pickThemeFor('dark'))
73
- : (props.light ?? pickThemeFor('light')),
74
- following: true,
75
- };
76
- const state = signal(initialState);
77
- // Guard against re-applying the same theme on stray re-fires.
78
- let lastApplied = state.following ? readScheme() : null;
79
- function applySystem(scheme, force = false) {
80
- if (!state.following)
81
- return;
82
- if (!force && lastApplied === scheme)
83
- return;
84
- lastApplied = scheme;
85
- state.name = scheme === 'dark'
86
- ? (props.dark ?? pickThemeFor('dark'))
87
- : (props.light ?? pickThemeFor('light'));
177
+ : (props.light ?? pickThemeFor('light'));
178
+ }
88
179
  }
89
- const controller = {
90
- get name() { return state.name; },
91
- get followingSystem() { return state.following; },
92
- set(next) {
93
- state.name = next;
94
- state.following = false;
95
- },
96
- toggle() {
97
- state.name = pairOf(state.name);
98
- state.following = false;
99
- },
100
- followSystem() {
101
- state.following = true;
102
- applySystem(readScheme(), /* force */ true);
103
- },
104
- };
180
+ const controller = isRoot
181
+ ? themeController
182
+ : makeThemeController(state);
105
183
  defineProvide(useTheme, () => controller);
106
184
  // Wire the daisy color resolver into `@sigx/lynx-icons`'s injectable
107
185
  // so any `<Icon variant="primary">` rendered inside this subtree gets
@@ -118,35 +196,65 @@ export const ThemeProvider = component(({ props, slots }) => {
118
196
  return palette?.[variant];
119
197
  };
120
198
  defineProvide(useIconColorResolver, () => resolver);
121
- // Subscribe to system color-scheme changes. Both PrimitiveSignal and
122
- // Computed expose `.subscribe(fn)` returning an unsubscribe handle —
123
- // we lean on the structural shape so this file doesn't pull
124
- // @sigx/reactivity into its imports.
125
- let unsubscribe;
199
+ // Follow the system color scheme while `following`. Reactive: re-runs when
200
+ // `following` flips true (e.g. `controller.followSystem()`, including the
201
+ // headless `themeController`) or when the OS scheme changes, and writes the
202
+ // matching theme. Reading `state.following` and `systemScheme.value` tracks
203
+ // them; the `name` write is `untrack`ed so it can't re-trigger the effect.
204
+ // Created on mount (the native publisher may populate the scheme between
205
+ // setup and mount) and torn down on unmount.
206
+ let follow;
207
+ let applyVars;
126
208
  onMounted(() => {
127
- // Re-seed once mounted — covers the case where the native publisher
128
- // populated `__globalProps` between setup and mount.
129
- applySystem(readScheme());
130
- const sig = systemScheme;
131
- if (typeof sig.subscribe === 'function') {
132
- unsubscribe = sig.subscribe(() => applySystem(readScheme()));
133
- }
209
+ follow = effect(() => {
210
+ const following = state.following;
211
+ const scheme = readScheme();
212
+ if (!following)
213
+ return;
214
+ const next = scheme === 'dark'
215
+ ? (props.dark ?? pickThemeFor('dark'))
216
+ : (props.light ?? pickThemeFor('light'));
217
+ untrack(() => {
218
+ if (state.name !== next)
219
+ state.name = next;
220
+ });
221
+ });
222
+ // Built-in themes are themed by their generated CSS class (applied on
223
+ // the host below), which resolves on the very first frame. This
224
+ // `setProperty` path additionally serves runtime-registered themes
225
+ // (`registerTheme`, no shipped CSS class) — applied once they're
226
+ // selected post-mount, where it lands reliably. Reading `state.name`
227
+ // (via buildThemeVars) tracks it, so this re-runs on every theme change.
228
+ applyVars = effect(() => {
229
+ const vars = buildThemeVars(state.name);
230
+ if (typeof lynx !== 'undefined') {
231
+ lynx.getElementById(hostId)?.setProperty(vars);
232
+ }
233
+ });
134
234
  });
135
235
  onUnmounted(() => {
136
- unsubscribe?.();
137
- unsubscribe = undefined;
236
+ follow?.stop();
237
+ follow = undefined;
238
+ applyVars?.stop();
239
+ applyVars = undefined;
138
240
  });
139
241
  return () => {
140
- // Every theme is data. Apply its color tokens as inline CSS custom
141
- // properties Lynx inherits custom properties to descendants, so
142
- // component classes resolve `var(--color-*)` against these (the same
143
- // mechanism SafeAreaProvider uses for `--sat`/`--sal`). The `daisy`
144
- // base class supplies theme-agnostic structural tokens (radius,
145
- // sizing); a theme may override roundness via `radius`. The root
146
- // background/text are painted from the palette literals (inline
147
- // `var()` values don't resolve in Lynx).
242
+ // Theme COLORS and any radius/size overrides are applied as real,
243
+ // inheritable CSS custom properties via the Lynx `setProperty` runtime
244
+ // API (see the `applyVars` effect above) Lynx does NOT honor custom
245
+ // properties declared through the inline `style` attribute in this
246
+ // toolchain. The root background/text are painted here from palette
247
+ // literals (real properties, not custom props) so the surface is themed
248
+ // on first paint; descendants resolve `var(--color-*)` once setProperty
249
+ // has run. The `daisy` base class supplies structural token defaults.
148
250
  const palette = colorsOf(state.name) ?? colorsOf('daisy-light');
149
- const radius = radiusOf(state.name);
251
+ // Built-ins ship a generated CSS class, so `state.name` alone paints on
252
+ // the first frame. A runtime-registered theme has no class — fall back
253
+ // to its variant's built-in class for the first frame; the `setProperty`
254
+ // effect above then swaps in its exact palette post-mount.
255
+ const themeClass = isBuiltInTheme(state.name)
256
+ ? state.name
257
+ : `${pickThemeFor(variantOf(state.name) ?? 'light')} ${state.name}`;
150
258
  const style = {
151
259
  flexGrow: 1,
152
260
  flexShrink: 1,
@@ -157,27 +265,10 @@ export const ThemeProvider = component(({ props, slots }) => {
157
265
  backgroundColor: palette['base-100'],
158
266
  color: palette['base-content'],
159
267
  };
160
- for (const key in palette) {
161
- style[`--color-${key}`] = palette[key];
162
- }
163
- if (radius) {
164
- if (radius.box)
165
- style['--rounded-box'] = radius.box;
166
- if (radius.btn)
167
- style['--rounded-btn'] = radius.btn;
168
- if (radius.badge)
169
- style['--rounded-badge'] = radius.badge;
170
- if (radius.tab)
171
- style['--rounded-tab'] = radius.tab;
172
- if (radius.selector)
173
- style['--rounded-selector'] = radius.selector;
174
- if (radius.toggle)
175
- style['--rounded-toggle'] = radius.toggle;
176
- }
177
268
  if (props.style)
178
269
  Object.assign(style, props.style);
179
- return (_jsx("view", { class: `daisy${props.class ? ' ' + props.class : ''}`, style: style, children: slots.default?.() }));
270
+ return (_jsx("view", { id: hostId, class: `daisy ${themeClass}${props.class ? ' ' + props.class : ''}`, style: style, children: slots.default?.() }));
180
271
  };
181
272
  });
182
273
  // Re-export registry helpers so consumers only need `@sigx/lynx-daisyui`.
183
- export { listThemes, registerTheme, extendTheme, pickThemeFor, pairOf, variantOf, colorsOf, radiusOf, } from './registry.js';
274
+ export { listThemes, registerTheme, extendTheme, pickThemeFor, pairOf, variantOf, colorsOf, radiusOf, sizesOf, } from './registry.js';
@@ -17,7 +17,7 @@
17
17
  *
18
18
  * Structural tokens (radius, sizing, component dimensions) are theme-agnostic
19
19
  * and ship once in the bundled `.daisy` base class (`styles/themes/tokens.css`);
20
- * a theme may override roundness via `radius`.
20
+ * a theme may override roundness via `radius` and base size units via `sizes`.
21
21
  *
22
22
  * Colors are engine-safe strings — hex or `rgb()`. Lynx's CSS engine does not
23
23
  * parse `oklch()`, so convert before registering.
@@ -26,14 +26,29 @@ import type { DaisyColor } from '../shared/styles.js';
26
26
  export type ThemeVariant = 'light' | 'dark';
27
27
  /** Full daisy color palette — every semantic token, no holes. */
28
28
  export type ThemePalette = Record<DaisyColor, string>;
29
- /** Roundness token overrides. Defaults live in the bundled `.daisy` base. */
29
+ /**
30
+ * Roundness token overrides (DaisyUI v5 contract). Emitted as
31
+ * `--radius-selector` / `--radius-field` / `--radius-box`. Defaults live in
32
+ * the bundled `.daisy` base.
33
+ */
30
34
  export interface ThemeRadius {
35
+ /** Small selectable controls — checkbox, toggle, badge. */
36
+ selector?: string;
37
+ /** Fields — button, input, select, textarea. */
38
+ field?: string;
39
+ /** Boxes — card, modal, alert. */
31
40
  box?: string;
32
- btn?: string;
33
- badge?: string;
34
- tab?: string;
41
+ }
42
+ /**
43
+ * Base size-unit overrides (DaisyUI v5 contract). Emitted as
44
+ * `--size-selector` / `--size-field`; component dimensions are integer
45
+ * multiples of these. Defaults live in the bundled `.daisy` base.
46
+ */
47
+ export interface ThemeSizes {
48
+ /** Base unit for selector controls (checkbox, toggle, badge). */
35
49
  selector?: string;
36
- toggle?: string;
50
+ /** Base unit for fields (button, input, select). */
51
+ field?: string;
37
52
  }
38
53
  export interface Theme {
39
54
  /** Unique id — also the value of `theme.name`. */
@@ -49,7 +64,16 @@ export interface Theme {
49
64
  pair?: string;
50
65
  /** Optional roundness overrides; unspecified tokens fall back to `.daisy`. */
51
66
  radius?: ThemeRadius;
67
+ /** Optional base size-unit overrides; unspecified tokens fall back to `.daisy`. */
68
+ sizes?: ThemeSizes;
52
69
  }
70
+ /**
71
+ * Whether `name` is a built-in theme that ships a CSS class — i.e. it paints
72
+ * correctly on the first frame. Runtime-registered themes return `false`;
73
+ * `<ThemeProvider>` falls back to their variant's built-in class for first
74
+ * paint and swaps in the exact palette via `setProperty`.
75
+ */
76
+ export declare function isBuiltInTheme(name: string | undefined): boolean;
53
77
  /**
54
78
  * All registered themes in insertion order. Returns a shallow copy so callers
55
79
  * can't mutate the internal registry — re-registration goes through
@@ -80,6 +104,7 @@ export declare function extendTheme(base: string, patch: {
80
104
  pair?: string;
81
105
  colors?: Partial<ThemePalette>;
82
106
  radius?: ThemeRadius;
107
+ sizes?: ThemeSizes;
83
108
  }): Theme;
84
109
  /** The variant of a registered theme, or `undefined` if not registered. */
85
110
  export declare function variantOf(name: string | undefined): ThemeVariant | undefined;
@@ -87,6 +112,8 @@ export declare function variantOf(name: string | undefined): ThemeVariant | unde
87
112
  export declare function colorsOf(name: string | undefined): ThemePalette | undefined;
88
113
  /** The roundness overrides of a registered theme, if any. */
89
114
  export declare function radiusOf(name: string | undefined): ThemeRadius | undefined;
115
+ /** The base size-unit overrides of a registered theme, if any. */
116
+ export declare function sizesOf(name: string | undefined): ThemeSizes | undefined;
90
117
  /**
91
118
  * Pick a default theme for a given system color scheme — the first registered
92
119
  * theme of that variant (`daisy-light` / `daisy-dark` under the seeded
@@ -89,6 +89,23 @@ const registry = [
89
89
  },
90
90
  },
91
91
  ];
92
+ /**
93
+ * Names of the themes seeded at module load — the built-ins that ship a
94
+ * generated CSS class (`scripts/gen-theme-css.mjs`). Captured before any
95
+ * runtime `registerTheme()`, so it distinguishes "has a first-paint CSS class"
96
+ * from runtime-registered themes (which only have their palette as data and
97
+ * apply via `setProperty` post-mount).
98
+ */
99
+ const BUILTIN_NAMES = new Set(registry.map((t) => t.name));
100
+ /**
101
+ * Whether `name` is a built-in theme that ships a CSS class — i.e. it paints
102
+ * correctly on the first frame. Runtime-registered themes return `false`;
103
+ * `<ThemeProvider>` falls back to their variant's built-in class for first
104
+ * paint and swaps in the exact palette via `setProperty`.
105
+ */
106
+ export function isBuiltInTheme(name) {
107
+ return name != null && BUILTIN_NAMES.has(name);
108
+ }
92
109
  /**
93
110
  * Resolve a `theme.name` to its registered `Theme`. Supports multi-class names
94
111
  * like `'daisy-light daisy-rounded'` by matching the first registered id found.
@@ -147,6 +164,7 @@ export function extendTheme(base, patch) {
147
164
  pair: patch.pair ?? src.pair,
148
165
  colors: { ...src.colors, ...patch.colors },
149
166
  radius: patch.radius ?? src.radius,
167
+ sizes: patch.sizes ?? src.sizes,
150
168
  };
151
169
  }
152
170
  /** The variant of a registered theme, or `undefined` if not registered. */
@@ -161,6 +179,10 @@ export function colorsOf(name) {
161
179
  export function radiusOf(name) {
162
180
  return findTheme(name)?.radius;
163
181
  }
182
+ /** The base size-unit overrides of a registered theme, if any. */
183
+ export function sizesOf(name) {
184
+ return findTheme(name)?.sizes;
185
+ }
164
186
  /**
165
187
  * Pick a default theme for a given system color scheme — the first registered
166
188
  * theme of that variant (`daisy-light` / `daisy-dark` under the seeded
@@ -0,0 +1,28 @@
1
+ import type { DaisyTheme, ThemeController } from './ThemeProvider.js';
2
+ /** The mutable selection a `ThemeController` reads from and writes to. */
3
+ export interface ThemeState {
4
+ name: DaisyTheme;
5
+ following: boolean;
6
+ }
7
+ /**
8
+ * Build a `ThemeController` over a given state object. Used for both the global
9
+ * singleton (below) and each nested `<ThemeProvider>`'s local state — same
10
+ * behaviour, different backing store. `followSystem()` only flips the flag; the
11
+ * owning provider's follow effect performs the re-apply.
12
+ */
13
+ export declare function makeThemeController(state: ThemeState): ThemeController;
14
+ /**
15
+ * The backing signal for the global theme. Read/written by the root
16
+ * `<ThemeProvider>` and shared with `themeController`; not part of the public
17
+ * API.
18
+ * @internal
19
+ */
20
+ export declare const globalThemeState: import("@sigx/reactivity").Signal<ThemeState>;
21
+ /**
22
+ * The global theme controller — the headless handle for issue #113. Import and
23
+ * call from anywhere (no `<ThemeProvider>` ancestor required); `useTheme()`'s
24
+ * default factory returns this same instance, and the root `<ThemeProvider>`
25
+ * provides it to its subtree. `StatusBarSync` binds to it so the OS bars always
26
+ * follow the global/screen theme, never a content sub-scope.
27
+ */
28
+ export declare const themeController: ThemeController;
@@ -0,0 +1,72 @@
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
+ /**
24
+ * Build a `ThemeController` over a given state object. Used for both the global
25
+ * singleton (below) and each nested `<ThemeProvider>`'s local state — same
26
+ * behaviour, different backing store. `followSystem()` only flips the flag; the
27
+ * owning provider's follow effect performs the re-apply.
28
+ */
29
+ export function makeThemeController(state) {
30
+ return {
31
+ get name() {
32
+ return state.name;
33
+ },
34
+ get followingSystem() {
35
+ return state.following;
36
+ },
37
+ set(next) {
38
+ state.name = next;
39
+ state.following = false;
40
+ },
41
+ toggle() {
42
+ state.name = pairOf(state.name);
43
+ state.following = false;
44
+ },
45
+ followSystem() {
46
+ state.following = true;
47
+ },
48
+ };
49
+ }
50
+ // Object signal (not primitive) so the `DaisyTheme` literal union survives —
51
+ // `signal<T>` widens primitive literals to plain `string` via `Widen<T>`.
52
+ // Seeded to a sane default; the root <ThemeProvider> re-seeds from the system
53
+ // color scheme + its props on mount.
54
+ const state = signal({
55
+ name: pickThemeFor('light'),
56
+ following: true,
57
+ });
58
+ /**
59
+ * The backing signal for the global theme. Read/written by the root
60
+ * `<ThemeProvider>` and shared with `themeController`; not part of the public
61
+ * API.
62
+ * @internal
63
+ */
64
+ export const globalThemeState = state;
65
+ /**
66
+ * The global theme controller — the headless handle for issue #113. Import and
67
+ * call from anywhere (no `<ThemeProvider>` ancestor required); `useTheme()`'s
68
+ * default factory returns this same instance, and the root `<ThemeProvider>`
69
+ * provides it to its subtree. `StatusBarSync` binds to it so the OS bars always
70
+ * follow the global/screen theme, never a content sub-scope.
71
+ */
72
+ export const themeController = makeThemeController(state);
@@ -0,0 +1,3 @@
1
+ import type { DaisyTheme } from './ThemeProvider.js';
2
+ /** Pin the global theme to `name` while this screen is focused; restore on blur. */
3
+ export declare function useScreenTheme(name: DaisyTheme): void;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `useScreenTheme(name)` — pin the **global** daisy 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
+ /** Pin the global theme to `name` while this screen is focused; restore on blur. */
30
+ export function useScreenTheme(name) {
31
+ useFocusEffect(() => {
32
+ const prevName = themeController.name;
33
+ const prevFollowing = themeController.followingSystem;
34
+ themeController.set(name);
35
+ return () => {
36
+ if (prevFollowing)
37
+ themeController.followSystem();
38
+ else
39
+ themeController.set(prevName);
40
+ };
41
+ });
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigx/lynx-daisyui",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "DaisyUI integration for sigx-lynx",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -37,15 +37,15 @@
37
37
  "LICENSE"
38
38
  ],
39
39
  "dependencies": {
40
- "@sigx/lynx": "^0.4.2",
41
- "@sigx/lynx-appearance": "^0.4.2",
42
- "@sigx/lynx-icons": "^0.4.2",
43
- "@sigx/lynx-motion": "^0.4.2",
44
- "@sigx/lynx-gestures": "^0.4.2"
40
+ "@sigx/lynx": "^0.4.4",
41
+ "@sigx/lynx-appearance": "^0.4.4",
42
+ "@sigx/lynx-gestures": "^0.4.4",
43
+ "@sigx/lynx-icons": "^0.4.4",
44
+ "@sigx/lynx-motion": "^0.4.4"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "tailwindcss": "^3.0.0 || ^4.0.0",
48
- "@sigx/lynx-navigation": "^0.4.2"
48
+ "@sigx/lynx-navigation": "^0.4.4"
49
49
  },
50
50
  "peerDependenciesMeta": {
51
51
  "@sigx/lynx-navigation": {
@@ -56,7 +56,7 @@
56
56
  "@typescript/native-preview": "7.0.0-dev.20260521.1",
57
57
  "tailwindcss": "^4.0.0",
58
58
  "typescript": "^6.0.3",
59
- "@sigx/lynx-navigation": "^0.4.2"
59
+ "@sigx/lynx-navigation": "^0.4.4"
60
60
  },
61
61
  "publishConfig": {
62
62
  "access": "public"
@@ -72,7 +72,7 @@
72
72
  "author": "Andreas Ekdahl",
73
73
  "license": "MIT",
74
74
  "scripts": {
75
- "build": "node ../../scripts/clean.mjs dist && tsgo && node ../../scripts/copy-assets.mjs src/styles dist/styles",
75
+ "build": "node ../../scripts/clean.mjs dist && tsgo && node ../../scripts/copy-assets.mjs src/styles dist/styles && node scripts/gen-theme-css.mjs",
76
76
  "dev": "tsgo --watch",
77
77
  "clean": "node ../../scripts/clean.mjs dist .turbo"
78
78
  }