@octane-xplat/workshop 0.1.0 → 0.15.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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/MOBILE.md +143 -0
  3. package/README.md +305 -1
  4. package/dist/node/index.js +50 -0
  5. package/package.json +118 -5
  6. package/src/catalog.ts +58 -0
  7. package/src/contract.ts +257 -0
  8. package/src/controls.ts +159 -0
  9. package/src/discover.mjs +584 -0
  10. package/src/errors.ts +47 -0
  11. package/src/fixture.ts +158 -0
  12. package/src/index.ts +6 -0
  13. package/src/macos/controller.ts +170 -0
  14. package/src/macos/index.macos.ts +2 -0
  15. package/src/macos/shell.macos.mjs +673 -0
  16. package/src/macos/stage.macos.ts +182 -0
  17. package/src/macos-node.mjs +95 -0
  18. package/src/macos-vite.mjs +47 -0
  19. package/src/mobile/controller.ts +134 -0
  20. package/src/mobile/entry.mobile.ts +607 -0
  21. package/src/mobile/index.mobile.ts +3 -0
  22. package/src/mobile/mount.mobile.ts +60 -0
  23. package/src/mobile/tree.mobile.tsrx +17 -0
  24. package/src/mobile-node.mjs +126 -0
  25. package/src/mobile-regex.mjs +60 -0
  26. package/src/node.d.ts +65 -0
  27. package/src/node.mjs +536 -0
  28. package/src/session.ts +520 -0
  29. package/src/vite-mobile.d.ts +21 -0
  30. package/src/vite-mobile.mjs +101 -0
  31. package/src/vite.d.ts +11 -0
  32. package/src/vite.mjs +315 -0
  33. package/src/web/entry.web.ts +270 -0
  34. package/src/web/index.web.ts +7 -0
  35. package/src/web/mount.web.ts +86 -0
  36. package/src/web/shell.css +449 -0
  37. package/src/web/shell.web.tsrx +759 -0
  38. package/src/windows/preflight.mjs +113 -0
  39. package/types/generated/.tsrx-typegen-manifest.json +24 -0
  40. package/types/generated/catalog.d.ts +30 -0
  41. package/types/generated/contract.d.ts +145 -0
  42. package/types/generated/controls.d.ts +28 -0
  43. package/types/generated/errors.d.ts +13 -0
  44. package/types/generated/fixture.d.ts +53 -0
  45. package/types/generated/index.d.ts +6 -0
  46. package/types/generated/macos/controller.d.ts +52 -0
  47. package/types/generated/macos/index.macos.d.ts +2 -0
  48. package/types/generated/macos/stage.macos.d.ts +10 -0
  49. package/types/generated/mobile/controller.d.ts +31 -0
  50. package/types/generated/mobile/entry.mobile.d.ts +22 -0
  51. package/types/generated/mobile/index.mobile.d.ts +3 -0
  52. package/types/generated/mobile/mount.mobile.d.ts +4 -0
  53. package/types/generated/mobile/tree.mobile.d.ts +4 -0
  54. package/types/generated/session.d.ts +84 -0
  55. package/types/generated/web/entry.web.d.ts +53 -0
  56. package/types/generated/web/index.web.d.ts +7 -0
  57. package/types/generated/web/mount.web.d.ts +2 -0
  58. package/types/generated/web/shell.web.d.ts +4 -0
