sh3-core 0.11.8 → 0.13.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 (104) hide show
  1. package/dist/__test__/reset.js +2 -0
  2. package/dist/actions/MenuButton.svelte +2 -1
  3. package/dist/actions/contextMenuModel.js +8 -0
  4. package/dist/actions/contextMenuModel.test.js +22 -2
  5. package/dist/actions/listeners.js +28 -2
  6. package/dist/actions/listeners.test.js +87 -1
  7. package/dist/actions/scope-helpers.d.ts +17 -0
  8. package/dist/actions/scope-helpers.js +37 -0
  9. package/dist/actions/scope-helpers.test.js +33 -1
  10. package/dist/api.d.ts +18 -1
  11. package/dist/api.js +15 -1
  12. package/dist/app/store/InstalledView.svelte +2 -1
  13. package/dist/app/store/StoreView.svelte +2 -1
  14. package/dist/apps/lifecycle.d.ts +7 -0
  15. package/dist/apps/lifecycle.js +25 -5
  16. package/dist/apps/lifecycle.test.js +95 -0
  17. package/dist/host.js +30 -4
  18. package/dist/layout/LayoutRenderer.svelte +5 -1
  19. package/dist/layout/LayoutRenderer.test.js +42 -0
  20. package/dist/layout/SlotContainer.svelte +11 -2
  21. package/dist/layout/SlotContainer.svelte.d.ts +1 -0
  22. package/dist/layout/slotHostPool.svelte.js +10 -3
  23. package/dist/layout/slotHostPool.test.js +15 -0
  24. package/dist/navigation/back-stack.d.ts +29 -0
  25. package/dist/navigation/back-stack.js +87 -0
  26. package/dist/navigation/back-stack.test.d.ts +1 -0
  27. package/dist/navigation/back-stack.test.js +145 -0
  28. package/dist/navigation/index.d.ts +2 -0
  29. package/dist/navigation/index.js +6 -0
  30. package/dist/navigation/platform-web.d.ts +3 -0
  31. package/dist/navigation/platform-web.js +54 -0
  32. package/dist/navigation/platform-web.test.d.ts +1 -0
  33. package/dist/navigation/platform-web.test.js +96 -0
  34. package/dist/overlays/modal.js +7 -0
  35. package/dist/overlays/modal.test.js +35 -0
  36. package/dist/overlays/popup.js +7 -0
  37. package/dist/overlays/popup.test.js +33 -0
  38. package/dist/platform/index.d.ts +15 -0
  39. package/dist/platform/index.js +47 -0
  40. package/dist/primitives/base.css +17 -6
  41. package/dist/primitives/widgets/ColorSwatch.svelte +66 -0
  42. package/dist/primitives/widgets/ColorSwatch.svelte.d.ts +9 -0
  43. package/dist/primitives/widgets/Field.svelte +124 -0
  44. package/dist/primitives/widgets/Field.svelte.d.ts +19 -0
  45. package/dist/primitives/widgets/FilePicker.d.ts +3 -0
  46. package/dist/primitives/widgets/FilePicker.js +19 -0
  47. package/dist/primitives/widgets/FilePicker.svelte +79 -0
  48. package/dist/primitives/widgets/FilePicker.svelte.d.ts +13 -0
  49. package/dist/primitives/widgets/FilePicker.test.d.ts +1 -0
  50. package/dist/primitives/widgets/FilePicker.test.js +44 -0
  51. package/dist/primitives/widgets/IconToggleGroup.d.ts +2 -0
  52. package/dist/primitives/widgets/IconToggleGroup.js +8 -0
  53. package/dist/primitives/widgets/IconToggleGroup.svelte +86 -0
  54. package/dist/primitives/widgets/IconToggleGroup.svelte.d.ts +16 -0
  55. package/dist/primitives/widgets/IconToggleGroup.test.d.ts +1 -0
  56. package/dist/primitives/widgets/IconToggleGroup.test.js +19 -0
  57. package/dist/primitives/widgets/NumberInput.d.ts +6 -0
  58. package/dist/primitives/widgets/NumberInput.js +19 -0
  59. package/dist/primitives/widgets/NumberInput.svelte +167 -0
  60. package/dist/primitives/widgets/NumberInput.svelte.d.ts +17 -0
  61. package/dist/primitives/widgets/NumberInput.test.d.ts +1 -0
  62. package/dist/primitives/widgets/NumberInput.test.js +28 -0
  63. package/dist/primitives/widgets/RangeSlider.d.ts +2 -0
  64. package/dist/primitives/widgets/RangeSlider.js +7 -0
  65. package/dist/primitives/widgets/RangeSlider.svelte +124 -0
  66. package/dist/primitives/widgets/RangeSlider.svelte.d.ts +13 -0
  67. package/dist/primitives/widgets/RangeSlider.test.d.ts +1 -0
  68. package/dist/primitives/widgets/RangeSlider.test.js +14 -0
  69. package/dist/primitives/widgets/Segmented.d.ts +9 -0
  70. package/dist/primitives/widgets/Segmented.js +28 -0
  71. package/dist/primitives/widgets/Segmented.svelte +82 -0
  72. package/dist/primitives/widgets/Segmented.svelte.d.ts +10 -0
  73. package/dist/primitives/widgets/Segmented.test.d.ts +1 -0
  74. package/dist/primitives/widgets/Segmented.test.js +24 -0
  75. package/dist/primitives/widgets/Select.d.ts +11 -0
  76. package/dist/primitives/widgets/Select.js +42 -0
  77. package/dist/primitives/widgets/Select.svelte +163 -0
  78. package/dist/primitives/widgets/Select.svelte.d.ts +14 -0
  79. package/dist/primitives/widgets/Select.test.d.ts +1 -0
  80. package/dist/primitives/widgets/Select.test.js +68 -0
  81. package/dist/primitives/widgets/Slider.d.ts +6 -0
  82. package/dist/primitives/widgets/Slider.js +19 -0
  83. package/dist/primitives/widgets/Slider.svelte +205 -0
  84. package/dist/primitives/widgets/Slider.svelte.d.ts +15 -0
  85. package/dist/primitives/widgets/Slider.test.d.ts +1 -0
  86. package/dist/primitives/widgets/Slider.test.js +31 -0
  87. package/dist/primitives/widgets/SliderGroup.svelte +58 -0
  88. package/dist/primitives/widgets/SliderGroup.svelte.d.ts +18 -0
  89. package/dist/primitives/widgets/Textarea.svelte +81 -0
  90. package/dist/primitives/widgets/Textarea.svelte.d.ts +16 -0
  91. package/dist/primitives/widgets/_select-listbox.svelte +228 -0
  92. package/dist/primitives/widgets/_select-listbox.svelte.d.ts +18 -0
  93. package/dist/shards/activate-error-isolation.test.d.ts +1 -0
  94. package/dist/shards/activate-error-isolation.test.js +98 -0
  95. package/dist/shards/activate.svelte.d.ts +30 -2
  96. package/dist/shards/activate.svelte.js +62 -17
  97. package/dist/shell-shard/Terminal.svelte +1 -4
  98. package/dist/shell-shard/verbs/index.js +2 -0
  99. package/dist/shell-shard/verbs/reset.d.ts +2 -0
  100. package/dist/shell-shard/verbs/reset.js +26 -0
  101. package/dist/tokens.css +32 -0
  102. package/dist/version.d.ts +1 -1
  103. package/dist/version.js +1 -1
  104. package/package.json +1 -1
