@octane-xplat/workshop 0.1.0 → 0.16.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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/MOBILE.md +143 -0
  3. package/README.md +305 -1
  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 +118 -5
  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,607 @@
1
+ import {
2
+ Application,
3
+ AndroidApplication,
4
+ Button,
5
+ ContentView,
6
+ Frame,
7
+ GridLayout,
8
+ Label,
9
+ Page,
10
+ ScrollView,
11
+ StackLayout,
12
+ TextField,
13
+ View,
14
+ } from '@nativescript/core'
15
+
16
+ import type { CatalogEntry } from '../catalog'
17
+ import type { WorkshopTarget } from '../contract'
18
+ import { UNSET, isUnsettable } from '../controls'
19
+ import { WorkshopSession } from '../session'
20
+ import { MobileController, mobileShellMode } from './controller'
21
+ import { createMobileStageHost } from './mount.mobile'
22
+ import { android, androidx } from '@octane-xplat/platform/android'
23
+ import {
24
+ UIKeyCommand,
25
+ UIKeyModifierFlags,
26
+ UIMenuElementAttributes,
27
+ UIViewController,
28
+ } from '@octane-xplat/platform/ios'
29
+
30
+ export interface MobileWorkshopOptions {
31
+ catalog: readonly CatalogEntry[]
32
+ diagnostics?: readonly { message: string }[]
33
+ target: 'ios' | 'android'
34
+ initialStoryId?: string | null
35
+ globalProviders?: readonly unknown[]
36
+ }
37
+
38
+ export interface MobileWorkshopRuntime {
39
+ controller: MobileController
40
+ page: Page
41
+ dispose(): Promise<void>
42
+ }
43
+
44
+ let runtime: MobileWorkshopRuntime | undefined
45
+ export function catalogDidUpdate(
46
+ catalog: readonly CatalogEntry[],
47
+ diagnostics: readonly { message: string }[] = [],
48
+ ): void {
49
+ runtime?.controller.updateCatalog(catalog, diagnostics)
50
+ }
51
+
52
+ const CSS = `
53
+ .xw-toolbar { padding: 8; background-color: #eeeeee; }
54
+ .xw-panel { padding: 12; background-color: #f5f5f5; }
55
+ .xw-status { padding: 8; }
56
+ .xw-error { color: #a01818; }
57
+ .xw-action { margin: 4; min-height: 44; }
58
+ .xw-action:highlighted { background-color: #cdddf0; }
59
+ .xw-action:focus { border-color: #175cd3; border-width: 2; }
60
+ .xw-field { margin-bottom: 8; min-height: 44; }
61
+ `
62
+
63
+ /** A host-native shell. Its stage ContentView is attached once for its lifetime. */
64
+ export function startWorkshop(options: MobileWorkshopOptions): MobileWorkshopRuntime {
65
+ const actual: WorkshopTarget = Application.android ? 'android' : 'ios'
66
+ if (actual !== options.target) {
67
+ throw new Error(`Workshop target ${options.target} does not match host ${actual}`)
68
+ }
69
+
70
+ if (runtime) {
71
+ throw new Error('Dispose the existing workshop before starting another')
72
+ }
73
+
74
+ const page = new Page()
75
+ page.actionBarHidden = true
76
+ page.css = CSS
77
+ page.iosOverflowSafeArea = false
78
+ const root = new GridLayout()
79
+ root.rows = 'auto,auto,*'
80
+ const toolbar = new GridLayout()
81
+ toolbar.columns = '*,*,*,*'
82
+ toolbar.className = 'xw-toolbar'
83
+ append(root, toolbar)
84
+ const status = new Label()
85
+ status.textWrap = true
86
+ status.className = 'xw-status'
87
+ GridLayout.setRow(status as unknown as View, 1)
88
+ append(root, status)
89
+ const body = new GridLayout()
90
+ GridLayout.setRow(body as unknown as View, 2)
91
+ append(root, body)
92
+ const stage = new ContentView()
93
+ append(body, stage)
94
+ const catalog = new ScrollView()
95
+ catalog.className = 'xw-panel'
96
+ append(body, catalog)
97
+ const controls = new ScrollView()
98
+ controls.className = 'xw-panel'
99
+ append(body, controls)
100
+ page.content = root
101
+ const session = new WorkshopSession({
102
+ host: createMobileStageHost(stage),
103
+ target: actual,
104
+ globalProviders: options.globalProviders,
105
+ })
106
+
107
+ let opener: Pick<View, 'focus'> | undefined
108
+ let lastRevision = -1
109
+ let catalogCollapsed = false
110
+ let controlsCollapsed = false
111
+ const collapsedGroups = new Set<string>()
112
+ let disposed = false
113
+ let syncEscapeCommand: (() => void) | undefined
114
+
115
+ // NativeScript's symbol setter index signatures make valid subclasses
116
+ // structurally incompatible under strictFunctionTypes; constrain the
117
+ // cast to this native tree insertion boundary.
118
+ function append(
119
+ parent: GridLayout | StackLayout,
120
+ child: GridLayout | StackLayout | Label | Button | ScrollView | ContentView | TextField,
121
+ ): void {
122
+ parent.addChild(child as unknown as View)
123
+ }
124
+
125
+ function label(text: string): Label {
126
+ const view = new Label()
127
+ view.text = text
128
+ view.textWrap = true
129
+ view.accessibilityLabel = text
130
+ return view
131
+ }
132
+
133
+ function action(parent: StackLayout | GridLayout, text: string, run: () => void): Button {
134
+ const button = new Button()
135
+ button.text = text
136
+ button.accessibilityLabel = text
137
+ button.className = 'xw-action'
138
+ button.on(Button.tapEvent, run)
139
+ if (parent === toolbar) {
140
+ GridLayout.setColumn(button as unknown as View, toolbar.getChildrenCount())
141
+ }
142
+
143
+ append(parent, button)
144
+ return button
145
+ }
146
+
147
+ function closePanel(): void {
148
+ controller.panel = null
149
+ layout()
150
+ opener?.focus()
151
+ }
152
+
153
+ /** Host keys may dismiss shell chrome only while this page owns the host. */
154
+ function dismissAllowed(): boolean {
155
+ return Frame.topmost()?.currentPage === page && !page.modal
156
+ }
157
+
158
+ /** One Back/Escape step; false leaves the key to the host/story. */
159
+ function tryDismiss(): boolean {
160
+ const dismissed = controller.dismissOverlay()
161
+ if (!dismissed) {
162
+ return false
163
+ }
164
+
165
+ layout()
166
+ if (dismissed === 'panel') {
167
+ opener?.focus()
168
+ }
169
+
170
+ return true
171
+ }
172
+
173
+ function toggle(panel: 'stories' | 'controls', button: Button): void {
174
+ opener = button
175
+ if (mobileShellMode(root.getActualSize().width, root.getActualSize().height) === 'wide') {
176
+ if (panel === 'stories') {
177
+ catalogCollapsed = !catalogCollapsed
178
+ } else {
179
+ controlsCollapsed = !controlsCollapsed
180
+ }
181
+ } else {
182
+ controller.panel = controller.panel === panel ? null : panel
183
+ }
184
+
185
+ layout()
186
+ }
187
+
188
+ function layout(): void {
189
+ const size = root.getActualSize()
190
+ storiesButton.visibility = controller.stageOnly ? 'collapse' : 'visible'
191
+ controlsButton.visibility = controller.stageOnly ? 'collapse' : 'visible'
192
+ toolbar.columns = controller.stageOnly ? '0,0,*,*' : '*,*,*,*'
193
+ const wide = mobileShellMode(size.width, size.height) === 'wide' && !controller.stageOnly
194
+ body.columns = wide ? `${catalogCollapsed ? 0 : 240},*,${controlsCollapsed ? 0 : 280}` : '*,0,0'
195
+ GridLayout.setColumn(stage as unknown as View, wide ? 1 : 0)
196
+ GridLayout.setColumn(catalog as unknown as View, wide ? 0 : 1)
197
+ GridLayout.setColumn(controls as unknown as View, 2)
198
+ catalog.visibility = wide && !catalogCollapsed ? 'visible' : 'collapse'
199
+ controls.visibility = wide && !controlsCollapsed ? 'visible' : 'collapse'
200
+ stage.visibility = 'visible'
201
+ if (!wide && controller.panel) {
202
+ const active = controller.panel === 'stories' ? catalog : controls
203
+ GridLayout.setColumn(active as unknown as View, 0)
204
+ active.visibility = 'visible'
205
+ stage.visibility = 'hidden'
206
+ }
207
+
208
+ syncEscapeCommand?.()
209
+ }
210
+
211
+ function renderCatalog(): void {
212
+ const content = new StackLayout()
213
+ action(content, 'Close Stories', () => {
214
+ catalogCollapsed = true
215
+ closePanel()
216
+ })
217
+
218
+ const search = new TextField()
219
+ search.hint = 'Find a story by title or ID'
220
+ search.accessibilityLabel = 'Search stories'
221
+ search.text = controller.query
222
+ search.className = 'xw-field'
223
+ const results = new StackLayout()
224
+ function fill(): void {
225
+ results.removeChildren()
226
+ const query = controller.query.toLowerCase()
227
+ const entries = [...controller.catalog].sort(
228
+ (a, b) => a.title.localeCompare(b.title) || a.id.localeCompare(b.id),
229
+ )
230
+
231
+ function group(prefix: string, items: readonly CatalogEntry[]): void {
232
+ const groups = new Map<string, CatalogEntry[]>()
233
+ for (const entry of items) {
234
+ const depth = prefix ? prefix.split('/').length : 0
235
+ const tail = entry.title
236
+ .split('/')
237
+ .map((part) => part.trim())
238
+ .filter(Boolean)
239
+ .slice(depth)
240
+ .join(' / ')
241
+
242
+ const separator = tail.indexOf('/')
243
+ if (separator < 0) {
244
+ action(
245
+ results,
246
+ `${entry.title} (${entry.id})${entry.applicable ? '' : ' — unsupported-by-story'}`,
247
+ () => {
248
+ void controller.select(entry.id)
249
+ },
250
+ )
251
+ } else {
252
+ const name = `${prefix}${prefix ? ' / ' : ''}${tail.slice(0, separator).trim()}`
253
+ const members = groups.get(name) ?? []
254
+ members.push(entry)
255
+ groups.set(name, members)
256
+ }
257
+ }
258
+
259
+ for (const [name, members] of groups) {
260
+ action(results, `${collapsedGroups.has(name) ? 'Expand' : 'Collapse'} ${name}`, () => {
261
+ if (collapsedGroups.has(name)) {
262
+ collapsedGroups.delete(name)
263
+ } else {
264
+ collapsedGroups.add(name)
265
+ }
266
+
267
+ fill()
268
+ })
269
+
270
+ if (query || !collapsedGroups.has(name)) {
271
+ group(name, members)
272
+ }
273
+ }
274
+ }
275
+
276
+ group(
277
+ '',
278
+ entries.filter((e) => `${e.title} ${e.id}`.toLowerCase().includes(query)),
279
+ )
280
+
281
+ if (!entries.length) {
282
+ append(
283
+ results,
284
+ label(
285
+ 'No stories. Add src/counter.stories.tsrx with an exported defineStory declaration.',
286
+ ),
287
+ )
288
+ }
289
+ }
290
+
291
+ search.on(TextField.textChangeEvent, () => {
292
+ controller.query = search.text
293
+ fill()
294
+ })
295
+
296
+ append(content, search)
297
+ append(content, results)
298
+ fill()
299
+ catalog.content = content
300
+ }
301
+
302
+ function renderControls(): void {
303
+ const content = new StackLayout()
304
+ action(content, 'Close Controls', () => {
305
+ controlsCollapsed = true
306
+ closePanel()
307
+ })
308
+
309
+ const story = session.currentStory
310
+ if (story) {
311
+ append(
312
+ content,
313
+ label(
314
+ story.capabilities?.length
315
+ ? `Required capabilities: ${story.capabilities.join(', ')}. Permissions are not granted by selection.`
316
+ : 'Local story',
317
+ ),
318
+ )
319
+
320
+ for (const [key, control] of Object.entries(story.controls ?? {})) {
321
+ const spec = control as { type: string; options?: readonly (string | number | boolean)[] }
322
+ const applied = label(`${key}: ${String(session.currentProps?.[key] ?? 'Unset')}`)
323
+ append(content, applied)
324
+ const error = label('')
325
+ error.className = 'xw-error'
326
+ const commit = (value: string | number | boolean | typeof UNSET) => {
327
+ error.text = controller.control(key, value) ?? ''
328
+ error.accessibilityLabel = error.text
329
+ if (!error.text) {
330
+ applied.text = `${key}: ${String(session.currentProps?.[key] ?? 'Unset')}`
331
+ }
332
+ }
333
+
334
+ if (spec.type === 'boolean') {
335
+ action(content, `${key}: true`, () => commit(true))
336
+ action(content, `${key}: false`, () => commit(false))
337
+ } else if (spec.type === 'enum') {
338
+ for (const option of spec.options ?? []) {
339
+ action(content, `${key}: ${String(option)}`, () => commit(option))
340
+ }
341
+ } else {
342
+ const field = new TextField()
343
+ field.text = String(session.currentProps?.[key] ?? '')
344
+ field.accessibilityLabel = key
345
+ field.className = 'xw-field'
346
+ // Apply explicitly: composition/drafts remain owned by the field,
347
+ // and invalid numbers never reach the stage.
348
+ append(content, field)
349
+ action(content, `Apply ${key}`, () => commit(field.text))
350
+ }
351
+
352
+ if (isUnsettable(story, key)) {
353
+ action(content, `Unset ${key}`, () => commit(UNSET))
354
+ }
355
+
356
+ append(content, error)
357
+ }
358
+ }
359
+
360
+ append(content, label('Targets — launch another actual host from the CLI'))
361
+ for (const target of ['web', 'ios', 'android', 'macos', 'windows', 'linux'] as const) {
362
+ const selected = controller.catalog.find((e) => e.id === controller.selectedId)
363
+ append(
364
+ content,
365
+ label(
366
+ `${target}: ${selected && !selected.targets.includes(target) ? 'unsupported-by-story' : 'unqualified backend / separate launch'}${target === actual ? ' (current host)' : ''}`,
367
+ ),
368
+ )
369
+ }
370
+
371
+ append(content, label('Stage settings'))
372
+ action(content, 'Default background', () => {
373
+ stage.backgroundColor = '#ffffff' as any
374
+ })
375
+
376
+ action(content, 'Dark background', () => {
377
+ stage.backgroundColor = '#242424' as any
378
+ })
379
+
380
+ action(content, 'Available stage size', () => {
381
+ stage.width = 'auto'
382
+ stage.height = 'auto'
383
+ })
384
+
385
+ action(content, 'Bounded stage size: 320 × 400', () => {
386
+ stage.width = 320
387
+ stage.height = 400
388
+ })
389
+
390
+ controls.content = content
391
+ }
392
+
393
+ function refresh(): void {
394
+ if (disposed) {
395
+ return
396
+ }
397
+
398
+ const snapshot = session.state
399
+ const entry = controller.catalog.find((e) => e.id === controller.selectedId)
400
+ status.text = `${actual} · unqualified backend · ${controller.missingId ? `Missing story: ${controller.missingId}` : entry && !entry.applicable ? `${entry.id}: unsupported-by-story` : (snapshot.storyId ?? 'Choose a story')} · ${snapshot.phase}${snapshot.stale ? ' · STALE' : ''}${snapshot.failure ? ` · ${snapshot.failure.phase}: ${snapshot.failure.message}` : ''}`
401
+ status.accessibilityLabel = status.text
402
+ if (lastRevision !== snapshot.revision) {
403
+ lastRevision = snapshot.revision
404
+ renderControls()
405
+ }
406
+
407
+ layout()
408
+ }
409
+
410
+ const controller = new MobileController(session, options.catalog, () => {
411
+ renderCatalog()
412
+ refresh()
413
+ })
414
+
415
+ const storiesButton = action(toolbar, 'Stories', () => toggle('stories', storiesButton))
416
+ const controlsButton = action(toolbar, 'Controls', () => toggle('controls', controlsButton))
417
+ action(toolbar, 'Reset', () => {
418
+ void session.reset()
419
+ })
420
+
421
+ action(toolbar, 'Stage / Return', () => {
422
+ controller.stageOnly = !controller.stageOnly
423
+ controller.panel = null
424
+ layout()
425
+ })
426
+
427
+ const unsubscribe = session.subscribe(refresh)
428
+ root.on(View.layoutChangedEvent, layout)
429
+ const back = (event: any) => {
430
+ // A story-owned modal keeps OS Back. Shell dismissal takes precedence
431
+ // only on this page, never in an unrelated navigation root.
432
+ if (dismissAllowed() && tryDismiss()) {
433
+ event.cancel = true
434
+ }
435
+ }
436
+
437
+ Application.android?.on(AndroidApplication.activityBackPressedEvent, back)
438
+
439
+ // Android hardware Escape: unhandled key events bubble from the focused
440
+ // view to its ancestors, so a story view that consumes Escape keeps it and
441
+ // IME composition is handled before dispatch. Consuming the down and its
442
+ // paired up prevents any host-level Escape-to-back translation when the
443
+ // shell dismissed. The listener lives on this page's root view only.
444
+ function installAndroidDismissal(): () => void {
445
+ const ViewCompat = androidx?.core?.view?.ViewCompat
446
+ if (typeof ViewCompat?.addOnUnhandledKeyEventListener !== 'function') {
447
+ return () => {}
448
+ }
449
+
450
+ let attached: android.view.View | undefined
451
+ let escapeDown = false
452
+ const listener = new androidx.core.view.ViewCompat.OnUnhandledKeyEventListenerCompat({
453
+ onUnhandledKeyEvent(_view: android.view.View, event: android.view.KeyEvent): boolean {
454
+ if (event.getKeyCode() !== android.view.KeyEvent.KEYCODE_ESCAPE) {
455
+ return false
456
+ }
457
+
458
+ const action = event.getAction()
459
+ if (action === android.view.KeyEvent.ACTION_DOWN) {
460
+ // Repeats only dispatch while we hold the press.
461
+ if (event.getRepeatCount() > 0) {
462
+ return escapeDown
463
+ }
464
+
465
+ escapeDown = dismissAllowed() && tryDismiss()
466
+ return escapeDown
467
+ }
468
+
469
+ if (action === android.view.KeyEvent.ACTION_UP) {
470
+ const handled = escapeDown
471
+ escapeDown = false
472
+ return handled
473
+ }
474
+
475
+ return escapeDown
476
+ },
477
+ })
478
+
479
+ const attach = () => {
480
+ const native = root.android as android.view.View | undefined
481
+ if (!native || attached === native) {
482
+ return
483
+ }
484
+
485
+ ViewCompat.addOnUnhandledKeyEventListener(native, listener)
486
+ attached = native
487
+ }
488
+
489
+ root.on(View.loadedEvent, attach)
490
+ if (root.isLoaded) {
491
+ attach()
492
+ }
493
+
494
+ return () => {
495
+ root.off(View.loadedEvent, attach)
496
+ if (attached) {
497
+ ViewCompat.removeOnUnhandledKeyEventListener(attached, listener)
498
+ attached = undefined
499
+ }
500
+ }
501
+ }
502
+
503
+ // iOS hardware Escape: a disabled-by-default key command on the page view
504
+ // controller resolves through the responder chain to the page's supported
505
+ // escape hook, so story views nearer the first responder and presented
506
+ // story modals keep priority. Enabling it only while a shell dismissal is
507
+ // available leaves the key (and marked-text composition) with the story.
508
+ // Hosts without UIViewController.addKeyCommand (iOS 15) keep the toolbar
509
+ // and accessibility-escape paths only.
510
+ function installIosDismissal(): () => void {
511
+ const vc = (page as any).ios as UIViewController | undefined
512
+ if (!vc?.respondsToSelector?.('addKeyCommand:')) {
513
+ return () => {}
514
+ }
515
+
516
+ const command = UIKeyCommand.keyCommandWithInputModifierFlagsAction(
517
+ UIKeyInputEscape,
518
+ 0 as UIKeyModifierFlags,
519
+ 'accessibilityPerformEscape',
520
+ )
521
+
522
+ const previous = page.onAccessibilityPerformEscape
523
+ // The VoiceOver escape gesture and the key command reach the same
524
+ // hook; both apply shell dismissal before any earlier handler.
525
+ page.onAccessibilityPerformEscape = () => {
526
+ if (dismissAllowed() && tryDismiss()) {
527
+ return true
528
+ }
529
+
530
+ return previous?.() ?? false
531
+ }
532
+
533
+ vc.addKeyCommand(command)
534
+ // UIMenuElementAttributes.Disabled is an ambient const enum, which this
535
+ // configuration reads only in type position.
536
+ syncEscapeCommand = () => {
537
+ command.attributes =
538
+ controller.panel || controller.stageOnly
539
+ ? (0 as UIMenuElementAttributes)
540
+ : (1 as UIMenuElementAttributes)
541
+ }
542
+
543
+ syncEscapeCommand()
544
+ return () => {
545
+ syncEscapeCommand = undefined
546
+ page.onAccessibilityPerformEscape = previous
547
+ vc.removeKeyCommand(command)
548
+ }
549
+ }
550
+
551
+ const releaseDismissal = Application.ios
552
+ ? installIosDismissal()
553
+ : Application.android
554
+ ? installAndroidDismissal()
555
+ : () => {}
556
+
557
+ const exit = () => {
558
+ void runtime
559
+ ?.dispose()
560
+ .catch((error) => console.error('Workshop cleanup requires restart:', error))
561
+ }
562
+
563
+ Application.on(Application.exitEvent, exit)
564
+ runtime = {
565
+ controller,
566
+ page,
567
+ async dispose() {
568
+ if (disposed) {
569
+ return
570
+ }
571
+
572
+ disposed = true
573
+ unsubscribe()
574
+ root.off(View.layoutChangedEvent, layout)
575
+ releaseDismissal()
576
+ Application.android?.off(AndroidApplication.activityBackPressedEvent, back)
577
+ Application.off(Application.exitEvent, exit)
578
+ await controller.dispose()
579
+ if (session.state.phase === 'restart-required') {
580
+ throw session.state.failure
581
+ }
582
+
583
+ runtime = undefined
584
+ },
585
+ }
586
+
587
+ renderCatalog()
588
+ renderControls()
589
+ const frame = new Frame()
590
+ frame.navigate({ create: () => page })
591
+ const entryPoint = { create: () => frame }
592
+ if (Application.started) {
593
+ Application.resetRootView(entryPoint)
594
+ } else {
595
+ Application.run(entryPoint)
596
+ }
597
+
598
+ if (options.diagnostics?.length) {
599
+ controller.updateCatalog(options.catalog, options.diagnostics)
600
+ } else if (options.initialStoryId) {
601
+ void controller.select(options.initialStoryId)
602
+ } else {
603
+ controller.panel = 'stories'
604
+ }
605
+
606
+ return runtime
607
+ }
@@ -0,0 +1,3 @@
1
+ export { startWorkshop, catalogDidUpdate } from './entry.mobile'
2
+ export type { MobileWorkshopOptions, MobileWorkshopRuntime } from './entry.mobile'
3
+ export { createMobileStageHost } from './mount.mobile'
@@ -0,0 +1,60 @@
1
+ import { createNativeScriptRoot, type UniversalComponent } from '@nativescript-community/octane'
2
+ import { composeStory } from 'storybook/preview-api'
3
+ import type { ContentView } from '@nativescript/core'
4
+ import type { StageHost, MountRequest } from '../session'
5
+ import { StoryTree } from './tree.mobile.tsrx'
6
+
7
+ /** Owns only this stage root; never detaches a runner or application host. */
8
+ export function createMobileStageHost(stage: ContentView): StageHost {
9
+ return {
10
+ mount(request: MountRequest) {
11
+ // Match the pinned portable composition subset used by the web and
12
+ // private native harness; fixtures/providers remain session-owned.
13
+ const composed = composeStory(
14
+ { args: request.props },
15
+ { title: request.story.title, component: request.component } as any,
16
+ { render: (props: any) => ({ ...request, props }) } as any,
17
+ {},
18
+ 'Story',
19
+ )
20
+
21
+ const prepare = (props: Record<string, unknown>): MountRequest => ({
22
+ ...(composed(props) as unknown as MountRequest),
23
+ // The session owns a complete props bag. Composition must not
24
+ // clone injected stores/fixtures or restore a removed default.
25
+ props,
26
+ })
27
+
28
+ const root = createNativeScriptRoot(stage)
29
+ try {
30
+ root.render(StoryTree as unknown as UniversalComponent, { request: prepare(request.props) })
31
+ } catch (error) {
32
+ root.unmount()
33
+ throw error
34
+ }
35
+
36
+ let disposed = false
37
+ return {
38
+ update(props: Record<string, unknown>) {
39
+ if (disposed) {
40
+ return
41
+ }
42
+
43
+ try {
44
+ root.render(StoryTree as unknown as UniversalComponent, { request: prepare(props) })
45
+ } catch (error) {
46
+ request.onRenderError(error)
47
+ }
48
+ },
49
+ unmount() {
50
+ if (disposed) {
51
+ return
52
+ }
53
+
54
+ disposed = true
55
+ root.unmount()
56
+ },
57
+ }
58
+ },
59
+ }
60
+ }
@@ -0,0 +1,17 @@
1
+ /** @jsxImportSource @nativescript-community/octane */
2
+ import type { MountRequest } from '../session'
3
+
4
+ function Providers(p: { request: MountRequest; index: number }) {
5
+ const provider = p.request.providers[p.index] as ((props: any) => unknown) | undefined
6
+ if (!provider) return <{p.request.component} {...p.request.props} />
7
+ return <{provider} fixture={p.request.fixture}><Providers request={p.request} index={p.index + 1} /></{provider}>
8
+ }
9
+
10
+ export function StoryTree(p: { request: MountRequest }) @{
11
+ @try {
12
+ <Providers request={p.request} index={0} />
13
+ } @catch (error) {
14
+ queueMicrotask(() => p.request.onRenderError(error))
15
+ <label text="Story failed; use Reset or Stories." />
16
+ }
17
+ }