@octane-xplat/workshop 0.0.0-nightly.g14544fe

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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/MOBILE.md +143 -0
  3. package/README.md +307 -0
  4. package/dist/build/discover.js +404 -0
  5. package/dist/build/macos-node.js +77 -0
  6. package/dist/build/mobile-node.js +96 -0
  7. package/dist/build/mobile-regex.js +40 -0
  8. package/dist/build/node.js +426 -0
  9. package/dist/build/vite-mobile.js +75 -0
  10. package/dist/build/vite.js +230 -0
  11. package/dist/build/windows/preflight.js +83 -0
  12. package/dist/contract.js +50 -0
  13. package/dist/index.js +2 -0
  14. package/package.json +127 -0
  15. package/src/build/discover.ts +585 -0
  16. package/src/build/macos/shell.macos.ts +674 -0
  17. package/src/build/macos-node.ts +98 -0
  18. package/src/build/macos-vite.ts +48 -0
  19. package/src/build/mobile-node.ts +127 -0
  20. package/src/build/mobile-regex.ts +60 -0
  21. package/src/build/node.d.ts +65 -0
  22. package/src/build/node.ts +537 -0
  23. package/src/build/vite-mobile.d.ts +21 -0
  24. package/src/build/vite-mobile.ts +102 -0
  25. package/src/build/vite.d.ts +11 -0
  26. package/src/build/vite.ts +316 -0
  27. package/src/build/windows/preflight.ts +113 -0
  28. package/src/catalog.ts +58 -0
  29. package/src/contract.ts +257 -0
  30. package/src/controls.ts +159 -0
  31. package/src/errors.ts +47 -0
  32. package/src/fixture.ts +158 -0
  33. package/src/index.ts +6 -0
  34. package/src/macos/controller.ts +170 -0
  35. package/src/macos/index.macos.ts +2 -0
  36. package/src/macos/stage.macos.ts +182 -0
  37. package/src/mobile/controller.ts +134 -0
  38. package/src/mobile/entry.mobile.ts +607 -0
  39. package/src/mobile/index.mobile.ts +3 -0
  40. package/src/mobile/mount.mobile.ts +60 -0
  41. package/src/mobile/tree.mobile.tsrx +17 -0
  42. package/src/session.ts +520 -0
  43. package/src/web/entry.web.ts +270 -0
  44. package/src/web/index.web.ts +7 -0
  45. package/src/web/mount.web.ts +86 -0
  46. package/src/web/shell.css +449 -0
  47. package/src/web/shell.web.tsrx +759 -0
  48. package/types/generated/.tsrx-typegen-manifest.json +24 -0
  49. package/types/generated/catalog.d.ts +30 -0
  50. package/types/generated/contract.d.ts +145 -0
  51. package/types/generated/controls.d.ts +28 -0
  52. package/types/generated/errors.d.ts +13 -0
  53. package/types/generated/fixture.d.ts +53 -0
  54. package/types/generated/index.d.ts +6 -0
  55. package/types/generated/macos/controller.d.ts +52 -0
  56. package/types/generated/macos/index.macos.d.ts +2 -0
  57. package/types/generated/macos/stage.macos.d.ts +10 -0
  58. package/types/generated/mobile/controller.d.ts +31 -0
  59. package/types/generated/mobile/entry.mobile.d.ts +22 -0
  60. package/types/generated/mobile/index.mobile.d.ts +3 -0
  61. package/types/generated/mobile/mount.mobile.d.ts +4 -0
  62. package/types/generated/mobile/tree.mobile.d.ts +4 -0
  63. package/types/generated/session.d.ts +84 -0
  64. package/types/generated/web/entry.web.d.ts +53 -0
  65. package/types/generated/web/index.web.d.ts +7 -0
  66. package/types/generated/web/mount.web.d.ts +2 -0
  67. package/types/generated/web/shell.web.d.ts +4 -0
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Workshop web entry — boots the shell, wires the session to HMR events, and
3
+ * receives refreshed catalogs when the virtual catalog module re-executes.
4
+ */
5
+ import { createElement, createRoot } from 'octane'
6
+ import { createStore } from '@octane-xplat/ui/data'
7
+ import type { CatalogEntry } from '../catalog'
8
+ import type { StoryDefinition, WorkshopTarget } from '../contract'
9
+ import type { SessionSnapshot } from '../session'
10
+ import { WorkshopSession } from '../session'
11
+ import { createWebStageHost } from './mount.web'
12
+ import { WorkshopApp } from './shell.web.tsrx'
13
+
14
+ export interface ShellState {
15
+ mode: 'wide' | 'compact' | 'phone'
16
+ query: string
17
+ selectedId: string | null
18
+ expanded: ReadonlySet<string>
19
+ catalogCollapsed: boolean
20
+ controlsCollapsed: boolean
21
+ auxPanel: 'stories' | 'controls' | null
22
+ stageOnly: boolean
23
+ stageBackground: 'default' | 'light' | 'dark'
24
+ stageBounds: 'available' | 'small' | 'medium'
25
+ catalog: CatalogEntry[]
26
+ catalogError: string | undefined
27
+ missingId: string | null
28
+ session: SessionSnapshot
29
+ drafts: Record<string, string>
30
+ fieldErrors: Record<string, string>
31
+ unsetKeys: ReadonlySet<string>
32
+ }
33
+
34
+ export interface WorkshopRuntime {
35
+ store: ReturnType<typeof createStore<ShellState>>
36
+ session: WorkshopSession
37
+ mountHost: HTMLElement
38
+ selectEntry(entry: CatalogEntry): Promise<void>
39
+ updateCatalog(entries: CatalogEntry[], diagnostics?: readonly { message: string }[]): void
40
+ loadGeneration: number
41
+ }
42
+
43
+ let runtime: WorkshopRuntime | null = null
44
+
45
+ /** Called by the generated catalog module on load and on every HMR re-execution. */
46
+ export function catalogDidUpdate(
47
+ entries: CatalogEntry[],
48
+ diagnostics: readonly { message: string }[] = [],
49
+ ): void {
50
+ runtime?.updateCatalog(entries, diagnostics)
51
+ }
52
+
53
+ export interface WorkshopBootOptions {
54
+ catalog: CatalogEntry[]
55
+ diagnostics?: readonly { message: string }[]
56
+ target: WorkshopTarget
57
+ initialStoryId?: string | null
58
+ globalProviders?: readonly unknown[]
59
+ /** Vite HMR handle — passed by the generated entry because lib builds
60
+ * fold `import.meta.hot` to undefined. */
61
+ hot?: { on?: (event: string, cb: (payload?: unknown) => void) => void }
62
+ }
63
+
64
+ export function startWorkshop(options: WorkshopBootOptions): WorkshopRuntime {
65
+ const mountHost = document.createElement('div')
66
+ mountHost.className = 'xw-stage-host'
67
+
68
+ const session = new WorkshopSession({
69
+ host: createWebStageHost(mountHost),
70
+ target: options.target,
71
+ globalProviders: options.globalProviders ?? [],
72
+ })
73
+
74
+ const store = createStore<ShellState>({
75
+ mode: 'wide',
76
+ query: '',
77
+ selectedId: null,
78
+ expanded: new Set(groupKeys(options.catalog)),
79
+ catalogCollapsed: false,
80
+ controlsCollapsed: false,
81
+ auxPanel: null,
82
+ stageOnly: false,
83
+ stageBackground: 'default',
84
+ stageBounds: 'available',
85
+ catalog: options.catalog,
86
+ catalogError: options.diagnostics?.length
87
+ ? options.diagnostics.map((d) => d.message).join('\n')
88
+ : undefined,
89
+ missingId: null,
90
+ session: session.state,
91
+ drafts: {},
92
+ fieldErrors: {},
93
+ unsetKeys: new Set(),
94
+ })
95
+
96
+ session.subscribe((snapshot) => {
97
+ store.update((s) => ({ ...s, session: snapshot }))
98
+ })
99
+
100
+ runtime = {
101
+ store,
102
+ session,
103
+ mountHost,
104
+ loadGeneration: 0,
105
+ async selectEntry(entry) {
106
+ if (!entry.load) {
107
+ return
108
+ }
109
+
110
+ const generation = ++runtime!.loadGeneration
111
+ try {
112
+ const loaded = await entry.load()
113
+ if (generation !== runtime!.loadGeneration) {
114
+ return
115
+ }
116
+
117
+ const definition = resolveDefinition(loaded, entry)
118
+ await session.select(definition)
119
+ } catch (error) {
120
+ if (generation === runtime!.loadGeneration) {
121
+ session.markCompileError(error)
122
+ }
123
+ }
124
+ },
125
+ updateCatalog(entries, diagnostics = []) {
126
+ const generation = ++runtime!.loadGeneration
127
+ const stamp = String(Date.now())
128
+ store.update((s) => ({
129
+ ...s,
130
+ catalog: entries,
131
+ catalogError: diagnostics.length ? diagnostics.map((d) => d.message).join('\n') : undefined,
132
+ expanded: new Set([...s.expanded, ...groupKeys(entries)]),
133
+ }))
134
+
135
+ if (diagnostics.length) {
136
+ session.markCompileError(diagnostics.map((d) => d.message).join('\n'))
137
+ return
138
+ }
139
+
140
+ const current = store.getSnapshot().selectedId
141
+ if (!current) {
142
+ return
143
+ }
144
+
145
+ const entry = pickEntry(entries, current)
146
+ if (!entry?.load) {
147
+ store.update((s) => ({ ...s, missingId: current }))
148
+ void session.dispose()
149
+ return
150
+ }
151
+
152
+ void (async () => {
153
+ try {
154
+ const loaded = await entry.load!(stamp)
155
+ if (generation !== runtime!.loadGeneration) {
156
+ return
157
+ }
158
+
159
+ await session.reload(resolveDefinition(loaded, entry))
160
+ } catch (error) {
161
+ if (generation === runtime!.loadGeneration) {
162
+ session.markCompileError(error)
163
+ }
164
+ }
165
+ })()
166
+ },
167
+ }
168
+
169
+ // Automation hook — tests and the dev bridge read the live session/store.
170
+ ;(globalThis as Record<string, unknown>).__xplatWorkshopRuntime = runtime
171
+
172
+ // Dev-server lifecycle: rebuilds mark the stage rebuilding; a failed
173
+ // transform keeps the last good stage and marks it stale.
174
+ let updateLoadGeneration = 0
175
+ options.hot?.on?.('vite:beforeUpdate', () => {
176
+ updateLoadGeneration = runtime!.loadGeneration
177
+ session.markRebuilding()
178
+ })
179
+
180
+ options.hot?.on?.('vite:afterUpdate', () => {
181
+ // Octane component modules self-accept HMR, which can stop propagation
182
+ // before the catalog. Retire/remount once the successful update has
183
+ // applied; a catalog update already owns its own fresh generation.
184
+ if (runtime!.loadGeneration === updateLoadGeneration && session.state.stale) {
185
+ runtime!.updateCatalog(store.getSnapshot().catalog)
186
+ }
187
+ })
188
+
189
+ options.hot?.on?.('vite:error', (payload) => {
190
+ const err = payload as { err?: { message?: string } } | undefined
191
+ session.markCompileError(err?.err?.message ?? 'source update failed')
192
+ })
193
+
194
+ options.hot?.on?.('vite:ws:disconnect', () => session.markDisconnected())
195
+ options.hot?.on?.('vite:ws:connect', () => session.markConnected())
196
+
197
+ const hostElement = document.getElementById('workshop-root') ?? document.body
198
+ createRoot(hostElement).render(createElement(WorkshopApp as never, { runtime } as never))
199
+
200
+ // Hash selection: #/story/<id> survives reloads and deep-links a story.
201
+ const idFromHash = () => {
202
+ const raw = location.hash.replace(/^#\/?story\//, '')
203
+ try {
204
+ return decodeURIComponent(raw) || null
205
+ } catch {
206
+ return raw || null
207
+ }
208
+ }
209
+
210
+ const pickEntry = (entries: CatalogEntry[], id: string) =>
211
+ entries.find((e) => e.id === id && e.load) ?? entries.find((e) => e.id === id)
212
+
213
+ const initialId = options.initialStoryId ?? idFromHash()
214
+ if (initialId) {
215
+ const entry = pickEntry(options.catalog, initialId)
216
+ if (entry?.load) {
217
+ store.update((s) => ({ ...s, selectedId: initialId }))
218
+ void runtime.selectEntry(entry)
219
+ } else {
220
+ store.update((s) => ({ ...s, selectedId: initialId, missingId: initialId }))
221
+ }
222
+ }
223
+
224
+ globalThis.addEventListener?.('hashchange', () => {
225
+ const id = idFromHash()
226
+ if (id && id !== store.getSnapshot().selectedId) {
227
+ const entry = pickEntry(store.getSnapshot().catalog, id)
228
+ if (entry?.load) {
229
+ store.update((s) => ({ ...s, selectedId: id, missingId: null }))
230
+ void runtime!.selectEntry(entry)
231
+ } else {
232
+ ++runtime!.loadGeneration
233
+ store.update((s) => ({ ...s, selectedId: id, missingId: id }))
234
+ void session.dispose()
235
+ }
236
+ }
237
+ })
238
+
239
+ return runtime
240
+ }
241
+
242
+ /** Group keys (`name@depth`) for every title segment group in the catalog. */
243
+ function groupKeys(entries: CatalogEntry[]): string[] {
244
+ const keys = new Set<string>()
245
+ for (const entry of entries) {
246
+ const segments = entry.title
247
+ .split('/')
248
+ .map((seg) => seg.trim())
249
+ .filter(Boolean)
250
+
251
+ for (let depth = 0; depth < segments.length - 1; depth++) {
252
+ keys.add(`${segments[depth]}@${depth}`)
253
+ }
254
+ }
255
+
256
+ return [...keys]
257
+ }
258
+
259
+ function resolveDefinition(loaded: unknown, entry: CatalogEntry): StoryDefinition<any, any, any> {
260
+ const mod = loaded as Record<string, unknown>
261
+ const candidate: unknown = mod?.[entry.exportName]
262
+ const definition = candidate as StoryDefinition<any, any, any> | undefined
263
+ if (!definition || typeof definition !== 'object' || definition.id !== entry.id) {
264
+ throw new Error(
265
+ `story module ${entry.file} did not provide a story definition at export "${entry.exportName}"`,
266
+ )
267
+ }
268
+
269
+ return definition
270
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Web runtime entry — the catalog virtual module and the generated workshop
3
+ * entry import this. Exposes startWorkshop plus the catalog-update seam the
4
+ * virtual catalog calls back into after HMR re-execution.
5
+ */
6
+ export { createWebStageHost } from './mount.web'
7
+ export { startWorkshop, catalogDidUpdate } from './entry.web'
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Web stage host — mounts a prepared story renderable inside the shell's
3
+ * stage element through the real Octane DOM renderer. Each generation owns
4
+ * its root; `unmount` disposes the subtree including owned portals.
5
+ */
6
+ import { ErrorBoundary, createElement, createRoot } from 'octane'
7
+ import { composeStory } from 'storybook/preview-api'
8
+ import type { MountedStory, MountRequest, StageHost } from '../session'
9
+
10
+ function storyTree(request: MountRequest): any {
11
+ let inner: any = createElement(request.component as any, request.props)
12
+ for (const provider of [...request.providers].reverse()) {
13
+ inner = createElement(provider as any, { fixture: request.fixture } as any, inner)
14
+ }
15
+
16
+ // A story render error swaps to a marker node and reports to the session;
17
+ // the shell keeps navigation/reset reachable around the failed subtree.
18
+ return createElement(
19
+ ErrorBoundary as any,
20
+ {
21
+ fallback: (error: unknown) => {
22
+ // The fallback runs inside the render pass — octane rejects store
23
+ // updates there, so report after the pass commits.
24
+ queueMicrotask(() => request.onRenderError(error))
25
+ return createElement('div', {
26
+ className: 'xw-stage-error-marker',
27
+ role: 'alert',
28
+ })
29
+ },
30
+ },
31
+ inner,
32
+ )
33
+ }
34
+
35
+ export function createWebStageHost(stageElement: HTMLElement): StageHost {
36
+ return {
37
+ mount(request: MountRequest): MountedStory {
38
+ // Pin the visual harness's portable composition subset to Storybook
39
+ // 10.6.1. Octane still mounts the compiled component; the session
40
+ // owns fixtures, providers, cancellation and every cleanup.
41
+ const composed = composeStory(
42
+ { args: request.props },
43
+ { title: request.story.title, component: request.component } as any,
44
+ { render: (props: any) => storyTree({ ...request, props }) } as any,
45
+ {},
46
+ 'Story',
47
+ )
48
+
49
+ const mountPoint = globalThis.document.createElement('div')
50
+ mountPoint.className = 'xw-stage-mount'
51
+ stageElement.append(mountPoint)
52
+ const root = createRoot(mountPoint)
53
+
54
+ try {
55
+ root.render(composed(request.props) as any)
56
+ } catch (error) {
57
+ root.unmount()
58
+ mountPoint.remove()
59
+ throw error
60
+ }
61
+
62
+ let disposed = false
63
+ return {
64
+ update(props: Record<string, unknown>) {
65
+ if (disposed) {
66
+ return
67
+ }
68
+
69
+ root.render(composed(props) as any)
70
+ },
71
+ unmount() {
72
+ if (disposed) {
73
+ return
74
+ }
75
+
76
+ disposed = true
77
+ try {
78
+ root.unmount()
79
+ } finally {
80
+ mountPoint.remove()
81
+ }
82
+ },
83
+ }
84
+ },
85
+ }
86
+ }