sh3-core 0.11.4 → 0.11.7

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 (90) hide show
  1. package/dist/BrandSlot.svelte +80 -0
  2. package/dist/BrandSlot.svelte.d.ts +3 -0
  3. package/dist/BrandSlot.test.d.ts +1 -0
  4. package/dist/BrandSlot.test.js +71 -0
  5. package/dist/Shell.svelte +8 -10
  6. package/dist/actions/ActionPanel.svelte +143 -0
  7. package/dist/actions/ActionPanel.svelte.d.ts +13 -0
  8. package/dist/actions/ActionPanel.test.d.ts +1 -0
  9. package/dist/actions/ActionPanel.test.js +168 -0
  10. package/dist/actions/ContextMenu.svelte +17 -85
  11. package/dist/actions/MenuBar.svelte +57 -0
  12. package/dist/actions/MenuBar.svelte.d.ts +3 -0
  13. package/dist/actions/MenuBar.test.d.ts +1 -0
  14. package/dist/actions/MenuBar.test.js +109 -0
  15. package/dist/actions/MenuButton.svelte +150 -0
  16. package/dist/actions/MenuButton.svelte.d.ts +10 -0
  17. package/dist/actions/MenuButton.test.d.ts +1 -0
  18. package/dist/actions/MenuButton.test.js +125 -0
  19. package/dist/actions/contextMenuModel.d.ts +10 -0
  20. package/dist/actions/contextMenuModel.js +44 -9
  21. package/dist/actions/contextMenuModel.test.js +28 -1
  22. package/dist/actions/defaultMenuContainers.d.ts +2 -0
  23. package/dist/actions/defaultMenuContainers.js +7 -0
  24. package/dist/actions/defaultMenuContainers.test.d.ts +1 -0
  25. package/dist/actions/defaultMenuContainers.test.js +23 -0
  26. package/dist/actions/listeners.d.ts +4 -0
  27. package/dist/actions/listeners.js +77 -17
  28. package/dist/actions/listeners.test.js +50 -0
  29. package/dist/actions/menuBarModel.d.ts +42 -0
  30. package/dist/actions/menuBarModel.js +110 -0
  31. package/dist/actions/menuBarModel.test.d.ts +1 -0
  32. package/dist/actions/menuBarModel.test.js +158 -0
  33. package/dist/actions/palette-scorer.d.ts +4 -0
  34. package/dist/actions/palette-scorer.js +5 -0
  35. package/dist/actions/palette-scorer.test.js +9 -1
  36. package/dist/actions/paletteModel.d.ts +7 -1
  37. package/dist/actions/paletteModel.js +26 -1
  38. package/dist/actions/paletteModel.test.js +43 -0
  39. package/dist/actions/registry.js +5 -0
  40. package/dist/actions/registry.test.js +12 -0
  41. package/dist/actions/types.d.ts +48 -1
  42. package/dist/actions/types.test.d.ts +1 -0
  43. package/dist/actions/types.test.js +31 -0
  44. package/dist/apps/lifecycle.js +8 -1
  45. package/dist/apps/lifecycle.test.js +211 -1
  46. package/dist/apps/registry.svelte.d.ts +17 -1
  47. package/dist/apps/registry.svelte.js +20 -1
  48. package/dist/apps/types.d.ts +28 -0
  49. package/dist/assets/icons.svg +5 -0
  50. package/dist/documents/backends.d.ts +2 -0
  51. package/dist/documents/backends.js +55 -0
  52. package/dist/documents/backends.test.d.ts +1 -1
  53. package/dist/documents/backends.test.js +69 -1
  54. package/dist/documents/browse.d.ts +18 -0
  55. package/dist/documents/browse.js +13 -0
  56. package/dist/documents/browse.test.js +47 -0
  57. package/dist/documents/handle.js +23 -0
  58. package/dist/documents/handle.test.js +51 -0
  59. package/dist/documents/http-backend.d.ts +1 -0
  60. package/dist/documents/http-backend.js +19 -0
  61. package/dist/documents/http-backend.test.js +42 -0
  62. package/dist/documents/types.d.ts +29 -1
  63. package/dist/documents/types.js +4 -0
  64. package/dist/documents/types.test.d.ts +1 -0
  65. package/dist/documents/types.test.js +20 -0
  66. package/dist/layout/LayoutRenderer.browser.test.js +196 -0
  67. package/dist/layout/SlotContainer.svelte +13 -8
  68. package/dist/layout/SlotDropZone.svelte +44 -9
  69. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-7-fixed-slot-drop-protection-still-accepts-a-strip-drop-into-a-fixed-tabs-node-1.png +0 -0
  70. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-8-same-strip-reorder-keeps-the-active-pane-populated-after-moving-the-second-tab-to-first-1.png +0 -0
  71. package/dist/layout/ops.d.ts +10 -0
  72. package/dist/layout/ops.js +30 -2
  73. package/dist/layout/ops.test.js +111 -1
  74. package/dist/layout/slotHostPool.svelte.d.ts +7 -1
  75. package/dist/layout/slotHostPool.svelte.js +27 -8
  76. package/dist/layout/store.svelte.d.ts +27 -0
  77. package/dist/layout/store.svelte.js +63 -0
  78. package/dist/overlays/ConfirmDialog.svelte +138 -0
  79. package/dist/overlays/ConfirmDialog.svelte.d.ts +13 -0
  80. package/dist/overlays/ConfirmDialog.test.d.ts +1 -0
  81. package/dist/overlays/ConfirmDialog.test.js +123 -0
  82. package/dist/overlays/FloatFrame.svelte +2 -2
  83. package/dist/overlays/ToastItem.svelte +3 -3
  84. package/dist/primitives/base.css +5 -5
  85. package/dist/sh3core-shard/sh3coreShard.svelte.js +38 -4
  86. package/dist/shell-shard/shellShard.svelte.js +0 -4
  87. package/dist/tokens.css +1 -1
  88. package/dist/version.d.ts +1 -1
  89. package/dist/version.js +1 -1
  90. package/package.json +2 -1
