prev-cli 0.24.20 → 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.
Files changed (150) hide show
  1. package/dist/cli.js +2006 -1714
  2. package/dist/previews/components/cart-item/index.d.ts +5 -0
  3. package/dist/previews/components/price-tag/index.d.ts +6 -0
  4. package/dist/previews/screens/cart/empty.d.ts +1 -0
  5. package/dist/previews/screens/cart/index.d.ts +1 -0
  6. package/dist/previews/screens/payment/error.d.ts +1 -0
  7. package/dist/previews/screens/payment/index.d.ts +1 -0
  8. package/dist/previews/screens/payment/processing.d.ts +1 -0
  9. package/dist/previews/screens/receipt/index.d.ts +1 -0
  10. package/dist/previews/shared/data.d.ts +30 -0
  11. package/dist/src/content/config-parser.d.ts +30 -0
  12. package/dist/src/content/flow-verifier.d.ts +21 -0
  13. package/dist/src/content/preview-types.d.ts +288 -0
  14. package/dist/{vite → src/content}/previews.d.ts +3 -11
  15. package/dist/{preview-runtime → src/preview-runtime}/build-optimized.d.ts +2 -0
  16. package/dist/{preview-runtime → src/preview-runtime}/build.d.ts +1 -1
  17. package/dist/src/preview-runtime/region-bridge.d.ts +1 -0
  18. package/dist/{preview-runtime → src/preview-runtime}/types.d.ts +18 -0
  19. package/dist/src/preview-runtime/vendors.d.ts +11 -0
  20. package/dist/{renderers → src/renderers}/index.d.ts +1 -1
  21. package/dist/{renderers → src/renderers}/types.d.ts +3 -31
  22. package/dist/src/server/build.d.ts +6 -0
  23. package/dist/src/server/dev.d.ts +13 -0
  24. package/dist/src/server/plugins/aliases.d.ts +5 -0
  25. package/dist/src/server/plugins/mdx.d.ts +5 -0
  26. package/dist/src/server/plugins/virtual-modules.d.ts +8 -0
  27. package/dist/src/server/preview.d.ts +10 -0
  28. package/dist/src/server/routes/component-bundle.d.ts +1 -0
  29. package/dist/src/server/routes/jsx-bundle.d.ts +3 -0
  30. package/dist/src/server/routes/og-image.d.ts +15 -0
  31. package/dist/src/server/routes/preview-bundle.d.ts +1 -0
  32. package/dist/src/server/routes/preview-config.d.ts +1 -0
  33. package/dist/src/server/routes/tokens.d.ts +1 -0
  34. package/dist/{vite → src/server}/start.d.ts +5 -2
  35. package/dist/{ui → src/ui}/button.d.ts +1 -1
  36. package/dist/{validators → src/validators}/index.d.ts +0 -5
  37. package/dist/{validators → src/validators}/semantic-validator.d.ts +2 -3
  38. package/package.json +8 -11
  39. package/src/jsx/CLAUDE.md +18 -0
  40. package/src/jsx/jsx-runtime.ts +1 -1
  41. package/src/preview-runtime/CLAUDE.md +21 -0
  42. package/src/preview-runtime/build-optimized.ts +189 -73
  43. package/src/preview-runtime/build.ts +75 -79
  44. package/src/preview-runtime/fast-template.html +5 -1
  45. package/src/preview-runtime/region-bridge.test.ts +41 -0
  46. package/src/preview-runtime/region-bridge.ts +101 -0
  47. package/src/preview-runtime/types.ts +6 -0
  48. package/src/preview-runtime/vendors.ts +215 -22
  49. package/src/primitives/CLAUDE.md +17 -0
  50. package/src/theme/CLAUDE.md +20 -0
  51. package/src/theme/Preview.tsx +10 -4
  52. package/src/theme/Toolbar.tsx +2 -2
  53. package/src/theme/entry.tsx +247 -121
  54. package/src/theme/hooks/useAnnotations.ts +77 -0
  55. package/src/theme/hooks/useApprovalStatus.ts +50 -0
  56. package/src/theme/hooks/useSnapshots.ts +147 -0
  57. package/src/theme/hooks/useStorage.ts +26 -0
  58. package/src/theme/hooks/useTokenOverrides.ts +56 -0
  59. package/src/theme/hooks/useViewport.ts +23 -0
  60. package/src/theme/icons.tsx +39 -1
  61. package/src/theme/index.html +18 -0
  62. package/src/theme/mdx-components.tsx +1 -1
  63. package/src/theme/previews/AnnotationLayer.tsx +285 -0
  64. package/src/theme/previews/AnnotationPin.tsx +61 -0
  65. package/src/theme/previews/AnnotationThread.tsx +257 -0
  66. package/src/theme/previews/CLAUDE.md +18 -0
  67. package/src/theme/previews/ComponentPreview.tsx +487 -107
  68. package/src/theme/previews/FlowDiagram.tsx +111 -0
  69. package/src/theme/previews/FlowPreview.tsx +938 -174
  70. package/src/theme/previews/PreviewRouter.tsx +1 -4
  71. package/src/theme/previews/ScreenPreview.tsx +515 -175
  72. package/src/theme/previews/SnapshotButton.tsx +68 -0
  73. package/src/theme/previews/SnapshotCompare.tsx +216 -0
  74. package/src/theme/previews/SnapshotPanel.tsx +274 -0
  75. package/src/theme/previews/StatusBadge.tsx +66 -0
  76. package/src/theme/previews/StatusDropdown.tsx +158 -0
  77. package/src/theme/previews/TokenPlayground.tsx +438 -0
  78. package/src/theme/previews/ViewportControls.tsx +67 -0
  79. package/src/theme/previews/flow-diagram.test.ts +141 -0
  80. package/src/theme/previews/flow-diagram.ts +109 -0
  81. package/src/theme/previews/flow-navigation.test.ts +90 -0
  82. package/src/theme/previews/flow-navigation.ts +47 -0
  83. package/src/theme/previews/machines/derived.test.ts +225 -0
  84. package/src/theme/previews/machines/derived.ts +73 -0
  85. package/src/theme/previews/machines/flow-machine.test.ts +379 -0
  86. package/src/theme/previews/machines/flow-machine.ts +207 -0
  87. package/src/theme/previews/machines/screen-machine.test.ts +149 -0
  88. package/src/theme/previews/machines/screen-machine.ts +76 -0
  89. package/src/theme/previews/stores/flow-store.test.ts +157 -0
  90. package/src/theme/previews/stores/flow-store.ts +49 -0
  91. package/src/theme/previews/stores/screen-store.test.ts +68 -0
  92. package/src/theme/previews/stores/screen-store.ts +33 -0
  93. package/src/theme/storage.test.ts +97 -0
  94. package/src/theme/storage.ts +71 -0
  95. package/src/theme/styles.css +296 -25
  96. package/src/theme/types.ts +64 -0
  97. package/src/tokens/CLAUDE.md +16 -0
  98. package/src/tokens/resolver.ts +1 -1
  99. package/dist/preview-runtime/vendors.d.ts +0 -6
  100. package/dist/vite/config-parser.d.ts +0 -13
  101. package/dist/vite/config.d.ts +0 -12
  102. package/dist/vite/plugins/config-plugin.d.ts +0 -3
  103. package/dist/vite/plugins/debug-plugin.d.ts +0 -3
  104. package/dist/vite/plugins/entry-plugin.d.ts +0 -2
  105. package/dist/vite/plugins/fumadocs-plugin.d.ts +0 -9
  106. package/dist/vite/plugins/pages-plugin.d.ts +0 -5
  107. package/dist/vite/plugins/previews-plugin.d.ts +0 -2
  108. package/dist/vite/plugins/tokens-plugin.d.ts +0 -2
  109. package/dist/vite/preview-types.d.ts +0 -70
  110. package/src/theme/previews/AtlasPreview.tsx +0 -528
  111. package/dist/{cli.d.ts → src/cli.d.ts} +0 -0
  112. package/dist/{config → src/config}/index.d.ts +0 -0
  113. package/dist/{config → src/config}/loader.d.ts +0 -0
  114. package/dist/{config → src/config}/schema.d.ts +0 -0
  115. package/dist/{vite → src/content}/pages.d.ts +0 -0
  116. package/dist/{jsx → src/jsx}/adapters/html.d.ts +0 -0
  117. package/dist/{jsx → src/jsx}/adapters/react.d.ts +0 -0
  118. package/dist/{jsx → src/jsx}/define-component.d.ts +0 -0
  119. package/dist/{jsx → src/jsx}/index.d.ts +0 -0
  120. package/dist/{jsx → src/jsx}/jsx-runtime.d.ts +0 -0
  121. package/dist/{jsx → src/jsx}/migrate.d.ts +0 -0
  122. package/dist/{jsx → src/jsx}/schemas/index.d.ts +0 -0
  123. package/dist/{jsx → src/jsx}/schemas/primitives.d.ts +10 -10
  124. package/dist/{jsx → src/jsx}/schemas/tokens.d.ts +3 -3
  125. /package/dist/{jsx → src/jsx}/validation.d.ts +0 -0
  126. /package/dist/{jsx → src/jsx}/vnode.d.ts +0 -0
  127. /package/dist/{migrate.d.ts → src/migrate.d.ts} +0 -0
  128. /package/dist/{preview-runtime → src/preview-runtime}/tailwind.d.ts +0 -0
  129. /package/dist/{primitives → src/primitives}/index.d.ts +0 -0
  130. /package/dist/{primitives → src/primitives}/migrate.d.ts +0 -0
  131. /package/dist/{primitives → src/primitives}/parser.d.ts +0 -0
  132. /package/dist/{primitives → src/primitives}/template-parser.d.ts +0 -0
  133. /package/dist/{primitives → src/primitives}/template-renderer.d.ts +0 -0
  134. /package/dist/{primitives → src/primitives}/types.d.ts +0 -0
  135. /package/dist/{renderers → src/renderers}/html/index.d.ts +0 -0
  136. /package/dist/{renderers → src/renderers}/react/index.d.ts +0 -0
  137. /package/dist/{renderers → src/renderers}/registry.d.ts +0 -0
  138. /package/dist/{renderers → src/renderers}/render.d.ts +0 -0
  139. /package/dist/{tokens → src/tokens}/defaults.d.ts +0 -0
  140. /package/dist/{tokens → src/tokens}/resolver.d.ts +0 -0
  141. /package/dist/{tokens → src/tokens}/utils.d.ts +0 -0
  142. /package/dist/{tokens → src/tokens}/validation.d.ts +0 -0
  143. /package/dist/{typecheck → src/typecheck}/index.d.ts +0 -0
  144. /package/dist/{ui → src/ui}/card.d.ts +0 -0
  145. /package/dist/{ui → src/ui}/index.d.ts +0 -0
  146. /package/dist/{ui → src/ui}/utils.d.ts +0 -0
  147. /package/dist/{utils → src/utils}/cache.d.ts +0 -0
  148. /package/dist/{utils → src/utils}/debug.d.ts +0 -0
  149. /package/dist/{utils → src/utils}/port.d.ts +0 -0
  150. /package/dist/{validators → src/validators}/schema-validator.d.ts +0 -0
