@pyreon/ui-core 0.24.5 → 0.25.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/lib/index.js +5 -1
- package/package.json +7 -9
- package/src/PyreonUI.tsx +0 -227
- package/src/__tests__/PyreonUI-inheritance.test.tsx +0 -157
- package/src/__tests__/PyreonUI.test.tsx +0 -194
- package/src/__tests__/compose.test.ts +0 -32
- package/src/__tests__/config.test.ts +0 -102
- package/src/__tests__/context.test.tsx +0 -71
- package/src/__tests__/hoistNonReactStatics.test.tsx +0 -166
- package/src/__tests__/isEmpty.test.ts +0 -53
- package/src/__tests__/isEqual.test.ts +0 -114
- package/src/__tests__/manifest-snapshot.test.ts +0 -27
- package/src/__tests__/native-markers.test.ts +0 -13
- package/src/__tests__/render.test.tsx +0 -72
- package/src/__tests__/useStableValue.test.ts +0 -118
- package/src/__tests__/utils.test.ts +0 -537
- package/src/compose.ts +0 -11
- package/src/config.ts +0 -57
- package/src/context.tsx +0 -80
- package/src/hoistNonReactStatics.ts +0 -59
- package/src/html/htmlElementAttrs.ts +0 -106
- package/src/html/htmlTags.ts +0 -151
- package/src/html/index.ts +0 -11
- package/src/index.ts +0 -57
- package/src/isEmpty.ts +0 -20
- package/src/isEqual.ts +0 -27
- package/src/manifest.ts +0 -104
- package/src/render.tsx +0 -50
- package/src/types.ts +0 -5
- package/src/useStableValue.ts +0 -21
- package/src/utils.ts +0 -187
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { computed, registerSingleton, signal } from "@pyreon/reactivity";
|
|
1
2
|
import { ThemeContext, css, keyframes, styled } from "@pyreon/styler";
|
|
2
3
|
import { createReactiveContext, h, nativeCompat, provide, useContext } from "@pyreon/core";
|
|
3
|
-
import { computed, signal } from "@pyreon/reactivity";
|
|
4
4
|
import { enrichTheme } from "@pyreon/unistyle";
|
|
5
5
|
|
|
6
6
|
//#region src/compose.ts
|
|
@@ -538,6 +538,10 @@ const merge = (target, ...sources) => {
|
|
|
538
538
|
return target;
|
|
539
539
|
};
|
|
540
540
|
|
|
541
|
+
//#endregion
|
|
542
|
+
//#region src/index.ts
|
|
543
|
+
registerSingleton("@pyreon/ui-core", "0.24.6", import.meta.url);
|
|
544
|
+
|
|
541
545
|
//#endregion
|
|
542
546
|
export { HTML_TAGS, HTML_TEXT_TAGS, Provider, PyreonUI, compose, config, context, get, hoistNonReactStatics, init, isEmpty, isEqual, merge, omit, pick, render, set, throttle, useMode, useStableValue };
|
|
543
547
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/ui-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Core utilities, config, and context for Pyreon UI System",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -13,8 +13,7 @@
|
|
|
13
13
|
"!lib/**/*.map",
|
|
14
14
|
"!lib/analysis",
|
|
15
15
|
"README.md",
|
|
16
|
-
"LICENSE"
|
|
17
|
-
"src"
|
|
16
|
+
"LICENSE"
|
|
18
17
|
],
|
|
19
18
|
"type": "module",
|
|
20
19
|
"sideEffects": false,
|
|
@@ -22,7 +21,6 @@
|
|
|
22
21
|
"types": "./lib/index.d.ts",
|
|
23
22
|
"exports": {
|
|
24
23
|
".": {
|
|
25
|
-
"bun": "./src/index.ts",
|
|
26
24
|
"import": "./lib/index.js",
|
|
27
25
|
"types": "./lib/index.d.ts"
|
|
28
26
|
}
|
|
@@ -38,16 +36,16 @@
|
|
|
38
36
|
},
|
|
39
37
|
"devDependencies": {
|
|
40
38
|
"@pyreon/manifest": "0.13.1",
|
|
41
|
-
"@pyreon/typescript": "^0.
|
|
39
|
+
"@pyreon/typescript": "^0.25.0",
|
|
42
40
|
"@vitus-labs/tools-rolldown": "^2.4.0"
|
|
43
41
|
},
|
|
44
42
|
"engines": {
|
|
45
43
|
"node": ">= 22"
|
|
46
44
|
},
|
|
47
45
|
"dependencies": {
|
|
48
|
-
"@pyreon/core": "^0.
|
|
49
|
-
"@pyreon/reactivity": "^0.
|
|
50
|
-
"@pyreon/styler": "^0.
|
|
51
|
-
"@pyreon/unistyle": "^0.
|
|
46
|
+
"@pyreon/core": "^0.25.0",
|
|
47
|
+
"@pyreon/reactivity": "^0.25.0",
|
|
48
|
+
"@pyreon/styler": "^0.25.0",
|
|
49
|
+
"@pyreon/unistyle": "^0.25.0"
|
|
52
50
|
}
|
|
53
51
|
}
|
package/src/PyreonUI.tsx
DELETED
|
@@ -1,227 +0,0 @@
|
|
|
1
|
-
import type { VNodeChild } from '@pyreon/core'
|
|
2
|
-
import { createReactiveContext, nativeCompat, provide, useContext } from '@pyreon/core'
|
|
3
|
-
import { computed, signal } from '@pyreon/reactivity'
|
|
4
|
-
import { ThemeContext } from '@pyreon/styler'
|
|
5
|
-
import type { PyreonTheme } from '@pyreon/unistyle'
|
|
6
|
-
import { enrichTheme } from '@pyreon/unistyle'
|
|
7
|
-
import { context as coreContext } from './context'
|
|
8
|
-
|
|
9
|
-
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
10
|
-
|
|
11
|
-
export type ThemeMode = 'light' | 'dark'
|
|
12
|
-
export type ThemeModeInput = ThemeMode | 'system'
|
|
13
|
-
|
|
14
|
-
export interface PyreonUIProps {
|
|
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
|
|
37
|
-
/**
|
|
38
|
-
* Color mode: "light", "dark", or "system" (follows OS preference).
|
|
39
|
-
* Can be a signal or getter for reactive mode switching.
|
|
40
|
-
*
|
|
41
|
-
* When omitted, mode is INHERITED from the nearest ancestor PyreonUI
|
|
42
|
-
* (or `'light'` at the root).
|
|
43
|
-
*/
|
|
44
|
-
mode?: ThemeModeInput | (() => ThemeModeInput) | undefined
|
|
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
|
-
*/
|
|
50
|
-
inversed?: boolean | undefined
|
|
51
|
-
children?: VNodeChild
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// ─── System mode detection ──────────────────────────────────────────────────
|
|
55
|
-
|
|
56
|
-
const _isBrowser = typeof window !== 'undefined' && typeof matchMedia === 'function'
|
|
57
|
-
|
|
58
|
-
/** Reactive signal tracking the OS dark mode preference. Lazy-initialized on first use. */
|
|
59
|
-
let _systemMode: ReturnType<typeof signal<ThemeMode>> | undefined
|
|
60
|
-
|
|
61
|
-
function getSystemMode(): ReturnType<typeof signal<ThemeMode>> {
|
|
62
|
-
if (_systemMode) return _systemMode
|
|
63
|
-
|
|
64
|
-
// Ternary (not `&&`) so the typeof-derived `_isBrowser` guard is
|
|
65
|
-
// statically verifiable as protecting the `matchMedia` access — same
|
|
66
|
-
// runtime value (`false` on the server), SSR-safe + analyzer-clear.
|
|
67
|
-
const prefersDark = _isBrowser
|
|
68
|
-
? matchMedia('(prefers-color-scheme: dark)').matches
|
|
69
|
-
: false
|
|
70
|
-
_systemMode = signal<ThemeMode>(prefersDark ? 'dark' : 'light')
|
|
71
|
-
|
|
72
|
-
if (_isBrowser) {
|
|
73
|
-
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
|
74
|
-
_systemMode?.set(e.matches ? 'dark' : 'light')
|
|
75
|
-
})
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
return _systemMode
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// ─── Mode context ───────────────────────────────────────────────────────────
|
|
82
|
-
|
|
83
|
-
/** Reactive context — useContext(ModeContext) returns () => ThemeMode. */
|
|
84
|
-
const ModeContext = createReactiveContext<ThemeMode>('light')
|
|
85
|
-
|
|
86
|
-
const INVERSED: Record<ThemeMode, ThemeMode> = { light: 'dark', dark: 'light' }
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Read the resolved color mode ("light" | "dark") from the nearest PyreonUI.
|
|
90
|
-
* Reactive — updates when the mode prop changes or when OS preference changes
|
|
91
|
-
* (if mode="system").
|
|
92
|
-
*
|
|
93
|
-
* @example
|
|
94
|
-
* const mode = useMode() // "light" | "dark"
|
|
95
|
-
*/
|
|
96
|
-
export function useMode(): ThemeMode {
|
|
97
|
-
return useContext(ModeContext)()
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// ─── Auto-init ──────────────────────────────────────────────────────────────
|
|
101
|
-
|
|
102
|
-
let _autoInitDone = false
|
|
103
|
-
|
|
104
|
-
function autoInit(): void {
|
|
105
|
-
if (_autoInitDone) return
|
|
106
|
-
_autoInitDone = true
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// ─── PyreonUI ───────────────────────────────────────────────────────────────
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Unified provider for the Pyreon UI system.
|
|
113
|
-
*
|
|
114
|
-
* Replaces the need for separate UnistyleProvider, RocketstyleProvider,
|
|
115
|
-
* and ThemeProvider — one component, zero init.
|
|
116
|
-
*
|
|
117
|
-
* Mode can be a static string OR a signal/getter for reactive switching:
|
|
118
|
-
* ```tsx
|
|
119
|
-
* // Static
|
|
120
|
-
* <PyreonUI theme={theme} mode="dark">
|
|
121
|
-
*
|
|
122
|
-
* // Reactive signal
|
|
123
|
-
* const mode = signal<ThemeModeInput>("light")
|
|
124
|
-
* <PyreonUI theme={theme} mode={mode}>
|
|
125
|
-
*
|
|
126
|
-
* // System (follows OS preference)
|
|
127
|
-
* <PyreonUI theme={theme} mode="system">
|
|
128
|
-
* ```
|
|
129
|
-
*/
|
|
130
|
-
export function PyreonUI(props: PyreonUIProps): VNodeChild {
|
|
131
|
-
autoInit()
|
|
132
|
-
|
|
133
|
-
// IMPORTANT: do NOT destructure props. Components run once in Pyreon, and
|
|
134
|
-
// destructuring captures values at setup time — losing reactivity. Reading
|
|
135
|
-
// `props.mode` / `props.inversed` lazily inside `resolveMode()` lets the
|
|
136
|
-
// computed re-evaluate when the underlying reactive props change (parent
|
|
137
|
-
// re-renders with a different value, or signal-driven mode toggling).
|
|
138
|
-
//
|
|
139
|
-
// Previously this destructured `{ theme, mode = 'light', inversed, children }`
|
|
140
|
-
// which made `inversed` permanently static — toggling inversed in a parent
|
|
141
|
-
// had no effect because the local boolean was captured once. See
|
|
142
|
-
// `.claude/rules/anti-patterns.md` "Destructuring props" entry.
|
|
143
|
-
|
|
144
|
-
// Create a reactive mode getter that resolves "system" and applies inversion.
|
|
145
|
-
// This getter is provided via context — consumers read it lazily in their
|
|
146
|
-
// own reactive scopes, so mode changes propagate automatically.
|
|
147
|
-
//
|
|
148
|
-
// When `inversed` is set without an explicit `mode`, inherit the parent's
|
|
149
|
-
// mode and flip it. This makes nested `<PyreonUI inversed>` work reactively:
|
|
150
|
-
// outer light → inner dark, outer dark → inner light.
|
|
151
|
-
//
|
|
152
|
-
// useContext(ModeContext) returns the reactive accessor from the nearest
|
|
153
|
-
// parent PyreonUI. At root level (no parent), the ReactiveContext default
|
|
154
|
-
// returns 'light'. This read is TRACKED inside the computed below, so when
|
|
155
|
-
// the parent's mode changes, this child's computed re-evaluates.
|
|
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)
|
|
165
|
-
const resolveMode = (): ThemeMode => {
|
|
166
|
-
const mode = props.mode
|
|
167
|
-
let resolved: ThemeMode
|
|
168
|
-
if (mode === undefined || mode === null) {
|
|
169
|
-
// No explicit mode — inherit from parent context
|
|
170
|
-
resolved = parentModeAccessor()
|
|
171
|
-
} else {
|
|
172
|
-
const raw = typeof mode === 'function' ? mode() : mode
|
|
173
|
-
resolved = raw === 'system' ? getSystemMode()() : raw
|
|
174
|
-
}
|
|
175
|
-
return props.inversed ? INVERSED[resolved] : resolved
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Wrap in computed for memoization
|
|
179
|
-
const modeComputed = computed(resolveMode)
|
|
180
|
-
|
|
181
|
-
// Enrich theme — wrapped in computed so user-preference theme swaps
|
|
182
|
-
// propagate. The enrichment itself is cheap (builds a __PYREON__ block).
|
|
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
|
-
})
|
|
199
|
-
|
|
200
|
-
// Provide to all three context layers:
|
|
201
|
-
|
|
202
|
-
// 1. Styler ThemeContext — reactive accessor. DynamicStyled reads this
|
|
203
|
-
// inside its computed() to re-resolve CSS on theme swap.
|
|
204
|
-
provide(ThemeContext, () => enrichedTheme())
|
|
205
|
-
|
|
206
|
-
// 2. Core context — provide a reactive getter function.
|
|
207
|
-
// coreContext is a ReactiveContext, so provide(() => value).
|
|
208
|
-
// Rocketstyle reads mode/isDark/isLight by calling the getter.
|
|
209
|
-
provide(coreContext, () => ({
|
|
210
|
-
theme: enrichedTheme(),
|
|
211
|
-
mode: modeComputed(),
|
|
212
|
-
isDark: modeComputed() === 'dark',
|
|
213
|
-
isLight: modeComputed() === 'light',
|
|
214
|
-
}))
|
|
215
|
-
|
|
216
|
-
// 3. Mode context — getter function for useMode()
|
|
217
|
-
provide(ModeContext, () => modeComputed())
|
|
218
|
-
|
|
219
|
-
return props.children ?? null
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// Mark as native — compat-mode jsx() runtimes skip wrapCompatComponent so
|
|
223
|
-
// PyreonUI's three provide() calls + useContext(ModeContext) read run inside
|
|
224
|
-
// Pyreon's setup frame. Critical for compat-mode apps that wrap their tree
|
|
225
|
-
// with <PyreonUI> at the top level — without the marker, theme/mode never
|
|
226
|
-
// propagate to descendants.
|
|
227
|
-
nativeCompat(PyreonUI)
|
|
@@ -1,157 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
import { h } from '@pyreon/core'
|
|
2
|
-
import { signal } from '@pyreon/reactivity'
|
|
3
|
-
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
-
import { PyreonUI, type PyreonUIProps } from '../PyreonUI'
|
|
5
|
-
|
|
6
|
-
// Spy on provide to verify context provision
|
|
7
|
-
const provideSpy = vi.spyOn(await import('@pyreon/core'), 'provide')
|
|
8
|
-
|
|
9
|
-
/** Get the value argument (2nd arg) from a provide() call by index. */
|
|
10
|
-
const getProvideValue = (callIndex: number): any => provideSpy.mock.calls[callIndex]![1]
|
|
11
|
-
|
|
12
|
-
describe('PyreonUI', () => {
|
|
13
|
-
const theme = {
|
|
14
|
-
rootSize: 16,
|
|
15
|
-
breakpoints: { xs: 0, sm: 576, md: 768 },
|
|
16
|
-
colors: { primary: '#228be6' },
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
beforeEach(() => {
|
|
20
|
-
provideSpy.mockClear()
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
it('renders children', () => {
|
|
24
|
-
const child = h('div', null, 'hello')
|
|
25
|
-
const result = PyreonUI({ theme, children: child })
|
|
26
|
-
expect(result).toBe(child)
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
it('returns null when no children', () => {
|
|
30
|
-
const result = PyreonUI({ theme })
|
|
31
|
-
expect(result).toBeNull()
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
it('calls provide three times (ThemeContext, core context, mode context)', () => {
|
|
35
|
-
PyreonUI({ theme, children: null })
|
|
36
|
-
expect(provideSpy).toHaveBeenCalledTimes(3)
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
it('defaults mode to light', () => {
|
|
40
|
-
PyreonUI({ theme, children: null })
|
|
41
|
-
|
|
42
|
-
// Core context (2nd call) — ReactiveContext getter function
|
|
43
|
-
const coreCtxGetter = getProvideValue(1)
|
|
44
|
-
expect(typeof coreCtxGetter).toBe('function')
|
|
45
|
-
const coreCtx = coreCtxGetter()
|
|
46
|
-
expect(coreCtx.mode).toBe('light')
|
|
47
|
-
expect(coreCtx.isLight).toBe(true)
|
|
48
|
-
expect(coreCtx.isDark).toBe(false)
|
|
49
|
-
|
|
50
|
-
// Mode context (3rd call) — getter function
|
|
51
|
-
const modeGetter = getProvideValue(2)
|
|
52
|
-
expect(typeof modeGetter).toBe('function')
|
|
53
|
-
expect(modeGetter()).toBe('light')
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
it('provides dark mode', () => {
|
|
57
|
-
PyreonUI({ theme, mode: 'dark', children: null })
|
|
58
|
-
|
|
59
|
-
const coreCtxGetter = getProvideValue(1)
|
|
60
|
-
const coreCtx = coreCtxGetter()
|
|
61
|
-
expect(coreCtx.mode).toBe('dark')
|
|
62
|
-
expect(coreCtx.isDark).toBe(true)
|
|
63
|
-
expect(coreCtx.isLight).toBe(false)
|
|
64
|
-
|
|
65
|
-
const modeGetter = getProvideValue(2)
|
|
66
|
-
expect(modeGetter()).toBe('dark')
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
it('inverts mode when inversed=true', () => {
|
|
70
|
-
PyreonUI({ theme, mode: 'light', inversed: true, children: null })
|
|
71
|
-
expect(getProvideValue(2)()).toBe('dark')
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
it('inverts dark to light', () => {
|
|
75
|
-
PyreonUI({ theme, mode: 'dark', inversed: true, children: null })
|
|
76
|
-
expect(getProvideValue(2)()).toBe('light')
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
it('enriches theme with __PYREON__ before providing', () => {
|
|
80
|
-
PyreonUI({ theme, children: null })
|
|
81
|
-
|
|
82
|
-
// ThemeContext is reactive — the provided value is an accessor.
|
|
83
|
-
const providedThemeGetter = getProvideValue(0)
|
|
84
|
-
const providedTheme = providedThemeGetter()
|
|
85
|
-
expect(providedTheme.__PYREON__).toBeDefined()
|
|
86
|
-
expect(providedTheme.__PYREON__.sortedBreakpoints).toEqual(['xs', 'sm', 'md'])
|
|
87
|
-
expect(providedTheme.colors).toEqual({ primary: '#228be6' })
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
it('works with system mode (resolves to light in happy-dom)', () => {
|
|
91
|
-
PyreonUI({ theme, mode: 'system', children: null })
|
|
92
|
-
expect(getProvideValue(2)()).toBe('light')
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
it('mode context is a getter function (reactive-ready)', () => {
|
|
96
|
-
PyreonUI({ theme, mode: 'dark', children: null })
|
|
97
|
-
const modeGetter = getProvideValue(2)
|
|
98
|
-
// Mode context is a function, not a static value — consumers call it
|
|
99
|
-
// inside their own reactive scopes for reactive mode switching.
|
|
100
|
-
expect(typeof modeGetter).toBe('function')
|
|
101
|
-
expect(modeGetter()).toBe('dark')
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
// ─── Reactivity regression tests ──────────────────────────────────────────
|
|
105
|
-
// Components run ONCE in Pyreon. Destructuring props at setup captures
|
|
106
|
-
// values statically, breaking reactivity. PyreonUI used to destructure
|
|
107
|
-
// `{ theme, mode, inversed, children }` which made `inversed` permanently
|
|
108
|
-
// static — toggling it in a parent had no effect.
|
|
109
|
-
//
|
|
110
|
-
// The fix: read `props.X` lazily inside `resolveMode()`. With reactive
|
|
111
|
-
// props (signal-backed via the compiler's _rp() wrapping, or signal
|
|
112
|
-
// reads inside a getter prop), the computed correctly tracks the
|
|
113
|
-
// dependencies and re-evaluates on change.
|
|
114
|
-
//
|
|
115
|
-
// These tests use real signals to simulate the compiler-emitted reactive
|
|
116
|
-
// prop pattern: `<PyreonUI inversed={isInversed()}>` becomes
|
|
117
|
-
// `_rp(() => isInversed())` which is converted to a getter by
|
|
118
|
-
// makeReactiveProps. The getter reads the signal each time, registering
|
|
119
|
-
// it as a dependency of any reactive scope (like our `computed`).
|
|
120
|
-
|
|
121
|
-
it('inversed mode reacts when backed by a signal (regression for destructuring bug)', () => {
|
|
122
|
-
const inversed = signal(false)
|
|
123
|
-
// Simulate makeReactiveProps output: define `inversed` as a getter
|
|
124
|
-
// that reads the signal. This matches what the compiler emits for
|
|
125
|
-
// reactive props in real usage.
|
|
126
|
-
const props = {} as PyreonUIProps
|
|
127
|
-
Object.assign(props, { theme, mode: 'light' as const, children: null })
|
|
128
|
-
Object.defineProperty(props, 'inversed', {
|
|
129
|
-
get: () => inversed(),
|
|
130
|
-
enumerable: true,
|
|
131
|
-
configurable: true,
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
PyreonUI(props)
|
|
135
|
-
const modeGetter = getProvideValue(2)
|
|
136
|
-
|
|
137
|
-
// Initial: inversed=false, mode=light → resolved=light
|
|
138
|
-
expect(modeGetter()).toBe('light')
|
|
139
|
-
|
|
140
|
-
// Toggle inversed via the signal — mimics a parent's signal change
|
|
141
|
-
// that would, in real usage, drive a re-render's reactive prop.
|
|
142
|
-
inversed.set(true)
|
|
143
|
-
|
|
144
|
-
// The mode getter MUST see the new value. If destructured (the old
|
|
145
|
-
// bug), the local `inversed` boolean was captured at setup and this
|
|
146
|
-
// would still return 'light'.
|
|
147
|
-
expect(modeGetter()).toBe('dark')
|
|
148
|
-
|
|
149
|
-
// And back
|
|
150
|
-
inversed.set(false)
|
|
151
|
-
expect(modeGetter()).toBe('light')
|
|
152
|
-
})
|
|
153
|
-
|
|
154
|
-
it('mode reacts when backed by a signal getter (no destructuring)', () => {
|
|
155
|
-
// The function form (mode={() => signal()}) is the documented way
|
|
156
|
-
// to make mode reactive. The destructuring bug never broke this
|
|
157
|
-
// form because `typeof mode === 'function'` correctly called it
|
|
158
|
-
// each time. But this test guards against future regressions.
|
|
159
|
-
const mode = signal<'light' | 'dark'>('light')
|
|
160
|
-
PyreonUI({ theme, mode: () => mode(), children: null })
|
|
161
|
-
const modeGetter = getProvideValue(2)
|
|
162
|
-
|
|
163
|
-
expect(modeGetter()).toBe('light')
|
|
164
|
-
|
|
165
|
-
mode.set('dark')
|
|
166
|
-
|
|
167
|
-
expect(modeGetter()).toBe('dark')
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
it('inversed false → true → false toggles correctly through the full cycle', () => {
|
|
171
|
-
// Full cycle: verifies the inverted-mode dependency chain
|
|
172
|
-
// (mode + inversed both feeding into resolveMode) reacts correctly
|
|
173
|
-
// to multiple toggles of the signal.
|
|
174
|
-
const inversed = signal(false)
|
|
175
|
-
const props = {} as PyreonUIProps
|
|
176
|
-
Object.assign(props, { theme, mode: 'dark' as const, children: null })
|
|
177
|
-
Object.defineProperty(props, 'inversed', {
|
|
178
|
-
get: () => inversed(),
|
|
179
|
-
enumerable: true,
|
|
180
|
-
configurable: true,
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
PyreonUI(props)
|
|
184
|
-
const modeGetter = getProvideValue(2)
|
|
185
|
-
|
|
186
|
-
expect(modeGetter()).toBe('dark') // dark + not inversed → dark
|
|
187
|
-
|
|
188
|
-
inversed.set(true)
|
|
189
|
-
expect(modeGetter()).toBe('light') // dark + inversed → light
|
|
190
|
-
|
|
191
|
-
inversed.set(false)
|
|
192
|
-
expect(modeGetter()).toBe('dark') // dark + not inversed → dark again
|
|
193
|
-
})
|
|
194
|
-
})
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import compose from '../compose'
|
|
3
|
-
|
|
4
|
-
describe('compose', () => {
|
|
5
|
-
it('should compose two functions right-to-left', () => {
|
|
6
|
-
const double = (x: number) => x * 2
|
|
7
|
-
const addOne = (x: number) => x + 1
|
|
8
|
-
const composed = compose(addOne, double)
|
|
9
|
-
// double(3) = 6, then addOne(6) = 7
|
|
10
|
-
expect(composed(3)).toBe(7)
|
|
11
|
-
})
|
|
12
|
-
|
|
13
|
-
it('should compose three functions right-to-left', () => {
|
|
14
|
-
const add = (x: number) => x + 1
|
|
15
|
-
const mul = (x: number) => x * 3
|
|
16
|
-
const sub = (x: number) => x - 2
|
|
17
|
-
// sub(5) = 3, mul(3) = 9, add(9) = 10
|
|
18
|
-
expect(compose(add, mul, sub)(5)).toBe(10)
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
it('should work with a single function', () => {
|
|
22
|
-
const identity = (x: number) => x
|
|
23
|
-
expect(compose(identity)(42)).toBe(42)
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
it('should pass value through string transforms', () => {
|
|
27
|
-
const upper = (s: string) => s.toUpperCase()
|
|
28
|
-
const exclaim = (s: string) => `${s}!`
|
|
29
|
-
// exclaim('hello') = 'hello!', upper('hello!') = 'HELLO!'
|
|
30
|
-
expect(compose(upper, exclaim)('hello')).toBe('HELLO!')
|
|
31
|
-
})
|
|
32
|
-
})
|