@pyreon/ui-core 0.24.2 → 0.24.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/lib/index.d.ts CHANGED
@@ -234,15 +234,41 @@ declare const isEqual: (a: unknown, b: unknown) => boolean;
234
234
  type ThemeMode = 'light' | 'dark';
235
235
  type ThemeModeInput = ThemeMode | 'system';
236
236
  interface PyreonUIProps {
237
- /** Theme object with breakpoints, rootSize, and custom keys. */
238
- theme: PyreonTheme;
237
+ /**
238
+ * Theme object with breakpoints, rootSize, and custom keys.
239
+ *
240
+ * Optional — when omitted, the theme is INHERITED from the nearest
241
+ * ancestor PyreonUI. This makes nested `<PyreonUI inversed>` work
242
+ * without re-passing the theme:
243
+ *
244
+ * ```tsx
245
+ * <PyreonUI theme={appTheme}>
246
+ * <Header />
247
+ * <PyreonUI inversed> // inherits appTheme, just flips mode
248
+ * <DarkSidebar />
249
+ * </PyreonUI>
250
+ * </PyreonUI>
251
+ * ```
252
+ *
253
+ * At the OUTERMOST PyreonUI with no ancestor, the provided ThemeContext
254
+ * value falls back to the default `{}` (styled components see fields as
255
+ * undefined; no crash). Pass a real theme at the outermost PyreonUI to
256
+ * avoid that.
257
+ */
258
+ theme?: PyreonTheme | undefined;
239
259
  /**
240
260
  * Color mode: "light", "dark", or "system" (follows OS preference).
241
261
  * Can be a signal or getter for reactive mode switching.
242
- * @default "light"
262
+ *
263
+ * When omitted, mode is INHERITED from the nearest ancestor PyreonUI
264
+ * (or `'light'` at the root).
243
265
  */
244
266
  mode?: ThemeModeInput | (() => ThemeModeInput) | undefined;
245
- /** Flip mode for a nested section (e.g. dark sidebar in light app). */
267
+ /**
268
+ * Flip mode for a nested section (e.g. dark sidebar in light app).
269
+ * Scoped — only affects DESCENDANTS of this PyreonUI; ancestors and
270
+ * siblings see the original mode unchanged.
271
+ */
246
272
  inversed?: boolean | undefined;
247
273
  children?: VNodeChild;
248
274
  }