@@ -47,3 +47,46 @@ describe('buildPaletteCandidates', () => {
47
47
  expect(out[0].scopeBadge).toBe('view:editor');
48
48
  });
49
49
  });
50
+ describe('buildPaletteCandidates — submenu and filter', () => {
51
+ it('filter.submenuOf: only returns children of the given parent', () => {
52
+ const entries = [
53
+ mkEntry({ id: 'p', scope: 'home', label: 'P', submenu: true }),
54
+ mkEntry({ id: 'p.a', scope: 'home', label: 'A', submenuOf: 'p' }),
55
+ mkEntry({ id: 'p.b', scope: 'home', label: 'B', submenuOf: 'p' }),
56
+ mkEntry({ id: 'q.x', scope: 'home', label: 'X', submenuOf: 'q' }),
57
+ mkEntry({ id: 'r', scope: 'home', label: 'R' }),
58
+ ];
59
+ const out = buildPaletteCandidates(entries, mkState(), { filter: { submenuOf: 'p' } });
60
+ expect(out.map((c) => c.id)).toEqual(['p.a', 'p.b']);
61
+ });
62
+ it('returns all active candidates by default (children included; scorer hides them on empty query)', () => {
63
+ const entries = [
64
+ mkEntry({ id: 'p', scope: 'home', label: 'P', submenu: true }),
65
+ mkEntry({ id: 'p.a', scope: 'home', label: 'A', submenuOf: 'p' }),
66
+ ];
67
+ const out = buildPaletteCandidates(entries, mkState());
68
+ expect(out.map((c) => c.id).sort()).toEqual(['p', 'p.a']);
69
+ });
70
+ it('hides disabled actions', () => {
71
+ const entries = [
72
+ mkEntry({ id: 'a', scope: 'home', label: 'A', disabled: true }),
73
+ mkEntry({ id: 'b', scope: 'home', label: 'B', disabled: () => true }),
74
+ mkEntry({ id: 'c', scope: 'home', label: 'C' }),
75
+ ];
76
+ const out = buildPaletteCandidates(entries, mkState(), {});
77
+ expect(out.map((c) => c.id)).toEqual(['c']);
78
+ });
79
+ it('annotates submenu and submenuOf on candidates', () => {
80
+ const entries = [
81
+ mkEntry({ id: 'p', scope: 'home', label: 'P', submenu: true }),
82
+ mkEntry({ id: 'p.a', scope: 'home', label: 'A', submenuOf: 'p' }),
83
+ ];
84
+ const out = buildPaletteCandidates(entries, mkState());
85
+ const parent = out.find((c) => c.id === 'p');
86
+ const child = out.find((c) => c.id === 'p.a');
87
+ expect(parent.submenu).toBe(true);
88
+ expect(parent.submenuOf).toBeUndefined();
89
+ expect(child.submenu).toBe(false);
90
+ expect(child.submenuOf).toBe('p');
91
+ });
92
+ });
@@ -9,6 +9,11 @@ import { register as contributionsRegister, list as contributionsList, onChange
9
9
  export const ACTIONS_POINT_ID = 'sh3.actions';
10
10
  const liveIds = new Set();
11
11
  export function registerAction(action, ownerShardId) {
12
+ if (typeof action.run !== 'function' && action.submenu !== true) {
13
+ console.warn(`[sh3] Action "${action.id}" registered by "${ownerShardId}" has no \`run\` ` +
14
+ `and is not a submenu parent (\`submenu: true\`). Registration ignored.`);
15
+ return () => { };
16
+ }
12
17
  if (liveIds.has(action.id)) {
13
18
  console.warn(`[sh3] Duplicate action id "${action.id}" registered by "${ownerShardId}". ` +
14
19
  `First registration wins; second registration still stored but shortcut conflicts may occur.`);
@@ -46,4 +46,16 @@ describe('actions registry', () => {
46
46
  expect(warn).toHaveBeenCalled();
47
47
  warn.mockRestore();
48
48
  });
49
+ it('warns and rejects an action with neither run nor submenu', () => {
50
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
51
+ const dispose = registerAction({ id: 'no-op', label: 'No-Op', scope: 'home' }, 'shard.a');
52
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('"no-op"'));
53
+ expect(listActions()).toHaveLength(0);
54
+ dispose();
55
+ warn.mockRestore();
56
+ });
57
+ it('accepts a submenu parent without run', () => {
58
+ registerAction({ id: 'p', label: 'Parent', scope: 'home', submenu: true }, 'shard.a');
59
+ expect(listActions()).toHaveLength(1);
60
+ });
49
61
  });
