@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.
- package/LICENSE +21 -0
- package/MOBILE.md +143 -0
- package/README.md +305 -1
- package/dist/node/index.js +50 -0
- package/package.json +118 -5
- package/src/catalog.ts +58 -0
- package/src/contract.ts +257 -0
- package/src/controls.ts +159 -0
- package/src/discover.mjs +584 -0
- package/src/errors.ts +47 -0
- package/src/fixture.ts +158 -0
- package/src/index.ts +6 -0
- package/src/macos/controller.ts +170 -0
- package/src/macos/index.macos.ts +2 -0
- package/src/macos/shell.macos.mjs +673 -0
- package/src/macos/stage.macos.ts +182 -0
- package/src/macos-node.mjs +95 -0
- package/src/macos-vite.mjs +47 -0
- package/src/mobile/controller.ts +134 -0
- package/src/mobile/entry.mobile.ts +607 -0
- package/src/mobile/index.mobile.ts +3 -0
- package/src/mobile/mount.mobile.ts +60 -0
- package/src/mobile/tree.mobile.tsrx +17 -0
- package/src/mobile-node.mjs +126 -0
- package/src/mobile-regex.mjs +60 -0
- package/src/node.d.ts +65 -0
- package/src/node.mjs +536 -0
- package/src/session.ts +520 -0
- package/src/vite-mobile.d.ts +21 -0
- package/src/vite-mobile.mjs +101 -0
- package/src/vite.d.ts +11 -0
- package/src/vite.mjs +315 -0
- package/src/web/entry.web.ts +270 -0
- package/src/web/index.web.ts +7 -0
- package/src/web/mount.web.ts +86 -0
- package/src/web/shell.css +449 -0
- package/src/web/shell.web.tsrx +759 -0
- package/src/windows/preflight.mjs +113 -0
- package/types/generated/.tsrx-typegen-manifest.json +24 -0
- package/types/generated/catalog.d.ts +30 -0
- package/types/generated/contract.d.ts +145 -0
- package/types/generated/controls.d.ts +28 -0
- package/types/generated/errors.d.ts +13 -0
- package/types/generated/fixture.d.ts +53 -0
- package/types/generated/index.d.ts +6 -0
- package/types/generated/macos/controller.d.ts +52 -0
- package/types/generated/macos/index.macos.d.ts +2 -0
- package/types/generated/macos/stage.macos.d.ts +10 -0
- package/types/generated/mobile/controller.d.ts +31 -0
- package/types/generated/mobile/entry.mobile.d.ts +22 -0
- package/types/generated/mobile/index.mobile.d.ts +3 -0
- package/types/generated/mobile/mount.mobile.d.ts +4 -0
- package/types/generated/mobile/tree.mobile.d.ts +4 -0
- package/types/generated/session.d.ts +84 -0
- package/types/generated/web/entry.web.d.ts +53 -0
- package/types/generated/web/index.web.d.ts +7 -0
- package/types/generated/web/mount.web.d.ts +2 -0
- 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
|
+
}
|