@@ -0,0 +1,149 @@
1
+ import { test, expect, describe } from 'bun:test'
2
+ import {
3
+ screenReducer,
4
+ createScreenInitialState,
5
+ type ScreenMachineState,
6
+ type ScreenAction,
7
+ } from './screen-machine'
8
+
9
+ // --- Test fixtures ---
10
+
11
+ function readyState(overrides?: Partial<ScreenMachineState>): ScreenMachineState {
12
+ return {
13
+ ...createScreenInitialState(['index', 'error', 'loading']),
14
+ status: 'ready',
15
+ ...overrides,
16
+ }
17
+ }
18
+
19
+ // --- Tests ---
20
+
21
+ describe('createScreenInitialState', () => {
22
+ test('returns loading state with provided states', () => {
23
+ const state = createScreenInitialState(['index', 'error', 'loading'])
24
+ expect(state.status).toBe('loading')
25
+ expect(state.activeState).toBe('index')
26
+ expect(state.availableStates).toEqual(['index', 'error', 'loading'])
27
+ expect(state.viewport).toBe('desktop')
28
+ expect(state.isFullscreen).toBe(false)
29
+ expect(state.error).toBeUndefined()
30
+ })
31
+
32
+ test('defaults to index-only when no states provided', () => {
33
+ const state = createScreenInitialState([])
34
+ expect(state.activeState).toBe('index')
35
+ expect(state.availableStates).toEqual([])
36
+ })
37
+
38
+ test('accepts initial state override', () => {
39
+ const state = createScreenInitialState(['index', 'error'], 'error')
40
+ expect(state.activeState).toBe('error')
41
+ })
42
+ })
43
+
44
+ describe('screenReducer — set_state', () => {
45
+ test('switches to a valid state', () => {
46
+ const state = readyState()
47
+ const next = screenReducer(state, { type: 'set_state', state: 'error' })
48
+ expect(next.activeState).toBe('error')
49
+ })
50
+
51
+ test('ignores switch to non-existent state', () => {
52
+ const state = readyState()
53
+ const same = screenReducer(state, { type: 'set_state', state: 'nonexistent' })
54
+ expect(same.activeState).toBe('index')
55
+ })
56
+ })
57
+
58
+ describe('screenReducer — set_viewport', () => {
59
+ test('switches viewport to mobile', () => {
60
+ const state = readyState()
61
+ const next = screenReducer(state, { type: 'set_viewport', viewport: 'mobile' })
62
+ expect(next.viewport).toBe('mobile')
63
+ })
64
+
65
+ test('switches viewport to tablet', () => {
66
+ const state = readyState()
67
+ const next = screenReducer(state, { type: 'set_viewport', viewport: 'tablet' })
68
+ expect(next.viewport).toBe('tablet')
69
+ })
70
+
71
+ test('switches viewport to desktop', () => {
72
+ const state = readyState({ viewport: 'mobile' })
73
+ const next = screenReducer(state, { type: 'set_viewport', viewport: 'desktop' })
74
+ expect(next.viewport).toBe('desktop')
75
+ })
76
+ })
77
+
78
+ describe('screenReducer — toggle_fullscreen', () => {
79
+ test('toggles fullscreen on', () => {
80
+ const state = readyState()
81
+ const next = screenReducer(state, { type: 'toggle_fullscreen' })
82
+ expect(next.isFullscreen).toBe(true)
83
+ })
84
+
85
+ test('toggles fullscreen off', () => {
86
+ const state = readyState({ isFullscreen: true })
87
+ const next = screenReducer(state, { type: 'toggle_fullscreen' })
88
+ expect(next.isFullscreen).toBe(false)
89
+ })
90
+ })
91
+
92
+ describe('screenReducer — build status FSM', () => {
93
+ test('loading -> building via build_started', () => {
94
+ const state = createScreenInitialState(['index'])
95
+ expect(state.status).toBe('loading')
96
+
97
+ const next = screenReducer(state, { type: 'build_started' })
98
+ expect(next.status).toBe('building')
99
+ })
100
+
101
+ test('building -> ready via build_ready', () => {
102
+ const state = readyState({ status: 'building' })
103
+ const next = screenReducer(state, { type: 'build_ready' })
104
+ expect(next.status).toBe('ready')
105
+ expect(next.error).toBeUndefined()
106
+ })
107
+
108
+ test('build_ready clears previous error', () => {
109
+ const state = readyState({ status: 'building', error: 'previous error' })
110
+ const next = screenReducer(state, { type: 'build_ready' })
111
+ expect(next.error).toBeUndefined()
112
+ })
113
+
114
+ test('building -> error via build_error', () => {
115
+ const state = readyState({ status: 'building' })
116
+ const next = screenReducer(state, { type: 'build_error', error: 'Syntax error in component' })
117
+ expect(next.status).toBe('error')
118
+ expect(next.error).toBe('Syntax error in component')
119
+ })
120
+
121
+ test('error -> building via build_started (retry)', () => {
122
+ const state = readyState({ status: 'error', error: 'previous' })
123
+ const next = screenReducer(state, { type: 'build_started' })
124
+ expect(next.status).toBe('building')
125
+ })
126
+ })
127
+
128
+ // --- Codex review fixes ---
129
+
130
+ describe('createScreenInitialState — invalid initial state clamping', () => {
131
+ test('clamps to index when initialState not in availableStates', () => {
132
+ const state = createScreenInitialState(['index', 'error'], 'nonexistent')
133
+ expect(state.activeState).toBe('index')
134
+ })
135
+
136
+ test('clamps to first available when initialState invalid and no index', () => {
137
+ const state = createScreenInitialState(['error', 'loading'], 'nonexistent')
138
+ expect(state.activeState).toBe('error')
139
+ })
140
+ })
141
+
142
+ describe('screenReducer — immutability', () => {
143
+ test('does not mutate previous state', () => {
144
+ const state = readyState()
145
+ const original = state.activeState
146
+ screenReducer(state, { type: 'set_state', state: 'error' })
147
+ expect(state.activeState).toBe(original)
148
+ })
149
+ })
@@ -0,0 +1,76 @@
1
+ // Pure screen state machine — zero React imports
2
+
3
+ // --- State ---
4
+
5
+ export interface ScreenMachineState {
6
+ status: 'loading' | 'building' | 'ready' | 'error'
7
+ activeState: string
8
+ availableStates: string[]
9
+ viewport: 'mobile' | 'tablet' | 'desktop'
10
+ isFullscreen: boolean
11
+ error?: string
12
+ }
13
+
14
+ // --- Actions ---
15
+
16
+ export type ScreenAction =
17
+ | { type: 'set_state'; state: string }
18
+ | { type: 'set_viewport'; viewport: 'mobile' | 'tablet' | 'desktop' }
19
+ | { type: 'toggle_fullscreen' }
20
+ | { type: 'build_started' }
21
+ | { type: 'build_ready' }
22
+ | { type: 'build_error'; error: string }
23
+
24
+ // --- Initial state ---
25
+
26
+ export function createScreenInitialState(
27
+ availableStates: string[],
28
+ initialState?: string,
29
+ ): ScreenMachineState {
30
+ // Clamp: use initialState if valid, else 'index' if available, else first available, else 'index'
31
+ let active = 'index'
32
+ if (initialState && availableStates.includes(initialState)) {
33
+ active = initialState
34
+ } else if (availableStates.includes('index')) {
35
+ active = 'index'
36
+ } else if (availableStates.length > 0) {
37
+ active = availableStates[0]
38
+ }
39
+
40
+ return {
41
+ status: 'loading',
42
+ activeState: active,
43
+ availableStates,
44
+ viewport: 'desktop',
45
+ isFullscreen: false,
46
+ }
47
+ }
48
+
49
+ // --- Reducer ---
50
+
51
+ export function screenReducer(state: ScreenMachineState, action: ScreenAction): ScreenMachineState {
52
+ switch (action.type) {
53
+ case 'set_state': {
54
+ if (!state.availableStates.includes(action.state)) return state
55
+ return { ...state, activeState: action.state }
56
+ }
57
+
58
+ case 'set_viewport':
59
+ return { ...state, viewport: action.viewport }
60
+
61
+ case 'toggle_fullscreen':
62
+ return { ...state, isFullscreen: !state.isFullscreen }
63
+
64
+ case 'build_started':
65
+ return { ...state, status: 'building' }
66
+
67
+ case 'build_ready':
68
+ return { ...state, status: 'ready', error: undefined }
69
+
70
+ case 'build_error':
71
+ return { ...state, status: 'error', error: action.error }
72
+
73
+ default:
74
+ return state
75
+ }
76
+ }
@@ -0,0 +1,157 @@
1
+ import { test, expect, describe } from 'bun:test'
2
+ import { createFlowStore } from './flow-store'
3
+ import type { FlowStep } from '../../../content/preview-types'
4
+
5
+ const steps: FlowStep[] = [
6
+ {
7
+ id: 'step1',
8
+ title: 'Sign Up',
9
+ screen: 'signup',
10
+ regions: {
11
+ submit: { goto: 'step2' },
12
+ },
13
+ },
14
+ {
15
+ id: 'step2',
16
+ title: 'Dashboard',
17
+ screen: 'dashboard',
18
+ regions: {
19
+ 'upgrade-btn': {
20
+ outcomes: {
21
+ success: { goto: 'step3', label: 'Paid' },
22
+ failure: { goto: 'step1' },
23
+ },
24
+ },
25
+ },
26
+ },
27
+ {
28
+ id: 'step3',
29
+ title: 'Done',
30
+ screen: 'settings',
31
+ terminal: true,
32
+ },
33
+ ]
34
+
35
+ describe('createFlowStore', () => {
36
+ test('starts in loading state', () => {
37
+ const store = createFlowStore()
38
+ expect(store.getState().status).toBe('loading')
39
+ expect(store.getState().currentStepId).toBeNull()
40
+ })
41
+
42
+ test('loaded action transitions to ready with steps', () => {
43
+ const store = createFlowStore()
44
+ store.getState().loaded(steps, 'Test Flow', 'A flow')
45
+ const state = store.getState()
46
+ expect(state.status).toBe('ready')
47
+ expect(state.name).toBe('Test Flow')
48
+ expect(state.steps).toEqual(steps)
49
+ expect(state.currentStepId).toBe('step1')
50
+ })
51
+
52
+ test('goto navigates and updates history', () => {
53
+ const store = createFlowStore()
54
+ store.getState().loaded(steps, 'Test')
55
+ store.getState().goto('step2')
56
+ expect(store.getState().currentStepId).toBe('step2')
57
+ expect(store.getState().history).toEqual(['step1', 'step2'])
58
+ })
59
+
60
+ test('back pops history', () => {
61
+ const store = createFlowStore()
62
+ store.getState().loaded(steps, 'Test')
63
+ store.getState().goto('step2')
64
+ store.getState().back()
65
+ expect(store.getState().currentStepId).toBe('step1')
66
+ expect(store.getState().history).toEqual(['step1'])
67
+ })
68
+
69
+ test('regionClick with goto navigates', () => {
70
+ const store = createFlowStore()
71
+ store.getState().loaded(steps, 'Test')
72
+ store.getState().regionClick('submit')
73
+ expect(store.getState().currentStepId).toBe('step2')
74
+ })
75
+
76
+ test('regionClick with outcomes opens picker', () => {
77
+ const store = createFlowStore()
78
+ store.getState().loaded(steps, 'Test')
79
+ store.getState().goto('step2')
80
+ store.getState().regionClick('upgrade-btn')
81
+ expect(store.getState().outcomePicker).not.toBeNull()
82
+ expect(store.getState().currentStepId).toBe('step2')
83
+ })
84
+
85
+ test('pickOutcome navigates and clears picker', () => {
86
+ const store = createFlowStore()
87
+ store.getState().loaded(steps, 'Test')
88
+ store.getState().goto('step2')
89
+ store.getState().regionClick('upgrade-btn')
90
+ store.getState().pickOutcome('step3')
91
+ expect(store.getState().currentStepId).toBe('step3')
92
+ expect(store.getState().outcomePicker).toBeNull()
93
+ })
94
+
95
+ test('cancelPicker clears picker without navigating', () => {
96
+ const store = createFlowStore()
97
+ store.getState().loaded(steps, 'Test')
98
+ store.getState().goto('step2')
99
+ store.getState().regionClick('upgrade-btn')
100
+ store.getState().cancelPicker()
101
+ expect(store.getState().outcomePicker).toBeNull()
102
+ expect(store.getState().currentStepId).toBe('step2')
103
+ })
104
+
105
+ test('toggleOverlay toggles showOverlay', () => {
106
+ const store = createFlowStore()
107
+ expect(store.getState().showOverlay).toBe(true)
108
+ store.getState().toggleOverlay()
109
+ expect(store.getState().showOverlay).toBe(false)
110
+ })
111
+
112
+ test('toggleFullscreen toggles isFullscreen', () => {
113
+ const store = createFlowStore()
114
+ expect(store.getState().isFullscreen).toBe(false)
115
+ store.getState().toggleFullscreen()
116
+ expect(store.getState().isFullscreen).toBe(true)
117
+ })
118
+
119
+ test('linearNext advances for non-region steps', () => {
120
+ const linearSteps: FlowStep[] = [
121
+ { id: 'a', screen: 'page-a' },
122
+ { id: 'b', screen: 'page-b' },
123
+ ]
124
+ const store = createFlowStore()
125
+ store.getState().loaded(linearSteps, 'Test')
126
+ store.getState().linearNext()
127
+ expect(store.getState().currentStepId).toBe('b')
128
+ })
129
+
130
+ test('linearPrev goes back', () => {
131
+ const linearSteps: FlowStep[] = [
132
+ { id: 'a', screen: 'page-a' },
133
+ { id: 'b', screen: 'page-b' },
134
+ ]
135
+ const store = createFlowStore()
136
+ store.getState().loaded(linearSteps, 'Test')
137
+ store.getState().linearNext()
138
+ store.getState().linearPrev()
139
+ expect(store.getState().currentStepId).toBe('a')
140
+ })
141
+
142
+ // Codex review: missing store-level tests
143
+ test('loadError sets error state', () => {
144
+ const store = createFlowStore()
145
+ store.getState().loadError('Network failure')
146
+ expect(store.getState().status).toBe('error')
147
+ expect(store.getState().error).toBe('Network failure')
148
+ })
149
+
150
+ test('load_error -> loaded clears stale error', () => {
151
+ const store = createFlowStore()
152
+ store.getState().loadError('oops')
153
+ store.getState().loaded(steps, 'Recovered')
154
+ expect(store.getState().status).toBe('ready')
155
+ expect(store.getState().error).toBeUndefined()
156
+ })
157
+ })
@@ -0,0 +1,49 @@
1
+ // Zustand store wrapping the flow state machine
2
+
3
+ import { createStore } from 'zustand/vanilla'
4
+ import { flowReducer, createFlowInitialState, type FlowMachineState } from '../machines/flow-machine'
5
+ import type { FlowStep } from '../../../content/preview-types'
6
+
7
+ export interface FlowStoreActions {
8
+ loaded: (steps: FlowStep[], name: string, description?: string) => void
9
+ loadError: (error?: string) => void
10
+ goto: (stepId: string) => void
11
+ back: () => void
12
+ linearNext: () => void
13
+ linearPrev: () => void
14
+ regionClick: (region: string) => void
15
+ pickOutcome: (stepId: string) => void
16
+ cancelPicker: () => void
17
+ toggleOverlay: () => void
18
+ toggleFullscreen: () => void
19
+ }
20
+
21
+ export type FlowStore = FlowMachineState & FlowStoreActions
22
+
23
+ export function createFlowStore() {
24
+ return createStore<FlowStore>((set) => ({
25
+ ...createFlowInitialState(),
26
+ loaded: (steps, name, description) =>
27
+ set(s => flowReducer(s, { type: 'loaded', steps, name, description })),
28
+ loadError: (error) =>
29
+ set(s => flowReducer(s, { type: 'load_error', error })),
30
+ goto: (stepId) =>
31
+ set(s => flowReducer(s, { type: 'goto', stepId })),
32
+ back: () =>
33
+ set(s => flowReducer(s, { type: 'back' })),
34
+ linearNext: () =>
35
+ set(s => flowReducer(s, { type: 'linear_next' })),
36
+ linearPrev: () =>
37
+ set(s => flowReducer(s, { type: 'linear_prev' })),
38
+ regionClick: (region) =>
39
+ set(s => flowReducer(s, { type: 'region_click', region })),
40
+ pickOutcome: (stepId) =>
41
+ set(s => flowReducer(s, { type: 'pick_outcome', stepId })),
42
+ cancelPicker: () =>
43
+ set(s => flowReducer(s, { type: 'cancel_picker' })),
44
+ toggleOverlay: () =>
45
+ set(s => flowReducer(s, { type: 'toggle_overlay' })),
46
+ toggleFullscreen: () =>
47
+ set(s => flowReducer(s, { type: 'toggle_fullscreen' })),
48
+ }))
49
+ }
@@ -0,0 +1,68 @@
1
+ import { test, expect, describe } from 'bun:test'
2
+ import { createScreenStore } from './screen-store'
3
+
4
+ describe('createScreenStore', () => {
5
+ test('starts in loading state', () => {
6
+ const store = createScreenStore(['index', 'error'])
7
+ expect(store.getState().status).toBe('loading')
8
+ expect(store.getState().activeState).toBe('index')
9
+ expect(store.getState().availableStates).toEqual(['index', 'error'])
10
+ })
11
+
12
+ test('accepts initial state override', () => {
13
+ const store = createScreenStore(['index', 'error'], 'error')
14
+ expect(store.getState().activeState).toBe('error')
15
+ })
16
+
17
+ test('setState switches to valid state', () => {
18
+ const store = createScreenStore(['index', 'error'])
19
+ store.getState().setState('error')
20
+ expect(store.getState().activeState).toBe('error')
21
+ })
22
+
23
+ test('setState ignores invalid state', () => {
24
+ const store = createScreenStore(['index', 'error'])
25
+ store.getState().setState('nonexistent')
26
+ expect(store.getState().activeState).toBe('index')
27
+ })
28
+
29
+ test('setViewport switches viewport', () => {
30
+ const store = createScreenStore(['index'])
31
+ store.getState().setViewport('mobile')
32
+ expect(store.getState().viewport).toBe('mobile')
33
+ })
34
+
35
+ test('toggleFullscreen toggles', () => {
36
+ const store = createScreenStore(['index'])
37
+ expect(store.getState().isFullscreen).toBe(false)
38
+ store.getState().toggleFullscreen()
39
+ expect(store.getState().isFullscreen).toBe(true)
40
+ })
41
+
42
+ test('build status FSM: loading -> building -> ready', () => {
43
+ const store = createScreenStore(['index'])
44
+ expect(store.getState().status).toBe('loading')
45
+ store.getState().buildStarted()
46
+ expect(store.getState().status).toBe('building')
47
+ store.getState().buildReady()
48
+ expect(store.getState().status).toBe('ready')
49
+ expect(store.getState().error).toBeUndefined()
50
+ })
51
+
52
+ test('build status FSM: building -> error', () => {
53
+ const store = createScreenStore(['index'])
54
+ store.getState().buildStarted()
55
+ store.getState().buildError('Syntax error')
56
+ expect(store.getState().status).toBe('error')
57
+ expect(store.getState().error).toBe('Syntax error')
58
+ })
59
+
60
+ test('build_ready clears previous error', () => {
61
+ const store = createScreenStore(['index'])
62
+ store.getState().buildStarted()
63
+ store.getState().buildError('oops')
64
+ store.getState().buildStarted()
65
+ store.getState().buildReady()
66
+ expect(store.getState().error).toBeUndefined()
67
+ })
68
+ })
@@ -0,0 +1,33 @@
1
+ // Zustand store wrapping the screen state machine
2
+
3
+ import { createStore } from 'zustand/vanilla'
4
+ import { screenReducer, createScreenInitialState, type ScreenMachineState } from '../machines/screen-machine'
5
+
6
+ export interface ScreenStoreActions {
7
+ setState: (state: string) => void
8
+ setViewport: (viewport: 'mobile' | 'tablet' | 'desktop') => void
9
+ toggleFullscreen: () => void
10
+ buildStarted: () => void
11
+ buildReady: () => void
12
+ buildError: (error: string) => void
13
+ }
14
+
15
+ export type ScreenStore = ScreenMachineState & ScreenStoreActions
16
+
17
+ export function createScreenStore(availableStates: string[], initialState?: string) {
18
+ return createStore<ScreenStore>((set) => ({
19
+ ...createScreenInitialState(availableStates, initialState),
20
+ setState: (state) =>
21
+ set(s => screenReducer(s, { type: 'set_state', state })),
22
+ setViewport: (viewport) =>
23
+ set(s => screenReducer(s, { type: 'set_viewport', viewport })),
24
+ toggleFullscreen: () =>
25
+ set(s => screenReducer(s, { type: 'toggle_fullscreen' })),
26
+ buildStarted: () =>
27
+ set(s => screenReducer(s, { type: 'build_started' })),
28
+ buildReady: () =>
29
+ set(s => screenReducer(s, { type: 'build_ready' })),
30
+ buildError: (error) =>
31
+ set(s => screenReducer(s, { type: 'build_error', error })),
32
+ }))
33
+ }
@@ -0,0 +1,97 @@
1
+ import { test, expect, describe, beforeEach } from 'bun:test'
2
+ import { PrefixedStorageAdapter } from './storage'
3
+
4
+ function createMemoryBackend() {
5
+ const store = new Map<string, string>()
6
+ return {
7
+ getItem: (k: string) => store.get(k) ?? null,
8
+ setItem: (k: string, v: string) => store.set(k, v),
9
+ removeItem: (k: string) => store.delete(k),
10
+ key: (i: number) => [...store.keys()][i] ?? null,
11
+ get length() { return store.size },
12
+ _store: store,
13
+ }
14
+ }
15
+
16
+ describe('PrefixedStorageAdapter', () => {
17
+ let backend: ReturnType<typeof createMemoryBackend>
18
+ let storage: PrefixedStorageAdapter
19
+
20
+ beforeEach(() => {
21
+ backend = createMemoryBackend()
22
+ storage = new PrefixedStorageAdapter(backend)
23
+ })
24
+
25
+ test('get returns null for missing key', () => {
26
+ expect(storage.get('nonexistent')).toBeNull()
27
+ })
28
+
29
+ test('set and get round-trip', () => {
30
+ storage.set('test-key', { name: 'hello', count: 42 })
31
+ expect(storage.get('test-key')).toEqual({ name: 'hello', count: 42 })
32
+ })
33
+
34
+ test('set overwrites existing value', () => {
35
+ storage.set('key', 'first')
36
+ storage.set('key', 'second')
37
+ expect(storage.get('key')).toBe('second')
38
+ })
39
+
40
+ test('remove deletes a key', () => {
41
+ storage.set('to-remove', 'value')
42
+ storage.remove('to-remove')
43
+ expect(storage.get('to-remove')).toBeNull()
44
+ })
45
+
46
+ test('list returns keys matching prefix', () => {
47
+ storage.set('annotations:screen1:a', { id: 'a' })
48
+ storage.set('annotations:screen1:b', { id: 'b' })
49
+ storage.set('status:screen1', { status: 'draft' })
50
+
51
+ const keys = storage.list('annotations:')
52
+ expect(keys).toContain('annotations:screen1:a')
53
+ expect(keys).toContain('annotations:screen1:b')
54
+ expect(keys).not.toContain('status:screen1')
55
+ })
56
+
57
+ test('list returns empty array when no matches', () => {
58
+ expect(storage.list('nothing:')).toEqual([])
59
+ })
60
+
61
+ test('clear removes all keys matching prefix', () => {
62
+ storage.set('annotations:a', 1)
63
+ storage.set('annotations:b', 2)
64
+ storage.set('status:x', 3)
65
+
66
+ storage.clear('annotations:')
67
+
68
+ expect(storage.get('annotations:a')).toBeNull()
69
+ expect(storage.get('annotations:b')).toBeNull()
70
+ expect(storage.get('status:x')).toBe(3)
71
+ })
72
+
73
+ test('get handles corrupt JSON gracefully', () => {
74
+ backend._store.set('prev:corrupt', '{bad json')
75
+ expect(storage.get('corrupt')).toBeNull()
76
+ })
77
+
78
+ test('stores and retrieves arrays', () => {
79
+ storage.set('arr', [1, 2, 3])
80
+ expect(storage.get('arr')).toEqual([1, 2, 3])
81
+ })
82
+
83
+ test('stores and retrieves nested objects', () => {
84
+ const data = { a: { b: { c: 'deep' } } }
85
+ storage.set('nested', data)
86
+ expect(storage.get('nested')).toEqual(data)
87
+ })
88
+
89
+ test('prefix isolation - different prefixes dont collide', () => {
90
+ const storage2 = new PrefixedStorageAdapter(backend, 'other:')
91
+ storage.set('key', 'from-prev')
92
+ storage2.set('key', 'from-other')
93
+
94
+ expect(storage.get('key')).toBe('from-prev')
95
+ expect(storage2.get('key')).toBe('from-other')
96
+ })
97
+ })