@@ -8,11 +8,55 @@ export interface Action {
8
8
  scope: ActionScope;
9
9
  contextItem?: boolean;
10
10
  paletteItem?: boolean;
11
+ /**
12
+ * Optional menu container id. When set and the active app's declared
13
+ * (or canonical fallback) menu list contains this id, the action
14
+ * appears in that container's dropdown. Orphaned values render
15
+ * nowhere in the menu bar; the action remains reachable via
16
+ * palette/hotkey/context menu.
17
+ */
18
+ menuItem?: string;
11
19
  defaultShortcut?: string;
12
20
  icon?: string;
21
+ /**
22
+ * Optional grouping key. Items inside the same surface (menu bar
23
+ * dropdown, context menu, submenu popup) with distinct `group`
24
+ * values are separated by a divider line in `ActionPanel`. This is
25
+ * the only separator mechanism — there is no explicit separator
26
+ * primitive. Items without a `group` form a single default group.
27
+ */
13
28
  group?: string;
14
29
  allowInInputs?: boolean;
15
- run(ctx: ActionDispatchContext): void | Promise<void>;
30
+ /**
31
+ * Toggle-state indicator. Truthy renders a leading ✓ in MenuBar /
32
+ * ContextMenu rows. Function form is re-evaluated on each derive
33
+ * (no re-registration on flip). Ignored in CommandPalette.
34
+ */
35
+ checked?: boolean | (() => boolean);
36
+ /**
37
+ * Visible-but-not-dispatchable. Greyed in MenuBar/ContextMenu,
38
+ * skipped by keyboard nav, click is a no-op, shortcut dispatch is
39
+ * blocked. Hidden from CommandPalette in v1 (a future "show
40
+ * disabled" toggle is acknowledged in the spec but out of scope).
41
+ */
42
+ disabled?: boolean | (() => boolean);
43
+ /**
44
+ * Marks this action as a submenu parent. `run` becomes optional —
45
+ * when omitted, the framework provides default drill-down behavior:
46
+ * MenuBar/ContextMenu render the row as actionless+expanding;
47
+ * CommandPalette opens a sub-palette filtered to children
48
+ * (`submenuOf === this.id`).
49
+ */
50
+ submenu?: true;
51
+ /**
52
+ * Marks this action as a child of `submenuOf`'s submenu. Children
53
+ * inherit the parent's surface placement — they do NOT repeat
54
+ * `menuItem`, `contextItem`, or `paletteItem`. Children are excluded
55
+ * from their parent's container's flat list and only appear in the
56
+ * parent's submenu popup or via direct match in the palette.
57
+ */
58
+ submenuOf?: string;
59
+ run?(ctx: ActionDispatchContext): void | Promise<void>;
16
60
  }
17
61
  export interface Selection {
18
62
  type: string;
@@ -47,6 +91,9 @@ export interface ActionsApi {
47
91
  }): void;
48
92
  openPalette(opts?: {
49
93
  prefill?: string;
94
+ filter?: {
95
+ submenuOf?: string;
96
+ };
50
97
  }): void;
51
98
  }