package/dist/host.js CHANGED
@@ -18,7 +18,7 @@
18
18
  import { registerShard as registerShardInternal, activateShard, registeredShards, } from './shards/activate.svelte';
19
19
  import { addAutostartShard } from './actions/state.svelte';
20
20
  import { registerApp, registeredApps } from './apps/registry.svelte';
21
- import { launchApp, readLastApp } from './apps/lifecycle';
21
+ import { launchApp, readLastApp, clearLastApp } from './apps/lifecycle';
22
22
  import { sh3coreShard } from './sh3core-shard/sh3coreShard.svelte';
23
23
  import { shellShard } from './shell-shard/shellShard.svelte';
24
24
  import { storeShard } from './app/store/storeShard.svelte';
@@ -29,6 +29,9 @@ import { storeApp } from './app/store/storeApp';
29
29
  import { adminShard } from './app/admin/adminShard.svelte';
30
30
  import { adminApp } from './app/admin/adminApp';
31
31
  import { runShellRenameMigration, } from './migrations/shell-rename';
32
+ import { setLifecycleHandlers } from './navigation/back-stack';
33
+ import { installWebEmitter } from './navigation/platform-web';
34
+ import { returnToHome } from './apps/lifecycle';
32
35
  export { __setBackend };
33
36
  export { setLocalOwner };
34
37
  export { __setTenantId, __setDocumentBackend } from './documents/config';