package/lib/index.js CHANGED
@@ -347,6 +347,7 @@ function autoInit() {
347
347
  function PyreonUI(props) {
348
348
  autoInit();
349
349
  const parentModeAccessor = useContext(ModeContext);
350
+ const parentThemeAccessor = useContext(ThemeContext);
350
351
  const resolveMode = () => {
351
352
  const mode = props.mode;
352
353
  let resolved;
@@ -358,7 +359,11 @@ function PyreonUI(props) {
358
359
  return props.inversed ? INVERSED[resolved] : resolved;
359
360
  };
360
361
  const modeComputed = computed(resolveMode);
361
- const enrichedTheme = computed(() => enrichTheme(props.theme));
362
+ const enrichedTheme = computed(() => {
363
+ const t = props.theme;
364
+ if (t === void 0 || t === null) return parentThemeAccessor();
365
+ return enrichTheme(t);
366
+ });
362
367
  provide(ThemeContext, () => enrichedTheme());
363
368
  provide(context, () => ({
364
369
  theme: enrichedTheme(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/ui-core",
3
- "version": "0.24.2",
3
+ "version": "0.24.4",
4
4
  "description": "Core utilities, config, and context for Pyreon UI System",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,16 +38,16 @@
38
38
  },
39
39
  "devDependencies": {
40
40
  "@pyreon/manifest": "0.13.1",
41
- "@pyreon/typescript": "^0.24.2",
41
+ "@pyreon/typescript": "^0.24.4",
42
42
  "@vitus-labs/tools-rolldown": "^2.4.0"
43
43
  },
44
44
  "engines": {
45
45
  "node": ">= 22"
46
46
  },
47
47
  "dependencies": {
48
- "@pyreon/core": "^0.24.2",
49
- "@pyreon/reactivity": "^0.24.2",
50
- "@pyreon/styler": "^0.24.2",
51
- "@pyreon/unistyle": "^0.24.2"
48
+ "@pyreon/core": "^0.24.4",
49
+ "@pyreon/reactivity": "^0.24.4",
50
+ "@pyreon/styler": "^0.24.4",
51
+ "@pyreon/unistyle": "^0.24.4"
52
52
  }
53
53
  }
package/src/PyreonUI.tsx CHANGED
@@ -12,15 +12,41 @@ export type ThemeMode = 'light' | 'dark'
12
12
  export type ThemeModeInput = ThemeMode | 'system'
13
13
 
14
14
  export interface PyreonUIProps {
15
- /** Theme object with breakpoints, rootSize, and custom keys. */
16
- theme: PyreonTheme
15
+ /**
16
+ * Theme object with breakpoints, rootSize, and custom keys.
17
+ *
18
+ * Optional — when omitted, the theme is INHERITED from the nearest
19
+ * ancestor PyreonUI. This makes nested `<PyreonUI inversed>` work
20
+ * without re-passing the theme:
21
+ *
22
+ * ```tsx
23
+ * <PyreonUI theme={appTheme}>
24
+ * <Header />
25
+ * <PyreonUI inversed> // inherits appTheme, just flips mode
26
+ * <DarkSidebar />
27
+ * </PyreonUI>
28
+ * </PyreonUI>
29
+ * ```
30
+ *
31
+ * At the OUTERMOST PyreonUI with no ancestor, the provided ThemeContext
32
+ * value falls back to the default `{}` (styled components see fields as
33
+ * undefined; no crash). Pass a real theme at the outermost PyreonUI to
34
+ * avoid that.
35
+ */
36
+ theme?: PyreonTheme | undefined
17
37
  /**
18
38
  * Color mode: "light", "dark", or "system" (follows OS preference).
19
39
  * Can be a signal or getter for reactive mode switching.
20
- * @default "light"
40
+ *
41
+ * When omitted, mode is INHERITED from the nearest ancestor PyreonUI
42
+ * (or `'light'` at the root).
21
43
  */
22
44
  mode?: ThemeModeInput | (() => ThemeModeInput) | undefined
23
- /** Flip mode for a nested section (e.g. dark sidebar in light app). */
45
+ /**
46
+ * Flip mode for a nested section (e.g. dark sidebar in light app).
47
+ * Scoped — only affects DESCENDANTS of this PyreonUI; ancestors and
48
+ * siblings see the original mode unchanged.
49
+ */
24
50
  inversed?: boolean | undefined
25
51
  children?: VNodeChild
26
52
  }
@@ -128,6 +154,14 @@ export function PyreonUI(props: PyreonUIProps): VNodeChild {
128
154
  // returns 'light'. This read is TRACKED inside the computed below, so when
129
155
  // the parent's mode changes, this child's computed re-evaluates.
130
156
  const parentModeAccessor = useContext(ModeContext)
157
+ // Same shape for theme — useContext(ThemeContext) is a reactive accessor.
158
+ // When `props.theme` is omitted, we provide the parent's theme through
159
+ // verbatim (already enriched at the level it was provided). Re-enriching
160
+ // would be idempotent but wasteful, AND would replace the parent's exact
161
+ // `__PYREON__` reference, which downstream identity-keyed caches (styler's
162
+ // class cache, rocketstyle's per-definition WeakMaps) rely on for hits.
163
+ // Pass-through preserves identity.
164
+ const parentThemeAccessor = useContext(ThemeContext)
131
165
  const resolveMode = (): ThemeMode => {
132
166
  const mode = props.mode
133
167
  let resolved: ThemeMode
@@ -146,7 +180,22 @@ export function PyreonUI(props: PyreonUIProps): VNodeChild {
146
180
 
147
181
  // Enrich theme — wrapped in computed so user-preference theme swaps
148
182
  // propagate. The enrichment itself is cheap (builds a __PYREON__ block).
149
- const enrichedTheme = computed(() => enrichTheme(props.theme))
183
+ //
184
+ // When `props.theme` is omitted, return the parent's theme verbatim
185
+ // (already enriched). Without this, `enrichTheme(undefined)` would
186
+ // destructure undefined and throw — but the throw happens LAZILY (the
187
+ // computed is only read when a child consumes ThemeContext), so the
188
+ // failure mode is the cryptic dev-mode warning
189
+ // `[pyreon] Unhandled effect error: TypeError: Cannot destructure property
190
+ // 'breakpoints' of 'theme' as it is undefined`
191
+ // followed by every styled descendant rendering with an empty theme.
192
+ // That's what made the user's nested `<PyreonUI inversed>` "look like
193
+ // inversed wasn't working" — the whole subtree was broken.
194
+ const enrichedTheme = computed(() => {
195
+ const t = props.theme
196
+ if (t === undefined || t === null) return parentThemeAccessor()
197
+ return enrichTheme(t)
198
+ })
150
199
 
151
200
  // Provide to all three context layers:
152
201
 
@@ -0,0 +1,157 @@
1
+ // Verifies the user's three reported issues are FIXED:
2
+ //
3
+ // 1. `inversed` works on a nested PyreonUI when theme is inherited.
4
+ // 2. `theme` is optional — nested `<PyreonUI inversed>` (no theme prop)
5
+ // no longer crashes the ThemeContext getter.
6
+ // 3. Nested PyreonUI inherits theme from the parent's ThemeContext
7
+ // out of the box.
8
+ //
9
+ // PLUS the user's explicit scoping invariant: an INNER inversed PyreonUI
10
+ // must NOT leak its flipped mode back to the outer subtree. This holds
11
+ // trivially via Pyreon's provide() scoping (each `provide()` push lives
12
+ // only inside the providing component's subtree), but worth locking in.
13
+ import { describe, expect, it, vi } from 'vitest'
14
+ import { PyreonUI } from '../PyreonUI'
15
+
16
+ // Spy on provide()
17
+ const provideSpy = vi.spyOn(await import('@pyreon/core'), 'provide')
18
+
19
+ describe('PyreonUI — theme/mode inheritance + scope invariants', () => {
20
+ const theme = {
21
+ rootSize: 16,
22
+ breakpoints: { xs: 0, sm: 576, md: 768 },
23
+ colors: { primary: '#228be6' },
24
+ }
25
+
26
+ beforeEach(() => {
27
+ provideSpy.mockClear()
28
+ })
29
+
30
+ it('ISSUE 2 FIXED: <PyreonUI inversed> with no theme prop returns a non-throwing ThemeContext getter', () => {
31
+ // The previous broken shape: computed wraps enrichTheme(undefined),
32
+ // which throws lazily when a child reads ThemeContext. With the fix,
33
+ // omitted theme inherits from the parent ThemeContext (default `{}`
34
+ // at the root).
35
+ PyreonUI({ inversed: true, children: null })
36
+ const themeGetter = provideSpy.mock.calls[0]![1] as () => unknown
37
+ expect(() => themeGetter()).not.toThrow()
38
+ // At root with no parent theme, falls back to the ReactiveContext
39
+ // default `{}` — no crash, just an empty theme.
40
+ expect(themeGetter()).toEqual({})
41
+ })
42
+
43
+ it('ISSUE 3 FIXED: nested PyreonUI inherits theme from parent ThemeContext identity', () => {
44
+ // Drive the outer + inner manually. The inner runs inside the outer's
45
+ // setup frame — useContext(ThemeContext) inside the inner reads the
46
+ // outer's provided getter, returning the outer's enriched theme.
47
+ PyreonUI({ theme, children: null }) // outer
48
+ const outerThemeGetter = provideSpy.mock.calls[0]![1] as () => unknown
49
+ const outerEnriched = outerThemeGetter()
50
+
51
+ provideSpy.mockClear()
52
+ PyreonUI({ inversed: true, children: null }) // inner — no theme prop
53
+ const innerThemeGetter = provideSpy.mock.calls[0]![1] as () => unknown
54
+ const innerTheme = innerThemeGetter()
55
+
56
+ // The inner provides the PARENT'S theme by REFERENCE — not a fresh
57
+ // enrichTheme() call. Same `__PYREON__` block identity → downstream
58
+ // identity-keyed caches (styler classCache, rocketstyle WeakMaps) hit.
59
+ expect(innerTheme).toBe(outerEnriched)
60
+ })
61
+
62
+ it('ISSUE 1 FIXED: inversed flips the mode when theme is inherited from parent', () => {
63
+ // The user's reported shape:
64
+ // <PyreonUI theme={theme}> ← provides mode='light'
65
+ // <PyreonUI inversed> ← no theme, only `inversed`
66
+ // <DarkSidebar /> ← reads mode → should be 'dark'
67
+ // </PyreonUI>
68
+ // </PyreonUI>
69
+ //
70
+ // Before the fix: enrichTheme(undefined) crashed in the inner's
71
+ // ThemeContext getter and the whole inner subtree fell back to outer's
72
+ // mode (the inversion was effectively invisible because every styled
73
+ // descendant rendered with the wrong theme).
74
+ PyreonUI({ theme, mode: 'light', children: null }) // outer
75
+ provideSpy.mockClear()
76
+ PyreonUI({ inversed: true, children: null }) // inner — no theme
77
+ const innerModeGetter = provideSpy.mock.calls[2]![1] as () => unknown
78
+ expect(innerModeGetter()).toBe('dark')
79
+ })
80
+
81
+ it('SCOPE INVARIANT: inner inversed does NOT leak to outer', () => {
82
+ // The outer PyreonUI provided ModeContext BEFORE the inner one ran.
83
+ // Inner's provide() pushed a new frame onto the context stack — that
84
+ // frame is identity-removed on inner unmount, leaving the outer's
85
+ // frame intact. We assert on the outer's mode getter staying 'light'
86
+ // after the inner has also been provided.
87
+ PyreonUI({ theme, mode: 'light', children: null }) // outer
88
+ const outerModeGetter = provideSpy.mock.calls[2]![1] as () => unknown
89
+ provideSpy.mockClear()
90
+ PyreonUI({ inversed: true, children: null }) // inner
91
+
92
+ // Outer's mode getter is still the same closure and still returns
93
+ // 'light' — it was never touched by the inner's provide().
94
+ expect(outerModeGetter()).toBe('light')
95
+ })
96
+
97
+ it('SCOPE INVARIANT: outer theme is unaffected by inner inheritance', () => {
98
+ PyreonUI({ theme, children: null }) // outer
99
+ const outerThemeGetter = provideSpy.mock.calls[0]![1] as () => unknown
100
+ const outerThemeBefore = outerThemeGetter()
101
+ provideSpy.mockClear()
102
+ PyreonUI({ inversed: true, children: null }) // inner
103
+
104
+ // Outer's theme getter still returns the same enriched theme.
105
+ expect(outerThemeGetter()).toBe(outerThemeBefore)
106
+ })
107
+
108
+ // ─── Breakpoints optional — "one-size" app ───────────────────────────────────
109
+ // User requirement: breakpoints should not be required. An app that
110
+ // passes them gets responsive behavior; an app that omits them renders
111
+ // at a single size. The type already permits this (`breakpoints?:` in
112
+ // `PyreonTheme`) and unistyle's `makeItResponsive` already short-circuits
113
+ // when breakpoints are empty:
114
+ //
115
+ // // packages/ui-system/unistyle/src/responsive/makeItResponsive.ts:123
116
+ // if (isEmpty(breakpoints) || isEmpty(__PYREON__)) {
117
+ // return css`${renderStyles(internalTheme)}`
118
+ // }
119
+ //
120
+ // These two specs lock in that the WHOLE chain (PyreonUI →
121
+ // enrichTheme → ThemeContext consumer) works with a theme that has no
122
+ // `breakpoints` AND no `rootSize`. A regression in `enrichTheme` to a
123
+ // strict-required shape would surface here.
124
+
125
+ it('BREAKPOINTS OPTIONAL: theme with no breakpoints renders an enriched theme with empty media', () => {
126
+ // Minimal theme — just colors. No breakpoints, no rootSize. This is
127
+ // the "one-size app" shape.
128
+ const minimal = { colors: { primary: '#228be6' } }
129
+ PyreonUI({ theme: minimal, children: null })
130
+ const themeGetter = provideSpy.mock.calls[0]![1] as () => unknown
131
+ const enriched = themeGetter() as {
132
+ colors: unknown
133
+ __PYREON__: { sortedBreakpoints: unknown; media: unknown }
134
+ }
135
+
136
+ expect(enriched.colors).toEqual({ primary: '#228be6' })
137
+ // __PYREON__ exists but both fields are undefined — downstream
138
+ // `makeItResponsive` detects empty + falls back to plain CSS.
139
+ expect(enriched.__PYREON__).toBeDefined()
140
+ expect(enriched.__PYREON__.sortedBreakpoints).toBeUndefined()
141
+ expect(enriched.__PYREON__.media).toBeUndefined()
142
+ })
143
+
144
+ it('BREAKPOINTS OPTIONAL: nested PyreonUI inherits a no-breakpoints theme unchanged', () => {
145
+ const minimal = { colors: { primary: '#228be6' } }
146
+ PyreonUI({ theme: minimal, children: null }) // outer
147
+ const outerThemeGetter = provideSpy.mock.calls[0]![1] as () => unknown
148
+ const outerEnriched = outerThemeGetter()
149
+
150
+ provideSpy.mockClear()
151
+ PyreonUI({ inversed: true, children: null }) // inner — no theme prop
152
+ const innerThemeGetter = provideSpy.mock.calls[0]![1] as () => unknown
153
+
154
+ // Identity-preserved: same enriched theme reference, no re-enrichment.
155
+ expect(innerThemeGetter()).toBe(outerEnriched)
156
+ })
157
+ })