52
99
  export interface ResolvedAction {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,31 @@
1
+ import { describe, it, expectTypeOf } from 'vitest';
2
+ describe('Action type', () => {
3
+ it('accepts an action without run when submenu: true', () => {
4
+ const a = {
5
+ id: 'p',
6
+ label: 'Parent',
7
+ scope: 'home',
8
+ submenu: true,
9
+ };
10
+ expectTypeOf(a).toMatchTypeOf();
11
+ });
12
+ it('accepts checked / disabled as boolean or function', () => {
13
+ const flat = {
14
+ id: 'a', label: 'A', scope: 'home',
15
+ run: () => { }, checked: true, disabled: false,
16
+ };
17
+ const reactive = {
18
+ id: 'b', label: 'B', scope: 'home',
19
+ run: () => { }, checked: () => true, disabled: () => false,
20
+ };
21
+ expectTypeOf(flat).toMatchTypeOf();
22
+ expectTypeOf(reactive).toMatchTypeOf();
23
+ });
24
+ it('accepts submenuOf as a string id', () => {
25
+ const c = {
26
+ id: 'c', label: 'C', scope: 'home',
27
+ submenuOf: 'p', run: () => { },
28
+ };
29
+ expectTypeOf(c).toMatchTypeOf();
30
+ });
31
+ });
@@ -14,7 +14,7 @@
14
14
  import { createStateZones } from '../state/zones.svelte';
15
15
  import { activateShard, deactivateShard, getShardContext, registeredShards, } from '../shards/activate.svelte';
16
16
  import { attachApp, acquireAppSlotHolds, detachApp, switchToApp, switchToHome, } from '../layout/store.svelte';
17
- import { activeApp, getRegisteredApp, registeredApps } from './registry.svelte';
17
+ import { activeApp, breadcrumbApp, getRegisteredApp, registeredApps } from './registry.svelte';
18
18
  import { createZoneManager } from '../state/manage';
19
19
  import { PERMISSION_STATE_MANAGE } from '../state/types';
20
20
  import { setActiveApp, setUserBindings } from '../actions/state.svelte';
@@ -96,6 +96,7 @@ export async function launchApp(id) {
96
96
  switchToApp();
97
97
  void ((_c = app.onAppReady) === null || _c === void 0 ? void 0 : _c.call(app, getOrCreateAppContext(id)));
98
98
  writeLastApp(id);
99
+ breadcrumbApp.id = id;
99
100
  setActiveApp(id, new Set((_d = app.manifest.requiredShards) !== null && _d !== void 0 ? _d : []));
100
101
  void loadUserBindings(id).then(setUserBindings);
101
102
  return;
@@ -135,6 +136,7 @@ export async function launchApp(id) {
135
136
  switchToApp();
136
137
  void ((_g = app.onAppReady) === null || _g === void 0 ? void 0 : _g.call(app, getOrCreateAppContext(id)));
137
138
  writeLastApp(id);
139
+ breadcrumbApp.id = id;
138
140
  }
139
141
  // ---------- unload --------------------------------------------------------
140
142
  /**
@@ -226,6 +228,11 @@ export async function returnToHome() {
226
228
  return false;
227
229
  }
228
230
  switchToHome();
231
+ // Mirror unregisterApp: clear the dispatcher's active-app pointer so
232
+ // 'app'-scope actions become inactive on home. Without this, any action
233
+ // registered with scope: ['app'] keeps appearing in the palette while
234
+ // the user is on home.
235
+ setActiveApp(null, new Set());
229
236
  writeLastApp(null);
230
237
  return true;
231
238
  }
@@ -5,7 +5,7 @@ import { launchApp, returnToHome, unregisterApp } from './lifecycle';
5
5
  import { registerApp } from './registry.svelte';
6
6
  import { registerShard } from '../shards/activate.svelte';
7
7
  import { presetManager } from '../overlays/presets';
8
- import { layoutStore } from '../layout/store.svelte';
8
+ import { layoutStore, resetActivePresetToDefault } from '../layout/store.svelte';
9
9
  import LayoutRenderer from '../layout/LayoutRenderer.svelte';
10
10
  import { renderWithShell } from '../__test__/render';
11
11
  import { registerView } from '../shards/registry';
@@ -307,3 +307,213 @@ describe('installPackage evict-before-register (simulated via registerLoadedBund
307
307
  expect((_b = registeredShards.get('S')) === null || _b === void 0 ? void 0 : _b.manifest.version).toBe('1.0.1');
308
308
  });
309
309
  });
310
+ // ---------------------------------------------------------------------------
311
+ // Scenario C.1 — resetActivePresetToDefault rebuilds the active preset
312
+ // ---------------------------------------------------------------------------
313
+ describe('resetActivePresetToDefault — scenario C.1 happy path', () => {
314
+ beforeEach(resetFramework);
315
+ it('replaces the active preset tree with a fresh copy of initialLayout', async () => {
316
+ var _a;
317
+ registerApp(makeApp({
318
+ manifest: makeAppManifest({ id: 'reset-1' }),
319
+ initialLayout: [
320
+ {
321
+ name: 'main',
322
+ tree: makeTree(makeTabsNode([
323
+ makeTabEntry({ slotId: 'a', label: 'A' }),
324
+ makeTabEntry({ slotId: 'b', label: 'B' }),
325
+ ])),
326
+ },
327
+ ],
328
+ }));
329
+ await launchApp('reset-1');
330
+ // Mutate the live tree: change activeTab and add a float.
331
+ const root = layoutStore.root;
332
+ expect(root === null || root === void 0 ? void 0 : root.type).toBe('tabs');
333
+ if ((root === null || root === void 0 ? void 0 : root.type) === 'tabs')
334
+ root.activeTab = 1;
335
+ layoutStore.tree.floats.push({
336
+ id: 'float-test',
337
+ content: makeSlotNode('floated'),
338
+ position: { x: 10, y: 10 },
339
+ size: { w: 100, h: 100 },
340
+ });
341
+ expect(layoutStore.tree.floats.length).toBe(1);
342
+ resetActivePresetToDefault();
343
+ const resetRoot = layoutStore.root;
344
+ expect(resetRoot === null || resetRoot === void 0 ? void 0 : resetRoot.type).toBe('tabs');
345
+ if ((resetRoot === null || resetRoot === void 0 ? void 0 : resetRoot.type) === 'tabs') {
346
+ expect((_a = resetRoot.activeTab) !== null && _a !== void 0 ? _a : 0).toBe(0);
347
+ expect(resetRoot.tabs.map((t) => t.slotId)).toEqual(['a', 'b']);
348
+ }
349
+ expect(layoutStore.tree.floats.length).toBe(0);
350
+ });
351
+ });
352
+ // ---------------------------------------------------------------------------
353
+ // Scenario C.2 — reset falls back when active preset name no longer exists
354
+ // ---------------------------------------------------------------------------
355
+ describe('resetActivePresetToDefault — scenario C.2 missing-preset fallback', () => {
356
+ beforeEach(resetFramework);
357
+ it('falls back to canonical[0] and updates activePreset when the stored name is gone', async () => {
358
+ // Launch with two presets, switch to the second, then simulate the app
359
+ // dropping that preset by re-registering it with a different layout.
360
+ registerApp(makeApp({
361
+ manifest: makeAppManifest({ id: 'reset-2' }),
362
+ initialLayout: [
363
+ { name: 'first', tree: makeTree(makeSlotNode('x')) },
364
+ { name: 'second', tree: makeTree(makeSlotNode('y')) },
365
+ ],
366
+ }));
367
+ await launchApp('reset-2');
368
+ presetManager.switch('second');
369
+ expect(presetManager.active()).toBe('second');
370
+ // Replace the registry entry with a version that no longer declares
371
+ // 'second'. We mutate registeredApps directly because the public
372
+ // `unregisterApp` would also detach. This mimics the in-process
373
+ // version-update scenario for the reset code path.
374
+ const { registeredApps } = await import('./registry.svelte');
375
+ const existing = registeredApps.get('reset-2');
376
+ registeredApps.set('reset-2', Object.assign(Object.assign({}, existing), { initialLayout: [
377
+ { name: 'first', tree: makeTree(makeSlotNode('x')) },
378
+ // 'second' removed
379
+ ] }));
380
+ resetActivePresetToDefault();
381
+ expect(presetManager.active()).toBe('first');
382
+ // The previously-active 'second' preset is still in the blob (we
383
+ // don't garbage-collect dropped presets); only its name was vacated.
384
+ expect(layoutStore.root).toMatchObject({ type: 'slot', slotId: 'x' });
385
+ });
386
+ });
387
+ // ---------------------------------------------------------------------------
388
+ // Scenario C.3 — reset throws when no app is attached
389
+ // ---------------------------------------------------------------------------
390
+ describe('resetActivePresetToDefault — scenario C.3 no app attached', () => {
391
+ beforeEach(resetFramework);
392
+ it('throws a useful error when called with no attached app', () => {
393
+ expect(() => resetActivePresetToDefault()).toThrow(/no app attached/);
394
+ });
395
+ });
396
+ // ---------------------------------------------------------------------------
397
+ // Scenario C.4 — reset re-scopes slot-host refcount holds
398
+ // ---------------------------------------------------------------------------
399
+ describe('resetActivePresetToDefault — scenario C.4 slot-host hold accounting', () => {
400
+ beforeEach(resetFramework);
401
+ it('updates appEntry.heldSlotIds to match the rebuilt tree', async () => {
402
+ var _a, _b;
403
+ const { __inspectAppEntryHeldSlotIdsForTest } = await import('../layout/store.svelte');
404
+ registerApp(makeApp({
405
+ manifest: makeAppManifest({ id: 'reset-3' }),
406
+ initialLayout: [
407
+ {
408
+ name: 'main',
409
+ tree: makeTree(makeTabsNode([
410
+ makeTabEntry({ slotId: 't-a', label: 'A' }),
411
+ makeTabEntry({ slotId: 't-b', label: 'B' }),
412
+ ])),
413
+ },
414
+ ],
415
+ }));
416
+ await launchApp('reset-3');
417
+ expect((_a = __inspectAppEntryHeldSlotIdsForTest()) === null || _a === void 0 ? void 0 : _a.slice().sort()).toEqual(['t-a', 't-b']);
418
+ // Mutate the live tree by replacing the docked node with a different
419
+ // single slot. After reset, the held set must return to the default
420
+ // pair, which proves the old single hold was released and the
421
+ // canonical pair was re-acquired.
422
+ layoutStore.tree.docked = makeSlotNode('mutated');
423
+ resetActivePresetToDefault();
424
+ expect((_b = __inspectAppEntryHeldSlotIdsForTest()) === null || _b === void 0 ? void 0 : _b.slice().sort()).toEqual(['t-a', 't-b']);
425
+ });
426
+ });
427
+ // ---------------------------------------------------------------------------
428
+ // Scenario C.5 — sh3.app.reset-layout is registered with the right shape
429
+ // ---------------------------------------------------------------------------
430
+ describe('sh3coreShard — sh3.app.reset-layout registration', () => {
431
+ beforeEach(resetFramework);
432
+ it('exposes a "Reset Current Layout" action scoped to app, palette-only', async () => {
433
+ // Register and activate the sh3core pseudo-shard the same way bootstrap()
434
+ // would — tests don't run bootstrap, so the shard's actions wouldn't
435
+ // otherwise be present.
436
+ const { sh3coreShard } = await import('../sh3core-shard/sh3coreShard.svelte');
437
+ const { activateShard } = await import('../shards/activate.svelte');
438
+ const { addAutostartShard } = await import('../actions/state.svelte');
439
+ registerShard(sh3coreShard);
440
+ // Mirror what bootstrap() does: mark the framework shard as autostart so
441
+ // its 'app'-scope actions are treated as ambient inside any app.
442
+ addAutostartShard(sh3coreShard.manifest.id);
443
+ await activateShard(sh3coreShard.manifest.id);
444
+ registerApp(makeApp({
445
+ manifest: makeAppManifest({ id: 'reset-action-1' }),
446
+ initialLayout: [{ name: 'main', tree: makeTree(makeSlotNode('x')) }],
447
+ }));
448
+ await launchApp('reset-action-1');
449
+ const { shell } = await import('../shellRuntime.svelte');
450
+ const actions = shell.actions.listActive();
451
+ const reset = actions.find((a) => a.id === 'sh3.app.reset-layout');
452
+ expect(reset).toBeDefined();
453
+ expect(reset === null || reset === void 0 ? void 0 : reset.label).toBe('Reset Current Layout');
454
+ expect(reset === null || reset === void 0 ? void 0 : reset.paletteItem).toBe(true);
455
+ expect(reset === null || reset === void 0 ? void 0 : reset.contextItem).toBe(false);
456
+ expect(reset === null || reset === void 0 ? void 0 : reset.effectiveShortcut).toBeNull();
457
+ });
458
+ it('hides "app"-scope actions after returnToHome', async () => {
459
+ const { sh3coreShard } = await import('../sh3core-shard/sh3coreShard.svelte');
460
+ const { activateShard } = await import('../shards/activate.svelte');
461
+ const { addAutostartShard } = await import('../actions/state.svelte');
462
+ registerShard(sh3coreShard);
463
+ addAutostartShard(sh3coreShard.manifest.id);
464
+ await activateShard(sh3coreShard.manifest.id);
465
+ registerApp(makeApp({
466
+ manifest: makeAppManifest({ id: 'reset-action-2' }),
467
+ initialLayout: [{ name: 'main', tree: makeTree(makeSlotNode('x')) }],
468
+ }));
469
+ await launchApp('reset-action-2');
470
+ const { shell } = await import('../shellRuntime.svelte');
471
+ expect(shell.actions.listActive().some((a) => a.id === 'sh3.app.reset-layout')).toBe(true);
472
+ await returnToHome();
473
+ expect(shell.actions.listActive().some((a) => a.id === 'sh3.app.reset-layout')).toBe(false);
474
+ });
475
+ });
476
+ // ---------------------------------------------------------------------------
477
+ // breadcrumbAppId — lingers across returnToHome (distinct from lastAppState)
478
+ // ---------------------------------------------------------------------------
479
+ describe('breadcrumbAppId', () => {
480
+ beforeEach(resetFramework);
481
+ it('starts null when no app has launched this session', async () => {
482
+ const { getBreadcrumbAppId } = await import('./registry.svelte');
483
+ expect(getBreadcrumbAppId()).toBeNull();
484
+ });
485
+ it('is set to the launched app id', async () => {
486
+ const { getBreadcrumbAppId } = await import('./registry.svelte');
487
+ registerShard(makeShard({ manifest: makeShardManifest({ id: 'shard-A' }) }));
488
+ registerApp(makeApp({
489
+ manifest: makeAppManifest({ id: 'app-bc1', requiredShards: ['shard-A'] }),
490
+ }));
491
+ await launchApp('app-bc1');
492
+ expect(getBreadcrumbAppId()).toBe('app-bc1');
493
+ });
494
+ it('lingers across returnToHome (does NOT clear like lastAppState)', async () => {
495
+ const { getBreadcrumbAppId } = await import('./registry.svelte');
496
+ const { readLastApp } = await import('./lifecycle');
497
+ registerShard(makeShard({ manifest: makeShardManifest({ id: 'shard-A' }) }));
498
+ registerApp(makeApp({
499
+ manifest: makeAppManifest({ id: 'app-bc2', requiredShards: ['shard-A'] }),
500
+ }));
501
+ await launchApp('app-bc2');
502
+ await returnToHome();
503
+ expect(getBreadcrumbAppId()).toBe('app-bc2');
504
+ expect(readLastApp()).toBeNull();
505
+ });
506
+ it('overwrites on a subsequent launch', async () => {
507
+ const { getBreadcrumbAppId } = await import('./registry.svelte');
508
+ registerShard(makeShard({ manifest: makeShardManifest({ id: 'shard-A' }) }));
509
+ registerApp(makeApp({
510
+ manifest: makeAppManifest({ id: 'app-bc3a', requiredShards: ['shard-A'] }),
511
+ }));
512
+ registerApp(makeApp({
513
+ manifest: makeAppManifest({ id: 'app-bc3b', requiredShards: ['shard-A'] }),
514
+ }));
515
+ await launchApp('app-bc3a');
516
+ await launchApp('app-bc3b');
517
+ expect(getBreadcrumbAppId()).toBe('app-bc3b');
518
+ });
519
+ });
@@ -13,6 +13,22 @@ export declare const registeredApps: Map<string, App>;
13
13
  export declare const activeApp: {
14
14
  id: string | null;
15
15
  };
16
+ /**
17
+ * Most recently launched app id this session. Distinct from
18
+ * `lastAppState` in `lifecycle.ts` (which is persisted to the user zone
19
+ * for boot, and cleared by `returnToHome`). The breadcrumb pointer
20
+ * survives `returnToHome` so the top-bar BrandSlot can render
21
+ * `SH3 > [App Name]` and let the user one-click back into the app.
22
+ *
23
+ * In-memory only — page reload resets it to null. No persistence.
24
+ */
25
+ export declare const breadcrumbApp: {
26
+ id: string | null;
27
+ };
28
+ /** Read the breadcrumb app id. `null` when no app has launched this session. */
29
+ export declare function getBreadcrumbAppId(): string | null;
30
+ /** @internal — test helper; resets the breadcrumb to null. */
31
+ export declare function __resetBreadcrumbForTest(): void;
16
32
  /**
17
33
  * Register (or re-register) an app with the framework.
18
34
  *
@@ -38,5 +54,5 @@ export declare function getActiveApp(): AppManifest | null;
38
54
  * activate hook. Not re-exported through `api.ts`.
39
55
  */
40
56
  export declare function getRegisteredApp(id: string): App | undefined;
41
- /** Test-only reset: clear registered apps and the active-app pointer. */
57
+ /** Test-only reset: clear registered apps, active-app pointer, and breadcrumb. */
42
58
  export declare function __resetAppRegistryForTest(): void;
@@ -20,6 +20,24 @@ export const registeredApps = $state(new Map());
20
20
  * most one active app at a time.
21
21
  */
22
22
  export const activeApp = $state({ id: null });
23
+ /**
24
+ * Most recently launched app id this session. Distinct from
25
+ * `lastAppState` in `lifecycle.ts` (which is persisted to the user zone
26
+ * for boot, and cleared by `returnToHome`). The breadcrumb pointer
27
+ * survives `returnToHome` so the top-bar BrandSlot can render
28
+ * `SH3 > [App Name]` and let the user one-click back into the app.
29
+ *
30
+ * In-memory only — page reload resets it to null. No persistence.
31
+ */
32
+ export const breadcrumbApp = $state({ id: null });
33
+ /** Read the breadcrumb app id. `null` when no app has launched this session. */
34
+ export function getBreadcrumbAppId() {
35
+ return breadcrumbApp.id;
36
+ }
37
+ /** @internal — test helper; resets the breadcrumb to null. */
38
+ export function __resetBreadcrumbForTest() {
39
+ breadcrumbApp.id = null;
40
+ }
23
41
  /**
24
42
  * Register (or re-register) an app with the framework.
25
43
  *
@@ -57,8 +75,9 @@ export function getActiveApp() {
57
75
  export function getRegisteredApp(id) {
58
76
  return registeredApps.get(id);
59
77
  }
60
- /** Test-only reset: clear registered apps and the active-app pointer. */
78
+ /** Test-only reset: clear registered apps, active-app pointer, and breadcrumb. */
61
79
  export function __resetAppRegistryForTest() {
62
80
  registeredApps.clear();
63
81
  activeApp.id = null;
82
+ breadcrumbApp.id = null;
64
83
  }
@@ -1,6 +1,28 @@
1
1
  import type { LayoutNode, LayoutTree, LayoutPreset } from '../layout/types';
2
2
  import type { ZoneSchema, ZoneManager } from '../state/types';
3
3
  import type { StateZones } from '../state/zones.svelte';
4
+ /**
5
+ * One menu bar container ("File", "Edit", etc.). Apps declare these in
6
+ * `AppManifest.menus`; when omitted, sh3-core uses DEFAULT_MENU_CONTAINERS.
7
+ * Items reach a container by setting `Action.menuItem` to its `id`.
8
+ */
9
+ export interface MenuContainer {
10
+ /** Container id referenced by Action.menuItem. */
11
+ id: string;
12
+ /** Display label shown in the menu bar. */
13
+ label: string;
14
+ /** Optional lucide icon name. */
15
+ icon?: string;
16
+ /** Icon placement relative to the label. Default: 'before'. */
17
+ iconPosition?: 'before' | 'after';
18
+ /**
19
+ * Optional explicit ordering. When omitted, declaration order in the
20
+ * `AppManifest.menus` array is used. Containers with `order` defined
21
+ * sort first (ascending); ties and undefined fall back to declaration
22
+ * order.
23
+ */
24
+ order?: number;
25
+ }
4
26
  /**
5
27
  * Static description of an app as observed by the framework at runtime.
6
28
  * `version` is always present here: externally installed apps have it
@@ -46,6 +68,12 @@ export interface AppManifest {
46
68
  * in a later plan alongside the server-side sync runtime.
47
69
  */
48
70
  permissions?: string[];
71
+ /**
72
+ * Optional menu bar container list. When present, fully replaces the
73
+ * canonical fallback (file, edit, view, window, help) for this app.
74
+ * When absent, the canonical fallback is used. See MenuContainer.
75
+ */
76
+ menus?: MenuContainer[];
49
77
  }
50
78
  /**
51
79
  * Context object passed to `App.activate`. Provides app-scoped state zones
@@ -653,6 +653,11 @@
653
653
  <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
654
654
  </symbol>
655
655
 
656
+ <!-- lucide/magnet -->
657
+ <symbol id="magnet" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
658
+ <path d="m12 15 4 4" /><path d="M2.352 10.648a1.205 1.205 0 0 0 0 1.704l2.296 2.296a1.205 1.205 0 0 0 1.704 0l6.029-6.029a1 1 0 1 1 3 3l-6.029 6.029a1.205 1.205 0 0 0 0 1.704l2.296 2.296a1.205 1.205 0 0 0 1.704 0l6.365-6.367A1 1 0 0 0 8.716 4.282z" />
659
+ <path d="m5 8 4 4" />
660
+ </symbol>
656
661
  <!-- lucide/video -->
657
662
  <symbol id="video" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
658
663
  <path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" />
@@ -5,6 +5,7 @@ export declare class MemoryDocumentBackend implements DocumentBackend {
5
5
  read(tenantId: string, shardId: string, path: string): Promise<string | ArrayBuffer | null>;
6
6
  write(tenantId: string, shardId: string, path: string, content: string | ArrayBuffer): Promise<void>;
7
7
  delete(tenantId: string, shardId: string, path: string): Promise<void>;
8
+ rename(tenantId: string, shardId: string, oldPath: string, newPath: string): Promise<void>;
8
9
  list(tenantId: string, shardId: string): Promise<DocumentMeta[]>;
9
10
  exists(tenantId: string, shardId: string, path: string): Promise<boolean>;
10
11
  listAllShards(tenantId: string): Promise<string[]>;
@@ -18,6 +19,7 @@ export declare class IndexedDBDocumentBackend implements DocumentBackend {
18
19
  read(tenantId: string, shardId: string, path: string): Promise<string | ArrayBuffer | null>;
19
20
  write(tenantId: string, shardId: string, path: string, content: string | ArrayBuffer): Promise<void>;
20
21
  delete(tenantId: string, shardId: string, path: string): Promise<void>;
22
+ rename(tenantId: string, shardId: string, oldPath: string, newPath: string): Promise<void>;
21
23
  list(tenantId: string, shardId: string): Promise<DocumentMeta[]>;
22
24
  exists(tenantId: string, shardId: string, path: string): Promise<boolean>;
23
25
  listAllShards(tenantId: string): Promise<string[]>;
@@ -45,6 +45,19 @@ export class MemoryDocumentBackend {
45
45
  async delete(tenantId, shardId, path) {
46
46
  __classPrivateFieldGet(this, _MemoryDocumentBackend_store, "f").delete(compositeKey(tenantId, shardId, path));
47
47
  }
48
+ async rename(tenantId, shardId, oldPath, newPath) {
49
+ const oldKey = compositeKey(tenantId, shardId, oldPath);
50
+ const newKey = compositeKey(tenantId, shardId, newPath);
51
+ const entry = __classPrivateFieldGet(this, _MemoryDocumentBackend_store, "f").get(oldKey);
52
+ if (!entry) {
53
+ throw new Error(`Document not found at ${oldPath}`);
54
+ }
55
+ if (__classPrivateFieldGet(this, _MemoryDocumentBackend_store, "f").has(newKey)) {
56
+ throw new Error(`Document already exists at ${newPath}`);
57
+ }
58
+ __classPrivateFieldGet(this, _MemoryDocumentBackend_store, "f").set(newKey, Object.assign(Object.assign({}, entry), { lastModified: Date.now() }));
59
+ __classPrivateFieldGet(this, _MemoryDocumentBackend_store, "f").delete(oldKey);
60
+ }
48
61
  async list(tenantId, shardId) {
49
62
  const prefix = keyPrefix(tenantId, shardId);
50
63
  const out = [];
@@ -126,6 +139,48 @@ export class IndexedDBDocumentBackend {
126
139
  const key = compositeKey(tenantId, shardId, path);
127
140
  await __classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_tx).call(this, 'readwrite', (s) => s.delete(key));
128
141
  }
142
+ async rename(tenantId, shardId, oldPath, newPath) {
143
+ const oldKey = compositeKey(tenantId, shardId, oldPath);
144
+ const newKey = compositeKey(tenantId, shardId, newPath);
145
+ const db = await __classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_db).call(this);
146
+ return new Promise((resolve, reject) => {
147
+ let aborted = false;
148
+ const tx = db.transaction(IDB_STORE, 'readwrite');
149
+ const store = tx.objectStore(IDB_STORE);
150
+ const getOld = store.get(oldKey);
151
+ getOld.onsuccess = () => {
152
+ const entry = getOld.result;
153
+ if (!entry) {
154
+ aborted = true;
155
+ tx.abort();
156
+ reject(new Error(`Document not found at ${oldPath}`));
157
+ return;
158
+ }
159
+ const getNew = store.getKey(newKey);
160
+ getNew.onsuccess = () => {
161
+ if (getNew.result !== undefined) {
162
+ aborted = true;
163
+ tx.abort();
164
+ reject(new Error(`Document already exists at ${newPath}`));
165
+ return;
166
+ }
167
+ const renamed = Object.assign(Object.assign({}, entry), { lastModified: Date.now() });
168
+ store.put(renamed, newKey);
169
+ store.delete(oldKey);
170
+ };
171
+ getNew.onerror = () => reject(getNew.error);
172
+ };
173
+ getOld.onerror = () => reject(getOld.error);
174
+ tx.oncomplete = () => resolve();
175
+ tx.onerror = () => {
176
+ if (!aborted)
177
+ reject(tx.error);
178
+ };
179
+ tx.onabort = () => {
180
+ // Already rejected above; swallow.
181
+ };
182
+ });
183
+ }
129
184
  async list(tenantId, shardId) {
130
185
  const prefix = keyPrefix(tenantId, shardId);
131
186
  const db = await __classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_db).call(this);
@@ -1 +1 @@
1
- export {};
1
+ import 'fake-indexeddb/auto';