@@ -77,13 +80,36 @@ export async function bootstrap(config) {
77
80
  for (const [id, shard] of registeredShards) {
78
81
  if (shard.autostart) {
79
82
  addAutostartShard(id);
80
- await activateShard(id);
83
+ try {
84
+ await activateShard(id, { phase: 'autostart' });
85
+ }
86
+ catch (_a) {
87
+ // Already logged + recorded in erroredShards by activateShard.
88
+ // One bad self-starting shard must not prevent the shell from booting.
89
+ }
81
90
  }
82
91
  }
83
- // 5. Read the last-active app from the user zone
92
+ // 5. Read the last-active app from the user zone. If auto-launch fails,
93
+ // clear the slot so the next reload lands on home instead of looping
94
+ // into the same failure. No toast — the user did not initiate this.
84
95
  const lastId = readLastApp();
85
96
  if (lastId && registeredApps.has(lastId)) {
86
- await launchApp(lastId);
97
+ try {
98
+ await launchApp(lastId);
99
+ }
100
+ catch (err) {
101
+ console.error(`[sh3] Auto-launch of "${lastId}" failed:`, err);
102
+ clearLastApp();
103
+ }
104
+ }
105
+ // 6. Wire navigation lifecycle handlers and install the web back/forward
106
+ // emitter. Order: after autostart shards and the optional last-app
107
+ // launch, so the emitter's synthetic history entries don't interleave
108
+ // with boot-time launches. The window/history guard makes bootstrap
109
+ // safe to call from non-DOM environments (Node tests, future SSR).
110
+ setLifecycleHandlers({ returnToHome, launchApp });
111
+ if (typeof window !== 'undefined' && typeof history !== 'undefined') {
112
+ installWebEmitter();
87
113
  }
88
114
  }
89
115
  export { installPackage, listInstalledPackages } from './registry/installer';
@@ -202,7 +202,11 @@
202
202
  {@const entry = tabs?.tabs[i]}
203
203
  {#if entry}
204
204
  <div class="tab-slot-wrapper">
205
- <SlotContainer node={{ type: 'slot', slotId: entry.slotId, viewId: entry.viewId }} label={entry.label} />
205
+ <SlotContainer
206
+ node={{ type: 'slot', slotId: entry.slotId, viewId: entry.viewId }}
207
+ label={entry.label}
208
+ meta={entry.meta}
209
+ />
206
210
  <SlotDropZone {rootRef} path={path} />
207
211
  </div>
208
212
  {/if}
@@ -119,6 +119,48 @@ describe('LayoutRenderer — C.4 tabless preset', () => {
119
119
  expect(container.querySelector('[role="tab"]')).toBeNull();
120
120
  });
121
121
  });