@@ -0,0 +1,759 @@
1
+ /**
2
+ * Adaptive workshop shell — catalog, stage, controls, status.
3
+ * DOM vocabulary is correct here: this is the DOM-host surface (browser and
4
+ * the Linux WebKitGTK webview share it).
5
+ */
6
+ import { useEffect, useLayoutEffect, useRef, useState } from 'octane'
7
+ import { useStore } from '@octane-xplat/ui/data'
8
+ import type { CatalogEntry } from '../catalog'
9
+ import { WORKSHOP_TARGETS, type StoryDefinition } from '../contract'
10
+ import { UNSET, applyControlInput, isUnsettable } from '../controls'
11
+ import type { WorkshopRuntime, ShellState } from './entry.web'
12
+ import { describeCause } from '../errors'
13
+
14
+ const WIDE_MIN = 1080
15
+ const PHONE_MAX = 640
16
+
17
+ const EMPTY_EXAMPLE = [
18
+ "import { defineStory } from '@octane-xplat/workshop'",
19
+ '',
20
+ 'export const Basic = defineStory({',
21
+ " id: 'example/basic',",
22
+ " title: 'Example / Basic',",
23
+ " targets: 'all',",
24
+ ' component: MyComponent,',
25
+ ' args: {},',
26
+ '})',
27
+ ].join('\n')
28
+
29
+ const INCLUDE_PATTERN = '**/*.stories.{ts,tsx,tsrx}'
30
+
31
+ const modeFor = (width: number, height: number) =>
32
+ width >= WIDE_MIN && height >= 480 ? 'wide' : width <= PHONE_MAX ? 'phone' : 'compact'
33
+
34
+ /** Re-observe whenever the root element is (re)created — a mode swap can
35
+ * replace the node, and a detached node reports 0×0, which would stick the
36
+ * shell in phone mode. */
37
+ function observeMode(el: HTMLElement, store: WorkshopRuntime['store']): ResizeObserver {
38
+ const observer = new ResizeObserver((entries) => {
39
+ if (!el.isConnected) {
40
+ return
41
+ }
42
+
43
+ const width = entries[0]?.contentRect.width ?? el.clientWidth
44
+ const mode = modeFor(width, entries[0]?.contentRect.height ?? el.clientHeight)
45
+ // Defer past the notification frame — a synchronous re-render that
46
+ // resizes the root reports "ResizeObserver loop" noise.
47
+ requestAnimationFrame(() => {
48
+ if (el.isConnected && store.getSnapshot().mode !== mode) {
49
+ store.update((s) => ({ ...s, mode }))
50
+ }
51
+ })
52
+ })
53
+
54
+ observer.observe(el)
55
+ return observer
56
+ }
57
+
58
+ function selectStory(runtime: WorkshopRuntime, entry: CatalogEntry) {
59
+ const store = runtime.store
60
+ if (!entry.load) {
61
+ return
62
+ }
63
+
64
+ const s = store.getSnapshot()
65
+ store.set({
66
+ ...s,
67
+ selectedId: entry.id,
68
+ missingId: null,
69
+ auxPanel: s.mode === 'wide' ? s.auxPanel : null,
70
+ drafts: {},
71
+ fieldErrors: {},
72
+ unsetKeys: new Set(),
73
+ })
74
+
75
+ location.hash = `#/story/${encodeURIComponent(entry.id)}`
76
+ void runtime.selectEntry(entry)
77
+ }
78
+
79
+ // ---------- status bar ----------
80
+
81
+ function StatusBar(props: { runtime: WorkshopRuntime }) {
82
+ const s = useStore(props.runtime.store)
83
+ const session = s.session
84
+ const phaseLabel =
85
+ session.phase === 'rebuilding'
86
+ ? 'rebuilding'
87
+ : session.phase === 'preparing'
88
+ ? 'preparing fixtures'
89
+ : session.phase === 'mounting'
90
+ ? 'mounting'
91
+ : session.phase === 'disposing'
92
+ ? 'disposing'
93
+ : session.phase === 'restart-required'
94
+ ? 'restart required'
95
+ : session.phase
96
+
97
+ return (
98
+ <header className="xw-statusbar">
99
+ <details className="xw-target-menu">
100
+ <summary className="xw-target-chip" aria-label="Workshop targets">{props.runtime.session.target}</summary>
101
+ <ul>
102
+ {WORKSHOP_TARGETS.map((target) => <li key={target}>
103
+ {target}: {currentDefinition(props.runtime)?.targets !== 'all' &&
104
+ currentDefinition(props.runtime)?.targets &&
105
+ !currentDefinition(props.runtime)!.targets.includes(target)
106
+ ? 'unsupported by this story' : targetStatus(props.runtime.session.target, target)}
107
+ </li>)}
108
+ </ul>
109
+ </details>
110
+ <span className="xw-title" title={s.selectedId ?? ''}>
111
+ {s.selectedId ?? 'No story selected'}
112
+ </span>
113
+ <span className={`xw-phase xw-phase-${session.phase}`} aria-live="polite">
114
+ {phaseLabel}
115
+ {session.stale && session.phase !== 'rebuilding' ? ' · stale' : ''}
116
+ </span>
117
+ <span className="xw-spacer" />
118
+ {s.mode !== 'wide' ? (
119
+ <>
120
+ <button
121
+ type="button"
122
+ className="xw-btn"
123
+ data-workshop-opener="stories"
124
+ aria-pressed={s.auxPanel === 'stories'}
125
+ onClick={() => toggleAux(props.runtime, 'stories')}
126
+ >
127
+ Stories
128
+ </button>
129
+ <button
130
+ type="button"
131
+ className="xw-btn"
132
+ data-workshop-opener="controls"
133
+ aria-pressed={s.auxPanel === 'controls'}
134
+ onClick={() => toggleAux(props.runtime, 'controls')}
135
+ >
136
+ Controls
137
+ </button>
138
+ </>
139
+ ) : (
140
+ <>
141
+ <button
142
+ type="button"
143
+ className="xw-btn"
144
+ aria-pressed={!s.catalogCollapsed}
145
+ title="Toggle story catalog"
146
+ onClick={() =>
147
+ props.runtime.store.update((x) => ({ ...x, catalogCollapsed: !x.catalogCollapsed }))
148
+ }
149
+ >
150
+ Catalog
151
+ </button>
152
+ <button
153
+ type="button"
154
+ className="xw-btn"
155
+ aria-pressed={!s.controlsCollapsed}
156
+ title="Toggle controls panel"
157
+ onClick={() =>
158
+ props.runtime.store.update((x) => ({ ...x, controlsCollapsed: !x.controlsCollapsed }))
159
+ }
160
+ >
161
+ Controls
162
+ </button>
163
+ </>
164
+ )}
165
+ <button
166
+ type="button"
167
+ className="xw-btn xw-reset"
168
+ disabled={!s.selectedId || session.phase === 'preparing' || session.phase === 'mounting'}
169
+ onClick={() => {
170
+ clearEdits(props.runtime)
171
+ void props.runtime.session.reset()
172
+ }}
173
+ >
174
+ Reset
175
+ </button>
176
+ <button
177
+ type="button"
178
+ className="xw-btn"
179
+ title="Hide workshop chrome (Esc or the floating button returns)"
180
+ onClick={() => props.runtime.store.update((x) => ({ ...x, stageOnly: true }))}
181
+ >
182
+ Stage only
183
+ </button>
184
+ </header>
185
+ )
186
+ }
187
+
188
+ function toggleAux(runtime: WorkshopRuntime, panel: 'stories' | 'controls') {
189
+ const s = runtime.store.getSnapshot()
190
+ runtime.store.set({ ...s, auxPanel: s.auxPanel === panel ? null : panel })
191
+ }
192
+
193
+ function clearEdits(runtime: WorkshopRuntime) {
194
+ runtime.store.update((s) => ({ ...s, drafts: {}, fieldErrors: {}, unsetKeys: new Set() }))
195
+ }
196
+
197
+ // ---------- catalog ----------
198
+
199
+ interface Group {
200
+ name: string
201
+ entries: CatalogEntry[]
202
+ children: Map<string, Group>
203
+ }
204
+
205
+ function groupEntries(entries: CatalogEntry[], query: string): Group {
206
+ const root: Group = { name: '', entries: [], children: new Map() }
207
+ const q = query.trim().toLowerCase()
208
+ const filtered = q
209
+ ? entries.filter(
210
+ (e) => e.id.toLowerCase().includes(q) || e.title.toLowerCase().includes(q),
211
+ )
212
+ : entries
213
+
214
+ for (const entry of filtered) {
215
+ const segments = entry.title.split('/').map((seg) => seg.trim()).filter(Boolean)
216
+ let group = root
217
+ for (const seg of segments.slice(0, -1)) {
218
+ let next = group.children.get(seg)
219
+ if (!next) {
220
+ next = { name: seg, entries: [], children: new Map() }
221
+ group.children.set(seg, next)
222
+ }
223
+
224
+ group = next
225
+ }
226
+
227
+ group.entries.push(entry)
228
+ }
229
+
230
+ for (const g of [root, ...descendants(root)]) {
231
+ g.entries.sort((a, b) => a.title.localeCompare(b.title) || a.id.localeCompare(b.id))
232
+ }
233
+
234
+ return root
235
+ }
236
+
237
+ function descendants(group: Group): Group[] {
238
+ return [...group.children.values()].flatMap((g) => [g, ...descendants(g)])
239
+ }
240
+
241
+ function GroupView(props: { runtime: WorkshopRuntime; group: Group; depth: number }) {
242
+ const s = useStore(props.runtime.store)
243
+ return (
244
+ <>
245
+ {[...props.group.children.values()]
246
+ .sort((a, b) => a.name.localeCompare(b.name))
247
+ .map((child) => {
248
+ const key = `${child.name}@${props.depth}`
249
+ const open = s.expanded.has(key) || Boolean(s.query)
250
+ return (
251
+ <div key={key} className="xw-group">
252
+ <button
253
+ type="button"
254
+ className="xw-group-head"
255
+ aria-expanded={open}
256
+ onClick={() =>
257
+ props.runtime.store.update((x) => {
258
+ const next = new Set(x.expanded)
259
+ if (next.has(key)) next.delete(key)
260
+ else next.add(key)
261
+
262
+ return { ...x, expanded: next }
263
+ })
264
+ }
265
+ >
266
+ <span className="xw-caret" aria-hidden>{open ? '▾' : '▸'}</span> {child.name}
267
+ </button>
268
+ {open ? <GroupView runtime={props.runtime} group={child} depth={props.depth + 1} /> : null}
269
+ </div>
270
+ )
271
+ })}
272
+ {props.group.entries.map((entry) => (
273
+ <button
274
+ key={entry.id}
275
+ type="button"
276
+ className={`xw-story ${entry.id === s.selectedId ? 'xw-story-active' : ''}`}
277
+ disabled={!entry.load}
278
+ title={entry.load ? entry.id : `${entry.id} — ${entry.reason ?? 'not supported on this target'}`}
279
+ onClick={() => selectStory(props.runtime, entry)}
280
+ >
281
+ <span className="xw-story-name">{entry.title.split('/').pop()?.trim() || entry.title}</span>
282
+ {!entry.load ? <span className="xw-story-badge">unsupported</span> : null}
283
+ </button>
284
+ ))}
285
+ </>
286
+ )
287
+ }
288
+
289
+ function catalogBody(s: ShellState, runtime: WorkshopRuntime, groups: Group) {
290
+ if (s.catalogError) {
291
+ return <div className="xw-error" role="alert">{s.catalogError}</div>
292
+ }
293
+
294
+ if (s.catalog.length === 0) {
295
+ return (
296
+ <div className="xw-empty">
297
+ <p>No stories found.</p>
298
+ <p>Expected include: <code>{INCLUDE_PATTERN}</code></p>
299
+ <pre className="xw-example">{EMPTY_EXAMPLE}</pre>
300
+ </div>
301
+ )
302
+ }
303
+
304
+ return <GroupView runtime={runtime} group={groups} depth={0} />
305
+ }
306
+
307
+ function CatalogPanel(props: { runtime: WorkshopRuntime; mode: 'wide' | 'overlay' }) {
308
+ const s = useStore(props.runtime.store)
309
+ const groups = groupEntries(s.catalog, s.query)
310
+ return (
311
+ <aside className={`xw-panel xw-catalog ${props.mode === 'overlay' ? 'xw-overlay' : ''}`} aria-label="Story catalog">
312
+ <div className="xw-panel-head">
313
+ <input
314
+ type="search"
315
+ className="xw-search"
316
+ placeholder="Search stories"
317
+ aria-label="Search stories"
318
+ value={s.query}
319
+ onInput={(e: any) =>
320
+ props.runtime.store.update((x) => ({ ...x, query: e.target.value }))
321
+ }
322
+ />
323
+ {props.mode === 'overlay' ? (
324
+ <button
325
+ type="button"
326
+ className="xw-btn"
327
+ onClick={() => props.runtime.store.update((x) => ({ ...x, auxPanel: null }))}
328
+ >
329
+ Close
330
+ </button>
331
+ ) : null}
332
+ </div>
333
+ <div className="xw-panel-body" aria-label="Stories">
334
+ {catalogBody(s, props.runtime, groups)}
335
+ </div>
336
+ </aside>
337
+ )
338
+ }
339
+
340
+ // ---------- controls ----------
341
+
342
+ function currentDefinition(runtime: WorkshopRuntime): StoryDefinition<any, any, any> | undefined {
343
+ return runtime.session.currentStory
344
+ }
345
+
346
+ /** Honest per-target launch status — `xplat workshop doctor` is the
347
+ * authoritative machine check; this is the in-shell summary. */
348
+ function targetStatus(active: string, target: string): string {
349
+ if (target === active) {
350
+ return 'available (active host)'
351
+ }
352
+
353
+ switch (target) {
354
+ case 'web':
355
+ return 'available'
356
+ case 'linux':
357
+ return 'requires a Linux host'
358
+ case 'macos':
359
+ return 'requires a macOS arm64 host'
360
+ default:
361
+ return 'unqualified backend'
362
+ }
363
+ }
364
+
365
+ function ControlField(props: {
366
+ runtime: WorkshopRuntime
367
+ name: string
368
+ control: any
369
+ definition: StoryDefinition<any, any, any>
370
+ }) {
371
+ const s = useStore(props.runtime.store)
372
+ const store = props.runtime.store
373
+ const key = props.name
374
+ const control = props.control
375
+ const genProps = props.runtime.session.currentProps
376
+ const value = genProps?.[key]
377
+ const isSet = genProps ? key in genProps : false
378
+ const draft = s.drafts[key]
379
+ const error = s.fieldErrors[key]
380
+ const composing = useRef(false)
381
+
382
+ const commit = (raw: string | boolean | number | typeof UNSET) => {
383
+ const genProps = props.runtime.session.currentProps ?? {}
384
+ const result = applyControlInput(props.definition, genProps, key, raw)
385
+ if (result.error) {
386
+ store.update((x) => ({
387
+ ...x,
388
+ fieldErrors: { ...x.fieldErrors, [key]: result.error!.message },
389
+ }))
390
+
391
+ return
392
+ }
393
+
394
+ store.update((x) => {
395
+ const drafts = { ...x.drafts }
396
+ delete drafts[key]
397
+ const fieldErrors = { ...x.fieldErrors }
398
+ delete fieldErrors[key]
399
+ const unsetKeys = new Set(x.unsetKeys)
400
+ if (raw === UNSET) unsetKeys.add(key)
401
+ else unsetKeys.delete(key)
402
+
403
+ return { ...x, drafts, fieldErrors, unsetKeys }
404
+ })
405
+
406
+ props.runtime.session.applyProps(result.props!)
407
+ }
408
+
409
+ const setDraft = (text: string) =>
410
+ store.update((x) => ({ ...x, drafts: { ...x.drafts, [key]: text } }))
411
+
412
+ let field = null
413
+ if (control.type === 'boolean') {
414
+ field = (
415
+ <input
416
+ type="checkbox"
417
+ checked={Boolean(value)}
418
+ disabled={s.session.phase !== 'ready'}
419
+ aria-label={key}
420
+ onChange={(e: any) => commit(e.target.checked)}
421
+ />
422
+ )
423
+ } else if (control.type === 'enum') {
424
+ field = (
425
+ <select
426
+ value={isSet ? String(value) : ''}
427
+ disabled={s.session.phase !== 'ready'}
428
+ aria-label={key}
429
+ onChange={(e: any) => {
430
+ const raw = e.target.value
431
+ const option = (control.options ?? []).find((o: unknown) => String(o) === raw)
432
+ commit(option as never)
433
+ }}
434
+ >
435
+ {!isSet ? <option value="">(unset)</option> : null}
436
+ {(control.options ?? []).map((o: unknown) => (
437
+ <option key={String(o)} value={String(o)}>{String(o)}</option>
438
+ ))}
439
+ </select>
440
+ )
441
+ } else {
442
+ const shown = draft ?? (isSet ? String(value ?? '') : '')
443
+ field = (
444
+ <input
445
+ type={control.type === 'number' ? 'number' : 'text'}
446
+ value={shown}
447
+ placeholder={isSet ? undefined : '(unset)'}
448
+ aria-label={key}
449
+ aria-invalid={Boolean(error)}
450
+ min={control.min}
451
+ max={control.max}
452
+ step={control.step}
453
+ onCompositionStart={() => { composing.current = true }}
454
+ onCompositionEnd={(e: any) => { composing.current = false; setDraft(e.target.value) }}
455
+ onInput={(e: any) => setDraft(e.target.value)}
456
+ onKeyDown={(e: any) => {
457
+ if (e.key === 'Enter' && !e.isComposing && !composing.current) {
458
+ commit(e.target.value)
459
+ } else if (e.key === 'Escape' && !e.isComposing && !composing.current) {
460
+ setDraft(isSet ? String(value ?? '') : '')
461
+ e.target.blur()
462
+ }
463
+ }}
464
+ onBlur={(e: any) => {
465
+ if (composing.current) return
466
+ if ((draft ?? e.target.value) !== (isSet ? String(value ?? '') : '')) {
467
+ commit(draft ?? e.target.value)
468
+ }
469
+ }}
470
+ />
471
+ )
472
+ }
473
+
474
+ const unsettable = isUnsettable(props.definition, key)
475
+ return (
476
+ <div className="xw-field">
477
+ <div className="xw-field-row">
478
+ <label className="xw-field-label" title={key}>{key}</label>
479
+ {field}
480
+ {unsettable && isSet ? (
481
+ <button type="button" className="xw-btn xw-unset" onClick={() => commit(UNSET)}>
482
+ Unset
483
+ </button>
484
+ ) : null}
485
+ {!isSet && key in (props.definition.args ?? {}) ? (
486
+ <button
487
+ type="button"
488
+ className="xw-btn"
489
+ onClick={() => commit(props.definition.args[key])}
490
+ >
491
+ Default
492
+ </button>
493
+ ) : null}
494
+ </div>
495
+ {error ? <div className="xw-field-error" role="alert">{error}</div> : null}
496
+ </div>
497
+ )
498
+ }
499
+
500
+ function ControlsPanel(props: { runtime: WorkshopRuntime; mode: 'wide' | 'overlay' }) {
501
+ const s = useStore(props.runtime.store)
502
+ const definition = currentDefinition(props.runtime)
503
+ const controls = (definition?.controls ?? {}) as Record<string, unknown>
504
+ const keys = Object.keys(controls)
505
+ return (
506
+ <aside className={`xw-panel xw-controls ${props.mode === 'overlay' ? 'xw-overlay' : ''}`} aria-label="Story controls">
507
+ <div className="xw-panel-head">
508
+ <span className="xw-panel-title">Controls</span>
509
+ {props.mode === 'overlay' ? (
510
+ <button
511
+ type="button"
512
+ className="xw-btn"
513
+ onClick={() => props.runtime.store.update((x) => ({ ...x, auxPanel: null }))}
514
+ >
515
+ Close
516
+ </button>
517
+ ) : null}
518
+ </div>
519
+ <div className="xw-panel-body">
520
+ <fieldset className="xw-environment">
521
+ <legend>Stage environment</legend>
522
+ <label>Background <select aria-label="Stage background" value={s.stageBackground}
523
+ onChange={(e: any) => props.runtime.store.update(x => ({ ...x, stageBackground: e.target.value }))}>
524
+ <option value="default">Default</option><option value="light">Light</option><option value="dark">Dark</option>
525
+ </select></label>
526
+ <label>Bounds <select aria-label="Stage bounds" value={s.stageBounds}
527
+ onChange={(e: any) => props.runtime.store.update(x => ({ ...x, stageBounds: e.target.value }))}>
528
+ <option value="available">Available space</option><option value="small">320 × 240</option><option value="medium">640 × 480</option>
529
+ </select></label>
530
+ </fieldset>
531
+ {!definition ? (
532
+ <div className="xw-empty">Select a story to edit its declared controls.</div>
533
+ ) : keys.length === 0 ? (
534
+ <div className="xw-empty">This story declares no controls.</div>
535
+ ) : (
536
+ keys.map((key) => (
537
+ <ControlField
538
+ key={key}
539
+ runtime={props.runtime}
540
+ name={key}
541
+ control={controls[key]}
542
+ definition={definition}
543
+ />
544
+ ))
545
+ )}
546
+ </div>
547
+ </aside>
548
+ )
549
+ }
550
+
551
+ // ---------- stage ----------
552
+
553
+ function StageView(props: { runtime: WorkshopRuntime }) {
554
+ const s = useStore(props.runtime.store)
555
+ const session = s.session
556
+ const failure = session.failure
557
+ return (
558
+ <main className="xw-stage-area" data-workshop-stage>
559
+ <div
560
+ className={`xw-stage-slot xw-background-${s.stageBackground}`}
561
+ style={s.stageBounds === 'available' ? undefined : {
562
+ width: s.stageBounds === 'small' ? 320 : 640,
563
+ height: s.stageBounds === 'small' ? 240 : 480,
564
+ flex: 'none',
565
+ }}
566
+ ref={(el: HTMLElement | null) => {
567
+ if (el && props.runtime.mountHost.parentNode !== el) {
568
+ el.append(props.runtime.mountHost)
569
+ }
570
+ }}
571
+ />
572
+ {s.missingId ? (
573
+ <div className="xw-stage-note xw-error" role="alert">
574
+ <p>Story <code>{s.missingId}</code> was not found in the catalog.</p>
575
+ <button
576
+ type="button"
577
+ className="xw-btn"
578
+ onClick={() => {
579
+ const store = props.runtime.store
580
+ store.update((x) => ({ ...x, missingId: null, auxPanel: 'stories' }))
581
+ }}
582
+ >
583
+ Browse stories
584
+ </button>
585
+ </div>
586
+ ) : null}
587
+ {session.phase === 'preparing' || session.phase === 'mounting' || session.phase === 'disposing' ? (
588
+ <div className="xw-stage-note" role="status">
589
+ {session.phase === 'preparing' ? 'Preparing fixtures…' : session.phase === 'mounting' ? 'Mounting…' : 'Disposing…'}
590
+ </div>
591
+ ) : null}
592
+ {session.phase === 'rebuilding' && !s.missingId ? (
593
+ <div className="xw-stage-note xw-subtle" role="status">Rebuilding — remounting latest successful revision…</div>
594
+ ) : null}
595
+ {session.phase === 'failed' && failure ? (
596
+ <div className="xw-stage-note xw-error" role="alert">
597
+ <p className="xw-error-title">
598
+ {failure.phase} error{failure.storyId ? ` — ${failure.storyId}` : ''}
599
+ </p>
600
+ <pre className="xw-error-detail">{failure.message}</pre>
601
+ {failure.cause !== undefined ? (
602
+ <pre className="xw-error-detail">{describeCause(failure.cause)}</pre>
603
+ ) : null}
604
+ <div className="xw-error-actions">
605
+ {failure.phase === 'fixture' ? (
606
+ <button type="button" className="xw-btn" onClick={() => void props.runtime.session.reset()}>
607
+ Retry
608
+ </button>
609
+ ) : null}
610
+ <button type="button" className="xw-btn" onClick={() => { clearEdits(props.runtime); void props.runtime.session.reset() }}>
611
+ Reset
612
+ </button>
613
+ </div>
614
+ {session.stale ? <p className="xw-subtle">The last successful stage remains mounted and is marked stale.</p> : null}
615
+ </div>
616
+ ) : null}
617
+ {session.phase === 'restart-required' ? (
618
+ <div className="xw-stage-note xw-error" role="alert">
619
+ <p className="xw-error-title">Restart required</p>
620
+ <pre className="xw-error-detail">{failure?.message ?? 'Owned cleanup did not finish in time.'}</pre>
621
+ <p className="xw-subtle">Restart the workshop target (`xplat workshop dev`) to continue.</p>
622
+ </div>
623
+ ) : null}
624
+ {session.phase === 'disconnected' ? (
625
+ <div className="xw-stage-note" role="status">Dev server disconnected — reconnecting…</div>
626
+ ) : null}
627
+ {session.stale && session.phase !== 'failed' ? (
628
+ <div className="xw-stale-badge" role="status">stale</div>
629
+ ) : null}
630
+ </main>
631
+ )
632
+ }
633
+
634
+ // ---------- app ----------
635
+
636
+ export function WorkshopApp(props: { runtime: WorkshopRuntime }) {
637
+ const s = useStore(props.runtime.store)
638
+ const openerRef = useRef<HTMLElement | null>(null)
639
+ const openerKeyRef = useRef<string | null>(null)
640
+ const observerRef = useRef<ResizeObserver | null>(null)
641
+
642
+ const attachRoot = (el: HTMLDivElement | null) => {
643
+ observerRef.current?.disconnect()
644
+ observerRef.current = null
645
+ if (el) {
646
+ observerRef.current = observeMode(el, props.runtime.store)
647
+ const mode = modeFor(el.clientWidth, el.clientHeight)
648
+ if (props.runtime.store.getSnapshot().mode !== mode) {
649
+ props.runtime.store.update((x) => ({ ...x, mode }))
650
+ }
651
+ }
652
+ }
653
+
654
+ useLayoutEffect(() => () => observerRef.current?.disconnect(), [])
655
+
656
+ // Escape dismisses the auxiliary panel; focus returns to its opener. A
657
+ // story-owned dialog inside the stage keeps its own Escape handling.
658
+ useEffect(() => {
659
+ const onKeyDown = (e: KeyboardEvent) => {
660
+ if (e.key !== 'Escape' || e.isComposing) {
661
+ return
662
+ }
663
+
664
+ const state = props.runtime.store.getSnapshot()
665
+ if (state.stageOnly) {
666
+ props.runtime.store.update((x) => ({ ...x, stageOnly: false }))
667
+ return
668
+ }
669
+
670
+ if (!state.auxPanel) {
671
+ return
672
+ }
673
+
674
+ const active = document.activeElement
675
+ if (
676
+ active instanceof Element &&
677
+ active.closest('[data-workshop-stage] [role="dialog"], [data-workshop-stage] [role="alertdialog"]')
678
+ ) {
679
+ return
680
+ }
681
+
682
+ props.runtime.store.update((x) => ({ ...x, auxPanel: null }))
683
+ }
684
+
685
+ document.addEventListener('keydown', onKeyDown)
686
+ return () => document.removeEventListener('keydown', onKeyDown)
687
+ }, [])
688
+
689
+ // Restore focus to the opener when an overlay panel closes.
690
+ const prevAux = useRef<ShellState['auxPanel']>(null)
691
+ useEffect(() => {
692
+ const prev = prevAux.current
693
+ prevAux.current = s.auxPanel
694
+ if (!prev && s.auxPanel) {
695
+ openerRef.current = document.activeElement as HTMLElement | null
696
+ openerKeyRef.current = s.auxPanel
697
+ requestAnimationFrame(() => {
698
+ if (props.runtime.store.getSnapshot().auxPanel) {
699
+ document.querySelector<HTMLElement>('.xw-overlay input, .xw-overlay button')?.focus()
700
+ }
701
+ })
702
+ }
703
+
704
+ if (prev && !s.auxPanel && openerRef.current) {
705
+ const opener = openerRef.current
706
+ const openerKey = openerKeyRef.current
707
+ openerRef.current = null
708
+ requestAnimationFrame(() => {
709
+ const key = opener.dataset.workshopOpener ?? openerKey
710
+ const current = key ? document.querySelector<HTMLElement>(`[data-workshop-opener="${key}"]`) : opener
711
+ current?.focus()
712
+ })
713
+ }
714
+ }, [s.auxPanel])
715
+
716
+ if (s.stageOnly) {
717
+ return (
718
+ <div className="xw-root xw-stageonly" ref={attachRoot}>
719
+ <StageView runtime={props.runtime} />
720
+ <div className="xw-stageonly-bar">
721
+ <button
722
+ type="button"
723
+ className="xw-btn"
724
+ onClick={() => props.runtime.store.update((x) => ({ ...x, stageOnly: false }))}
725
+ >
726
+ Workshop
727
+ </button>
728
+ <button
729
+ type="button"
730
+ className="xw-btn"
731
+ onClick={() => { clearEdits(props.runtime); void props.runtime.session.reset() }}
732
+ >
733
+ Reset
734
+ </button>
735
+ </div>
736
+ </div>
737
+ )
738
+ }
739
+
740
+ return (
741
+ <div className={`xw-root xw-${s.mode}`} ref={attachRoot}>
742
+ <StatusBar runtime={props.runtime} />
743
+ <div className="xw-body">
744
+ {s.mode === 'wide' && !s.catalogCollapsed ? <CatalogPanel runtime={props.runtime} mode="wide" /> : null}
745
+ <StageView runtime={props.runtime} />
746
+ {s.mode === 'wide' && !s.controlsCollapsed ? <ControlsPanel runtime={props.runtime} mode="wide" /> : null}
747
+ {s.mode !== 'wide' && s.auxPanel === 'stories' ? (
748
+ <CatalogPanel runtime={props.runtime} mode="overlay" />
749
+ ) : null}
750
+ {s.mode !== 'wide' && s.auxPanel === 'controls' ? (
751
+ <ControlsPanel runtime={props.runtime} mode="overlay" />
752
+ ) : null}
753
+ </div>
754
+ <div className="xw-live" aria-live="polite" role="status">
755
+ {s.session.phase === 'failed' ? 'Error: ' + String(s.session.failure?.message ?? '') : ''}
756
+ </div>
757
+ </div>
758
+ )
759
+ }