@pyreon/ui-core 0.24.4 → 0.24.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/ui-core",
3
- "version": "0.24.4",
3
+ "version": "0.24.6",
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.24.4",
39
+ "@pyreon/typescript": "^0.24.6",
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.24.4",
49
- "@pyreon/reactivity": "^0.24.4",
50
- "@pyreon/styler": "^0.24.4",
51
- "@pyreon/unistyle": "^0.24.4"
46
+ "@pyreon/core": "^0.24.6",
47
+ "@pyreon/reactivity": "^0.24.6",
48
+ "@pyreon/styler": "^0.24.6",
49
+ "@pyreon/unistyle": "^0.24.6"
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
- })