122
+ describe('LayoutRenderer — C.6 TabEntry.meta threading', () => {
123
+ beforeEach(resetFramework);
124
+ it('forwards meta on a tab added post-launch (e.g. via floatManager.open)', async () => {
125
+ let captured;
126
+ registerView('test:meta-view', {
127
+ mount(_container, ctx) {
128
+ captured = ctx.meta;
129
+ return { unmount: () => { } };
130
+ },
131
+ });
132
+ // Launch with a placeholder tab — its slotId differs from the one we
133
+ // push later, so acquireAppSlotHolds takes no hold for the new tab.
134
+ // The new tab's first acquire is therefore SlotContainer's $effect,
135
+ // which is the gap the bug lives in.
136
+ registerApp(makeApp({
137
+ manifest: makeAppManifest({ id: 'c6' }),
138
+ initialLayout: [
139
+ {
140
+ name: 'default',
141
+ tree: makeTree(makeTabsNode([makeTabEntry({ slotId: 'placeholder', viewId: 'test:view' })])),
142
+ },
143
+ ],
144
+ }));
145
+ await launchApp('c6');
146
+ renderWithShell(LayoutRenderer, { path: [] });
147
+ await tick();
148
+ const root = layoutStore.root;
149
+ if ((root === null || root === void 0 ? void 0 : root.type) !== 'tabs')
150
+ throw new Error('expected tabs root');
151
+ root.tabs.push({
152
+ slotId: 'meta-slot',
153
+ viewId: 'test:meta-view',
154
+ label: 'M',
155
+ meta: { source: 'data:url', isBlob: false, label: 'pic.png' },
156
+ });
157
+ root.activeTab = 1;
158
+ await tick();
159
+ // acquireSlotHost defers factory.mount via queueMicrotask — flush it.
160
+ await Promise.resolve();
161
+ expect(captured).toEqual({ source: 'data:url', isBlob: false, label: 'pic.png' });
162
+ });
163
+ });
122
164
  describe('LayoutRenderer — C.5 invalid path', () => {
123
165
  beforeEach(resetFramework);
124
166
  it('renders nothing when the path no longer resolves to a node', async () => {
@@ -38,7 +38,11 @@
38
38
  import { getView } from '../shards/registry';
39
39
  import { acquireSlotHost, releaseSlotHost } from './slotHostPool.svelte';
40
40
 
41
- let { node, label = '' }: { node: SlotNode; label?: string } = $props();
41
+ let {
42
+ node,
43
+ label = '',
44
+ meta,
45
+ }: { node: SlotNode; label?: string; meta?: Record<string, unknown> } = $props();
42
46
 
43
47
  let wrapper: HTMLDivElement | undefined = $state();
44
48
  let width = $state(0);
@@ -62,7 +66,12 @@
62
66
  // detach if still in our wrapper" guard.
63
67
  const currentSlotId = node.slotId;
64
68
  const wrapperEl = wrapper;
65
- const host = acquireSlotHost(currentSlotId, node.viewId, label || node.viewId || currentSlotId);
69
+ const host = acquireSlotHost(
70
+ currentSlotId,
71
+ node.viewId,
72
+ label || node.viewId || currentSlotId,
73
+ meta,
74
+ );
66
75
  wrapperEl.appendChild(host);
67
76
 
68
77
  // Local observer exists only to drive the placeholder's dims text;
@@ -2,6 +2,7 @@ import type { SlotNode } from './types';
2
2
  type $$ComponentProps = {
3
3
  node: SlotNode;
4
4
  label?: string;
5
+ meta?: Record<string, unknown>;
5
6
  };
6
7
  declare const SlotContainer: import("svelte").Component<$$ComponentProps, {}, "">;
7
8
  type SlotContainer = ReturnType<typeof SlotContainer>;
@@ -35,6 +35,7 @@
35
35
  import { getView, __addViewRegistrationListener } from '../shards/registry';
36
36
  import { locateSlotIn } from './ops';
37
37
  import { activeLayout } from './store.svelte';
38
+ import { scopeToString } from '../actions/scope-helpers';
38
39
  const pool = new Map();
39
40
  const pendingDestroy = new Set();
40
41
  /**
@@ -134,8 +135,10 @@ function createHost(slotId, viewId, label, meta) {
134
135
  const host = document.createElement('div');
135
136
  host.className = 'slot-host';
136
137
  host.dataset.slotId = slotId;
137
- if (viewId)
138
+ if (viewId) {
138
139
  host.setAttribute('data-sh3-view', viewId);
140
+ host.setAttribute('data-sh3-scope', scopeToString(`focus:${viewId}`));
141
+ }
139
142
  // Position:absolute inset:0 so the host fills whichever wrapper it is
140
143
  // attached to. The wrapper is what the layout engine sizes; the host
141
144
  // just tracks it. Styles are set inline (not in a class) so consumers
@@ -220,10 +223,14 @@ export function acquireSlotHost(slotId, viewId, label, meta) {
220
223
  `but existing pooled entry has viewId "${entry.viewId}". Attribute synced; ` +
221
224
  `view handle unchanged.`);
222
225
  entry.viewId = viewId;
223
- if (viewId)
226
+ if (viewId) {
224
227
  entry.host.setAttribute('data-sh3-view', viewId);
225
- else
228
+ entry.host.setAttribute('data-sh3-scope', scopeToString(`focus:${viewId}`));
229
+ }
230
+ else {
226
231
  entry.host.removeAttribute('data-sh3-view');
232
+ entry.host.removeAttribute('data-sh3-scope');
233
+ }
227
234
  }
228
235
  entry.refcount++;
229
236
  return entry.host;
@@ -116,3 +116,18 @@ describe('slotHostPool — D.6 data-sh3-view attribute', () => {
116
116
  releaseSlotHost('slot-2');
117
117
  });
118
118
  });
119
+ // ─── D.7 ─────────────────────────────────────────────────────────────────────
120
+ describe('slotHostPool — D.7 data-sh3-scope attribute', () => {
121
+ beforeEach(resetFramework);
122
+ it('pooled host has data-sh3-scope="focus:<viewId>" alongside data-sh3-view', () => {
123
+ const host = acquireSlotHost('slot-3', 'editor', 'Editor');
124
+ expect(host.getAttribute('data-sh3-view')).toBe('editor');
125
+ expect(host.getAttribute('data-sh3-scope')).toBe('focus:editor');
126
+ releaseSlotHost('slot-3');
127
+ });
128
+ it('pooled host has no data-sh3-scope when viewId is null', () => {
129
+ const host = acquireSlotHost('slot-4', null, 'Empty');
130
+ expect(host.hasAttribute('data-sh3-scope')).toBe(false);
131
+ releaseSlotHost('slot-4');
132
+ });
133
+ });
@@ -0,0 +1,29 @@
1
+ export interface NavEntry {
2
+ /** Optional label. Reserved for future breadcrumb extension; not surfaced in v1. */
3
+ label?: string;
4
+ /** Called when this entry is popped via back. Synchronous; not cancellable in v1. */
5
+ onPop: () => void;
6
+ }
7
+ export interface NavEntryHandle {
8
+ /** Pop this entry without firing onPop. Idempotent; no-op if already popped. */
9
+ remove(): void;
10
+ }
11
+ export interface DismissableRegistration {
12
+ /** Remove the dismissable from the cascade. Idempotent. */
13
+ unregister(): void;
14
+ }
15
+ interface LifecycleHandlers {
16
+ returnToHome: () => void | Promise<unknown>;
17
+ launchApp: (id: string) => void | Promise<unknown>;
18
+ }
19
+ export declare function setLifecycleHandlers(handlers: LifecycleHandlers): void;
20
+ export declare function pushNavEntry(entry: NavEntry): NavEntryHandle;
21
+ export declare function registerDismissable(dismiss: () => void): DismissableRegistration;
22
+ export declare function clearAppNavEntries(): void;
23
+ export declare function dispatchBack(): void;
24
+ export declare function dispatchForward(): void;
25
+ /** @internal — test reset helper used by the central resetFramework. */
26
+ export declare function __resetBackStackForTest(): void;
27
+ /** @internal — test injection helper. */
28
+ export declare function __setLifecycleHandlersForTest(handlers: LifecycleHandlers): void;
29
+ export {};
@@ -0,0 +1,87 @@
1
+ /*
2
+ * Navigation back-cascade — global LIFO stacks for dismissable overlays
3
+ * and app-internal nav entries. Drives the response to back/forward
4
+ * signals (delivered by platform emitters in this same module folder).
5
+ *
6
+ * Cascade on back:
7
+ * 1. Top-most dismissable overlay (modal/popup) → close it.
8
+ * 2. Top-most app nav entry → fire its onPop.
9
+ * 3. Active app exists → returnToHome (cancellable suspend hooks).
10
+ * 4. Otherwise → no-op.
11
+ *
12
+ * Forward is asymmetric: only acts on home and only relaunches the
13
+ * breadcrumb app (matches the existing BrandSlot click semantics).
14
+ *
15
+ * Lifecycle handlers (returnToHome, launchApp) are injected during host
16
+ * bootstrap rather than imported directly. This avoids module-eval cycles
17
+ * with apps/lifecycle and keeps this module unit-testable in isolation.
18
+ */
19
+ import { activeApp, breadcrumbApp } from '../apps/registry.svelte';
20
+ const dismissables = [];
21
+ const appNavEntries = [];
22
+ let lifecycleHandlers = null;
23
+ export function setLifecycleHandlers(handlers) {
24
+ lifecycleHandlers = handlers;
25
+ }
26
+ export function pushNavEntry(entry) {
27
+ if (!activeApp.id) {
28
+ throw new Error('pushNavEntry requires an active app');
29
+ }
30
+ const id = Symbol('nav-entry');
31
+ appNavEntries.push({ id, label: entry.label, onPop: entry.onPop });
32
+ return {
33
+ remove() {
34
+ const idx = appNavEntries.findIndex((e) => e.id === id);
35
+ if (idx >= 0)
36
+ appNavEntries.splice(idx, 1);
37
+ },
38
+ };
39
+ }
40
+ export function registerDismissable(dismiss) {
41
+ const id = Symbol('dismissable');
42
+ dismissables.push({ id, dismiss });
43
+ return {
44
+ unregister() {
45
+ const idx = dismissables.findIndex((d) => d.id === id);
46
+ if (idx >= 0)
47
+ dismissables.splice(idx, 1);
48
+ },
49
+ };
50
+ }
51
+ export function clearAppNavEntries() {
52
+ appNavEntries.length = 0;
53
+ }
54
+ export function dispatchBack() {
55
+ if (dismissables.length > 0) {
56
+ const top = dismissables.pop();
57
+ top.dismiss();
58
+ return;
59
+ }
60
+ if (appNavEntries.length > 0) {
61
+ const top = appNavEntries.pop();
62
+ top.onPop();
63
+ return;
64
+ }
65
+ if (activeApp.id && lifecycleHandlers) {
66
+ void lifecycleHandlers.returnToHome();
67
+ }
68
+ }
69
+ export function dispatchForward() {
70
+ if (activeApp.id)
71
+ return;
72
+ if (!breadcrumbApp.id)
73
+ return;
74
+ if (!lifecycleHandlers)
75
+ return;
76
+ void lifecycleHandlers.launchApp(breadcrumbApp.id);
77
+ }
78
+ /** @internal — test reset helper used by the central resetFramework. */
79
+ export function __resetBackStackForTest() {
80
+ dismissables.length = 0;
81
+ appNavEntries.length = 0;
82
+ lifecycleHandlers = null;
83
+ }
84
+ /** @internal — test injection helper. */
85
+ export function __setLifecycleHandlersForTest(handlers) {
86
+ lifecycleHandlers = handlers;
87
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,145 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { pushNavEntry, dispatchBack, dispatchForward, registerDismissable, clearAppNavEntries, __resetBackStackForTest, __setLifecycleHandlersForTest, } from './back-stack';
3
+ import { activeApp, breadcrumbApp } from '../apps/registry.svelte';
4
+ beforeEach(() => {
5
+ __resetBackStackForTest();
6
+ activeApp.id = null;
7
+ breadcrumbApp.id = null;
8
+ });
9
+ describe('back-stack — empty state', () => {
10
+ it('dispatchBack with empty stacks and no active app is a no-op', () => {
11
+ const returnToHome = vi.fn();
12
+ const launchApp = vi.fn();
13
+ __setLifecycleHandlersForTest({ returnToHome, launchApp });
14
+ dispatchBack();
15
+ expect(returnToHome).not.toHaveBeenCalled();
16
+ expect(launchApp).not.toHaveBeenCalled();
17
+ });
18
+ it('dispatchBack with empty stacks and active app calls returnToHome', () => {
19
+ activeApp.id = 'some-app';
20
+ const returnToHome = vi.fn();
21
+ __setLifecycleHandlersForTest({ returnToHome, launchApp: vi.fn() });
22
+ dispatchBack();
23
+ expect(returnToHome).toHaveBeenCalledTimes(1);
24
+ });
25
+ });
26
+ describe('back-stack — app nav entries', () => {
27
+ it('pushNavEntry throws when no active app', () => {
28
+ expect(() => pushNavEntry({ onPop: () => { } })).toThrow(/active app/i);
29
+ });
30
+ it('dispatchBack pops the top entry and fires onPop', () => {
31
+ activeApp.id = 'app';
32
+ const popA = vi.fn();
33
+ const popB = vi.fn();
34
+ pushNavEntry({ onPop: popA });
35
+ pushNavEntry({ onPop: popB });
36
+ dispatchBack();
37
+ expect(popB).toHaveBeenCalledTimes(1);
38
+ expect(popA).not.toHaveBeenCalled();
39
+ });
40
+ it('dispatchBack with two entries fires popA on the second back', () => {
41
+ activeApp.id = 'app';
42
+ const popA = vi.fn();
43
+ const popB = vi.fn();
44
+ pushNavEntry({ onPop: popA });
45
+ pushNavEntry({ onPop: popB });
46
+ dispatchBack();
47
+ dispatchBack();
48
+ expect(popA).toHaveBeenCalledTimes(1);
49
+ expect(popB).toHaveBeenCalledTimes(1);
50
+ });
51
+ it('handle.remove() removes the entry without firing onPop', () => {
52
+ activeApp.id = 'app';
53
+ const onPop = vi.fn();
54
+ const handle = pushNavEntry({ onPop });
55
+ handle.remove();
56
+ dispatchBack();
57
+ expect(onPop).not.toHaveBeenCalled();
58
+ });
59
+ it('handle.remove() is idempotent', () => {
60
+ activeApp.id = 'app';
61
+ const handle = pushNavEntry({ onPop: () => { } });
62
+ handle.remove();
63
+ expect(() => handle.remove()).not.toThrow();
64
+ });
65
+ it('clearAppNavEntries drops all entries without firing onPop', () => {
66
+ activeApp.id = 'app';
67
+ const popA = vi.fn();
68
+ const popB = vi.fn();
69
+ pushNavEntry({ onPop: popA });
70
+ pushNavEntry({ onPop: popB });
71
+ clearAppNavEntries();
72
+ dispatchBack();
73
+ expect(popA).not.toHaveBeenCalled();
74
+ expect(popB).not.toHaveBeenCalled();
75
+ });
76
+ });
77
+ describe('back-stack — dismissables', () => {
78
+ it('dispatchBack runs the most recent dismissable', () => {
79
+ const dismissA = vi.fn();
80
+ const dismissB = vi.fn();
81
+ registerDismissable(dismissA);
82
+ registerDismissable(dismissB);
83
+ dispatchBack();
84
+ expect(dismissB).toHaveBeenCalledTimes(1);
85
+ expect(dismissA).not.toHaveBeenCalled();
86
+ });
87
+ it('dismissable goes before app nav entries', () => {
88
+ activeApp.id = 'app';
89
+ const onPop = vi.fn();
90
+ const dismiss = vi.fn();
91
+ pushNavEntry({ onPop });
92
+ registerDismissable(dismiss);
93
+ dispatchBack();
94
+ expect(dismiss).toHaveBeenCalledTimes(1);
95
+ expect(onPop).not.toHaveBeenCalled();
96
+ });
97
+ it('dismissable goes before app→home', () => {
98
+ activeApp.id = 'app';
99
+ const dismiss = vi.fn();
100
+ const returnToHome = vi.fn();
101
+ __setLifecycleHandlersForTest({ returnToHome, launchApp: vi.fn() });
102
+ registerDismissable(dismiss);
103
+ dispatchBack();
104
+ expect(dismiss).toHaveBeenCalledTimes(1);
105
+ expect(returnToHome).not.toHaveBeenCalled();
106
+ });
107
+ it('unregister removes the dismissable from the cascade', () => {
108
+ const dismiss = vi.fn();
109
+ const reg = registerDismissable(dismiss);
110
+ reg.unregister();
111
+ dispatchBack();
112
+ expect(dismiss).not.toHaveBeenCalled();
113
+ });
114
+ it('unregister is idempotent', () => {
115
+ const reg = registerDismissable(() => { });
116
+ reg.unregister();
117
+ expect(() => reg.unregister()).not.toThrow();
118
+ });
119
+ });
120
+ describe('back-stack — forward', () => {
121
+ it('dispatchForward on home with breadcrumb relaunches the app', () => {
122
+ activeApp.id = null;
123
+ breadcrumbApp.id = 'last-app';
124
+ const launchApp = vi.fn();
125
+ __setLifecycleHandlersForTest({ returnToHome: vi.fn(), launchApp });
126
+ dispatchForward();
127
+ expect(launchApp).toHaveBeenCalledWith('last-app');
128
+ });
129
+ it('dispatchForward on home without breadcrumb is a no-op', () => {
130
+ activeApp.id = null;
131
+ breadcrumbApp.id = null;
132
+ const launchApp = vi.fn();
133
+ __setLifecycleHandlersForTest({ returnToHome: vi.fn(), launchApp });
134
+ dispatchForward();
135
+ expect(launchApp).not.toHaveBeenCalled();
136
+ });
137
+ it('dispatchForward in-app is a no-op even with breadcrumb', () => {
138
+ activeApp.id = 'current';
139
+ breadcrumbApp.id = 'last-app';
140
+ const launchApp = vi.fn();
141
+ __setLifecycleHandlersForTest({ returnToHome: vi.fn(), launchApp });
142
+ dispatchForward();
143
+ expect(launchApp).not.toHaveBeenCalled();
144
+ });
145
+ });
@@ -0,0 +1,2 @@
1
+ export { pushNavEntry } from './back-stack';
2
+ export type { NavEntry, NavEntryHandle } from './back-stack';
@@ -0,0 +1,6 @@
1
+ /*
2
+ * Navigation public surface — re-exports the api consumers (apps) need.
3
+ * Internal pieces (dispatchBack, dispatchForward, registerDismissable,
4
+ * lifecycle handler wiring, platform emitters) stay private to this folder.
5
+ */
6
+ export { pushNavEntry } from './back-stack';
@@ -0,0 +1,3 @@
1
+ export declare function installWebEmitter(): void;
2
+ /** @internal — test cleanup. Removes the listener; does not unwind history. */
3
+ export declare function __uninstallWebEmitterForTest(): void;
@@ -0,0 +1,54 @@
1
+ /*
2
+ * Web platform emitter for the navigation back-cascade.
3
+ *
4
+ * Browser back/forward (and mouse X1/X2 buttons, which the browser maps
5
+ * to history navigation) all funnel through `popstate`. The event itself
6
+ * doesn't report direction, so we maintain a three-state "sandwich":
7
+ *
8
+ * position 0: { sh3: 'anchor' } ← popstate here = back was pressed
9
+ * position 1: { sh3: 'main' } ← resting position
10
+ * position 2: { sh3: 'forward-bumper' } ← popstate here = forward was pressed
11
+ *
12
+ * On every consumed signal we re-anchor to 'main' so the user never
13
+ * navigates out of SH3 via the back/forward chord.
14
+ *
15
+ * Page reload re-runs install. The two extra synthetic history entries
16
+ * are accepted noise; no URL changes.
17
+ *
18
+ * Known limitation: third-party `pushState` (HMR, libraries) clobbers the
19
+ * forward-bumper. SH3 framework code must not call pushState directly.
20
+ */
21
+ import { dispatchBack, dispatchForward } from './back-stack';
22
+ let installed = false;
23
+ let listener = null;
24
+ export function installWebEmitter() {
25
+ if (installed)
26
+ return;
27
+ installed = true;
28
+ listener = (e) => {
29
+ var _a;
30
+ const tag = (_a = e.state) === null || _a === void 0 ? void 0 : _a.sh3;
31
+ if (tag === 'anchor') {
32
+ dispatchBack();
33
+ history.forward();
34
+ }
35
+ else if (tag === 'forward-bumper') {
36
+ dispatchForward();
37
+ history.back();
38
+ }
39
+ // tag === 'main' (or undefined) → echo from our own correction; ignore.
40
+ };
41
+ window.addEventListener('popstate', listener);
42
+ history.replaceState({ sh3: 'anchor' }, '');
43
+ history.pushState({ sh3: 'main' }, '');
44
+ history.pushState({ sh3: 'forward-bumper' }, '');
45
+ history.back();
46
+ }
47
+ /** @internal — test cleanup. Removes the listener; does not unwind history. */
48
+ export function __uninstallWebEmitterForTest() {
49
+ if (listener) {
50
+ window.removeEventListener('popstate', listener);
51
+ listener = null;
52
+ }
53
+ installed = false;
54
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,96 @@
1
+ /*
2
+ * Note: happy-dom does not simulate `history.back()` / `history.forward()`
3
+ * (state stays put). We can't drive the listener via the real navigation
4
+ * API in this environment, so we test what we own — listener wiring and
5
+ * dispatch routing — by dispatching synthetic PopStateEvents directly.
6
+ * The trailing `history.back()` in installWebEmitter is a no-op here but
7
+ * is real in browsers and Tauri webviews; manual verification (Task 7 of
8
+ * the plan) covers the actual navigation behavior.
9
+ */
10
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
11
+ import { installWebEmitter, __uninstallWebEmitterForTest } from './platform-web';
12
+ import { __resetBackStackForTest, __setLifecycleHandlersForTest, } from './back-stack';
13
+ import { activeApp, breadcrumbApp } from '../apps/registry.svelte';
14
+ beforeEach(() => {
15
+ __resetBackStackForTest();
16
+ activeApp.id = null;
17
+ breadcrumbApp.id = null;
18
+ });
19
+ afterEach(() => {
20
+ __uninstallWebEmitterForTest();
21
+ });
22
+ // happy-dom's PopStateEvent constructor ignores the `state` init dict, so
23
+ // we attach state manually via defineProperty before dispatching.
24
+ function firePopState(state) {
25
+ const e = new PopStateEvent('popstate');
26
+ Object.defineProperty(e, 'state', { value: state });
27
+ window.dispatchEvent(e);
28
+ }
29
+ const fireAnchor = () => firePopState({ sh3: 'anchor' });
30
+ const fireForwardBumper = () => firePopState({ sh3: 'forward-bumper' });
31
+ const fireMain = () => firePopState({ sh3: 'main' });
32
+ describe('platform-web — sentinel install', () => {
33
+ it('installs without throwing and pushes the three sentinel entries', () => {
34
+ const before = history.length;
35
+ installWebEmitter();
36
+ // pushState appends; replaceState doesn't. We expect at least +2 entries
37
+ // (the two pushStates; replaceState replaced the user's current entry).
38
+ expect(history.length).toBeGreaterThanOrEqual(before + 2);
39
+ });
40
+ it('install is idempotent — second call does nothing', () => {
41
+ installWebEmitter();
42
+ const after1 = history.length;
43
+ installWebEmitter();
44
+ expect(history.length).toBe(after1);
45
+ });
46
+ });
47
+ describe('platform-web — back signal', () => {
48
+ it('an anchor-tagged popstate fires dispatchBack', () => {
49
+ activeApp.id = 'app';
50
+ const returnToHome = vi.fn();
51
+ __setLifecycleHandlersForTest({ returnToHome, launchApp: vi.fn() });
52
+ installWebEmitter();
53
+ fireAnchor();
54
+ expect(returnToHome).toHaveBeenCalledTimes(1);
55
+ });
56
+ });
57
+ describe('platform-web — forward signal', () => {
58
+ it('a forward-bumper-tagged popstate fires dispatchForward', () => {
59
+ activeApp.id = null;
60
+ breadcrumbApp.id = 'last-app';
61
+ const launchApp = vi.fn();
62
+ __setLifecycleHandlersForTest({ returnToHome: vi.fn(), launchApp });
63
+ installWebEmitter();
64
+ fireForwardBumper();
65
+ expect(launchApp).toHaveBeenCalledWith('last-app');
66
+ });
67
+ });
68
+ describe('platform-web — main echo is ignored', () => {
69
+ it('a popstate tagged main does not fire dispatch', () => {
70
+ activeApp.id = 'app';
71
+ const returnToHome = vi.fn();
72
+ __setLifecycleHandlersForTest({ returnToHome, launchApp: vi.fn() });
73
+ installWebEmitter();
74
+ fireMain();
75
+ expect(returnToHome).not.toHaveBeenCalled();
76
+ });
77
+ it('a popstate with no state (untagged) does not fire dispatch', () => {
78
+ activeApp.id = 'app';
79
+ const returnToHome = vi.fn();
80
+ __setLifecycleHandlersForTest({ returnToHome, launchApp: vi.fn() });
81
+ installWebEmitter();
82
+ firePopState(null);
83
+ expect(returnToHome).not.toHaveBeenCalled();
84
+ });
85
+ });
86
+ describe('platform-web — uninstall', () => {
87
+ it('after __uninstall, popstates no longer fire dispatch', () => {
88
+ activeApp.id = 'app';
89
+ const returnToHome = vi.fn();
90
+ __setLifecycleHandlersForTest({ returnToHome, launchApp: vi.fn() });
91
+ installWebEmitter();
92
+ __uninstallWebEmitterForTest();
93
+ fireAnchor();
94
+ expect(returnToHome).not.toHaveBeenCalled();
95
+ });
96
+ });