sh3-core 0.11.2 → 0.11.6

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 (84) 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 +105 -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 +80 -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 +104 -0
  16. package/dist/actions/MenuButton.svelte.d.ts +9 -0
  17. package/dist/actions/MenuButton.test.d.ts +1 -0
  18. package/dist/actions/MenuButton.test.js +88 -0
  19. package/dist/actions/bindings.d.ts +10 -1
  20. package/dist/actions/bindings.js +16 -0
  21. package/dist/actions/bindings.test.js +23 -1
  22. package/dist/actions/contextMenuModel.js +5 -40
  23. package/dist/actions/defaultMenuContainers.d.ts +2 -0
  24. package/dist/actions/defaultMenuContainers.js +7 -0
  25. package/dist/actions/defaultMenuContainers.test.d.ts +1 -0
  26. package/dist/actions/defaultMenuContainers.test.js +23 -0
  27. package/dist/actions/dispatcher.svelte.js +1 -14
  28. package/dist/actions/listActive.d.ts +4 -0
  29. package/dist/actions/listActive.js +42 -0
  30. package/dist/actions/listActive.test.d.ts +1 -0
  31. package/dist/actions/listActive.test.js +86 -0
  32. package/dist/actions/menuBarModel.d.ts +28 -0
  33. package/dist/actions/menuBarModel.js +67 -0
  34. package/dist/actions/menuBarModel.test.d.ts +1 -0
  35. package/dist/actions/menuBarModel.test.js +84 -0
  36. package/dist/actions/paletteModel.js +10 -21
  37. package/dist/actions/paletteModel.test.js +16 -0
  38. package/dist/actions/scope-helpers.d.ts +11 -0
  39. package/dist/actions/scope-helpers.js +51 -0
  40. package/dist/actions/scope-helpers.test.d.ts +1 -0
  41. package/dist/actions/scope-helpers.test.js +62 -0
  42. package/dist/actions/shellActions.test.js +50 -0
  43. package/dist/actions/state.svelte.d.ts +12 -0
  44. package/dist/actions/state.svelte.js +36 -0
  45. package/dist/actions/state.test.js +26 -1
  46. package/dist/actions/types.d.ts +49 -0
  47. package/dist/api.d.ts +5 -0
  48. package/dist/api.js +6 -0
  49. package/dist/apps/lifecycle.js +8 -1
  50. package/dist/apps/lifecycle.test.js +211 -1
  51. package/dist/apps/registry.svelte.d.ts +17 -1
  52. package/dist/apps/registry.svelte.js +20 -1
  53. package/dist/apps/types.d.ts +28 -0
  54. package/dist/assets/favicon.png +0 -0
  55. package/dist/assets/favicon.svg +5 -0
  56. package/dist/color/api.d.ts +38 -0
  57. package/dist/color/api.js +10 -0
  58. package/dist/color/native-fallback.test.d.ts +1 -0
  59. package/dist/color/native-fallback.test.js +43 -0
  60. package/dist/color/primitive.d.ts +2 -0
  61. package/dist/color/primitive.js +40 -0
  62. package/dist/color/primitive.test.d.ts +1 -0
  63. package/dist/color/primitive.test.js +42 -0
  64. package/dist/color/shell-api.d.ts +2 -0
  65. package/dist/color/shell-api.js +11 -0
  66. package/dist/index.d.ts +0 -2
  67. package/dist/index.js +0 -2
  68. package/dist/layout/store.svelte.d.ts +27 -0
  69. package/dist/layout/store.svelte.js +63 -0
  70. package/dist/overlays/ConfirmDialog.svelte +138 -0
  71. package/dist/overlays/ConfirmDialog.svelte.d.ts +13 -0
  72. package/dist/overlays/ConfirmDialog.test.d.ts +1 -0
  73. package/dist/overlays/ConfirmDialog.test.js +123 -0
  74. package/dist/overlays/FloatFrame.svelte +2 -2
  75. package/dist/overlays/ToastItem.svelte +3 -3
  76. package/dist/primitives/base.css +5 -5
  77. package/dist/sh3core-shard/sh3coreShard.svelte.js +20 -0
  78. package/dist/shell-shard/shellShard.svelte.js +0 -4
  79. package/dist/shellRuntime.svelte.d.ts +20 -0
  80. package/dist/shellRuntime.svelte.js +16 -1
  81. package/dist/tokens.css +1 -1
  82. package/dist/version.d.ts +1 -1
  83. package/dist/version.js +1 -1
  84. package/package.json +1 -1
package/dist/api.js CHANGED
@@ -32,6 +32,7 @@ export { inspectActiveLayout, spliceIntoActiveLayout, dockIntoActiveLayout, focu
32
32
  export { PERMISSION_DOCUMENTS_BROWSE, PERMISSION_DOCUMENTS_READ, PERMISSION_DOCUMENTS_WRITE, } from './documents/types';
33
33
  export { PERMISSION_SYNC_PEER, PERMISSION_SYNC_POLICY } from './documents/sync-types';
34
34
  export { CONFLICT_RENDERER_POINT, ConflictPermissionError, ConflictSessionOrphanedError, } from './conflicts/api';
35
+ export { COLOR_PICKER_POINT } from './color/api';
35
36
  // Shard introspection — read-only reactive maps exposing which shards are
36
37
  // known to the host and which are currently active. Intended for diagnostic
37
38
  // and tooling shards that need to visualize framework state. Phase 9
@@ -65,3 +66,8 @@ export const FRAMEWORK_SHARD_IDS = [
65
66
  ];
66
67
  // Theme token override API (shell-level theming support).
67
68
  export { setTokenOverrides, clearTokenOverrides, getTokenOverrides, } from './theme';
69
+ // UI primitives for shards and apps. Must live on the public shim surface
70
+ // (api.ts) — not just index.ts — so dynamically loaded bundles can resolve
71
+ // `import { Button } from 'sh3-core'` against the runtime shim in loader.ts.
72
+ export { default as Button } from './primitives/Button.svelte';
73
+ export { provideIcons, getIconSprite } from './primitives/icon-context';
@@ -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
Binary file
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" data-svg-designer-scene-id="13a98ace">
2
+ <rect id="07433d30" x="2" y="2" width="20" height="20" rx="2" fill="#a15c67" stroke="#ff7373" stroke-width="0.5" opacity="1"/>
3
+ <text id="deb1fa9e" x="2.0101" y="16.2415" font-family="monospace" font-size="12" font-weight="bold" fill="#2b0f2b" opacity="0.8">SH3</text>
4
+ <text id="055bb24a" x="2.0709" y="15.3866" font-family="monospace" font-size="12" font-weight="bold" fill="#fae3b6" opacity="1">SH3</text>
5
+ </svg>
@@ -0,0 +1,38 @@
1
+ /** Contribution-point id for color pickers. */
2
+ export declare const COLOR_PICKER_POINT = "sh3.color-picker";
3
+ export interface ColorPickOptions {
4
+ /** Initial color as '#rrggbb'. If omitted, contributors choose a default. */
5
+ initial?: string;
6
+ /** Request alpha support. Ignored by contributors that don't support it. */
7
+ alpha?: boolean;
8
+ /** Anchor element for floating/positioned pickers. Native fallback ignores. */
9
+ anchor?: HTMLElement;
10
+ /** Display title (e.g. 'Background color'). Native fallback ignores. */
11
+ title?: string;
12
+ }
13
+ /**
14
+ * A color-picker contribution. Higher `priority` wins; the inlined native
15
+ * fallback is priority 0, so contributors typically use >= 10.
16
+ *
17
+ * `open(opts)` resolves with '#rrggbb' (or '#rrggbbaa' when the contributor
18
+ * honors `opts.alpha`) or `null` if the user dismissed.
19
+ */
20
+ export interface ColorContribution {
21
+ id: string;
22
+ priority?: number;
23
+ open(opts: ColorPickOptions): Promise<string | null>;
24
+ }
25
+ /** Shell-level color API, mounted at `shell.color`. */
26
+ export interface ColorApi {
27
+ /**
28
+ * Open a color picker. Resolves with the chosen color as '#rrggbb'
29
+ * (or '#rrggbbaa' when a contributor honors `opts.alpha === true`),
30
+ * or `null` if the user dismissed.
31
+ *
32
+ * On browsers without `<input type="color">` cancel-event support, the
33
+ * returned promise may not resolve if the user closes the native
34
+ * fallback without committing. Install a color-picker shard (e.g.
35
+ * sh3-editor) to avoid this.
36
+ */
37
+ pick(opts?: ColorPickOptions): Promise<string | null>;
38
+ }
@@ -0,0 +1,10 @@
1
+ /*
2
+ * Public types for the color-picker contribution point.
3
+ *
4
+ * A contribution registered at COLOR_PICKER_POINT is picked by the
5
+ * primitive in ./primitive.ts when `shell.color.pick()` is called.
6
+ * The native <input type="color"> fallback is inlined there at priority 0;
7
+ * contributed pickers typically register at priority >= 10.
8
+ */
9
+ /** Contribution-point id for color pickers. */
10
+ export const COLOR_PICKER_POINT = 'sh3.color-picker';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { register, __resetContributionsForTest } from '../contributions/registry';
3
+ import { COLOR_PICKER_POINT } from './api';
4
+ import { pickColor } from './primitive';
5
+ describe('native fallback', () => {
6
+ beforeEach(() => __resetContributionsForTest());
7
+ afterEach(() => {
8
+ document.querySelectorAll('input[type="color"]').forEach((el) => el.remove());
9
+ });
10
+ it('creates an input[type=color], clicks it, and resolves on change', async () => {
11
+ const p = pickColor({ initial: '#ff8800' });
12
+ const el = document.querySelector('input[type="color"]');
13
+ expect(el).not.toBeNull();
14
+ expect(el.value).toBe('#ff8800');
15
+ el.value = '#112233';
16
+ el.dispatchEvent(new Event('change'));
17
+ await expect(p).resolves.toBe('#112233');
18
+ expect(document.querySelector('input[type="color"]')).toBeNull();
19
+ });
20
+ it('resolves null on cancel', async () => {
21
+ const p = pickColor();
22
+ const el = document.querySelector('input[type="color"]');
23
+ expect(el).not.toBeNull();
24
+ el.dispatchEvent(new Event('cancel'));
25
+ await expect(p).resolves.toBeNull();
26
+ expect(document.querySelector('input[type="color"]')).toBeNull();
27
+ });
28
+ it('ignores malformed initial values without crashing', () => {
29
+ pickColor({ initial: 'not-a-color' });
30
+ const el = document.querySelector('input[type="color"]');
31
+ expect(el).not.toBeNull();
32
+ expect(el.value).not.toBe('not-a-color');
33
+ });
34
+ it('priority-≥1 contributor bypasses native entirely', async () => {
35
+ register(COLOR_PICKER_POINT, {
36
+ id: 'c',
37
+ priority: 10,
38
+ open: () => Promise.resolve('#abcdef'),
39
+ });
40
+ await expect(pickColor()).resolves.toBe('#abcdef');
41
+ expect(document.querySelector('input[type="color"]')).toBeNull();
42
+ });
43
+ });
@@ -0,0 +1,2 @@
1
+ import { type ColorPickOptions } from './api';
2
+ export declare function pickColor(opts?: ColorPickOptions): Promise<string | null>;
@@ -0,0 +1,40 @@
1
+ /*
2
+ * Color-picker selection primitive.
3
+ *
4
+ * Reads contributions registered at COLOR_PICKER_POINT, sorts by
5
+ * priority descending, and calls `open(opts)` on the winner. The
6
+ * inlined native fallback at priority 0 guarantees there is always
7
+ * at least one candidate.
8
+ */
9
+ import { list } from '../contributions/registry';
10
+ import { COLOR_PICKER_POINT } from './api';
11
+ const nativeFallback = {
12
+ id: 'sh3.color-picker.native',
13
+ priority: 0,
14
+ open: ({ initial }) => new Promise((resolve) => {
15
+ const el = document.createElement('input');
16
+ el.type = 'color';
17
+ if (initial && /^#[0-9a-f]{6}$/i.test(initial))
18
+ el.value = initial;
19
+ let settled = false;
20
+ const settle = (v) => {
21
+ if (settled)
22
+ return;
23
+ settled = true;
24
+ el.remove();
25
+ resolve(v);
26
+ };
27
+ el.addEventListener('change', () => settle(el.value));
28
+ el.addEventListener('cancel', () => settle(null));
29
+ el.style.position = 'fixed';
30
+ el.style.left = '-9999px';
31
+ document.body.appendChild(el);
32
+ el.click();
33
+ }),
34
+ };
35
+ export function pickColor(opts = {}) {
36
+ const contributed = list(COLOR_PICKER_POINT);
37
+ const winner = [...contributed, nativeFallback]
38
+ .sort((a, b) => { var _a, _b; return ((_a = b.priority) !== null && _a !== void 0 ? _a : 0) - ((_b = a.priority) !== null && _b !== void 0 ? _b : 0); })[0];
39
+ return winner.open(opts);
40
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { register, __resetContributionsForTest } from '../contributions/registry';
3
+ import { COLOR_PICKER_POINT } from './api';
4
+ import { pickColor } from './primitive';
5
+ describe('pickColor / selection', () => {
6
+ beforeEach(() => __resetContributionsForTest());
7
+ it('picks the highest-priority contributor', async () => {
8
+ const low = {
9
+ id: 'low',
10
+ priority: 5,
11
+ open: vi.fn(() => Promise.resolve('#111111')),
12
+ };
13
+ const high = {
14
+ id: 'high',
15
+ priority: 50,
16
+ open: vi.fn(() => Promise.resolve('#222222')),
17
+ };
18
+ register(COLOR_PICKER_POINT, low);
19
+ register(COLOR_PICKER_POINT, high);
20
+ await expect(pickColor({ initial: '#ff0000' })).resolves.toBe('#222222');
21
+ expect(high.open).toHaveBeenCalledWith({ initial: '#ff0000' });
22
+ expect(low.open).not.toHaveBeenCalled();
23
+ });
24
+ it('passes opts through unchanged', async () => {
25
+ const open = vi.fn(() => Promise.resolve(null));
26
+ register(COLOR_PICKER_POINT, { id: 'c', priority: 10, open });
27
+ const opts = { initial: '#abcdef', alpha: true, title: 'Pick' };
28
+ await pickColor(opts);
29
+ expect(open).toHaveBeenCalledWith(opts);
30
+ });
31
+ it('re-reads contributions on each call (no caching)', async () => {
32
+ const first = vi.fn(() => Promise.resolve('#111111'));
33
+ const unreg = register(COLOR_PICKER_POINT, { id: 'a', priority: 10, open: first });
34
+ await pickColor();
35
+ unreg();
36
+ const second = vi.fn(() => Promise.resolve('#222222'));
37
+ register(COLOR_PICKER_POINT, { id: 'b', priority: 10, open: second });
38
+ await pickColor();
39
+ expect(first).toHaveBeenCalledTimes(1);
40
+ expect(second).toHaveBeenCalledTimes(1);
41
+ });
42
+ });
@@ -0,0 +1,2 @@
1
+ import type { ColorApi } from './api';
2
+ export declare const colorApi: ColorApi;
@@ -0,0 +1,11 @@
1
+ /*
2
+ * shell.color assembled API.
3
+ *
4
+ * The selector + inlined native fallback live in primitive.ts;
5
+ * this file just binds `pick` onto the object exposed on the shell
6
+ * singleton, mirroring conflicts/shell-api.ts.
7
+ */
8
+ import { pickColor } from './primitive';
9
+ export const colorApi = {
10
+ pick: pickColor,
11
+ };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,4 @@
1
1
  export * from './api';
2
2
  export { default as Shell } from './Shell.svelte';
3
- export { default as Button } from './primitives/Button.svelte';
4
- export { provideIcons, getIconSprite, type ButtonVariant } from './primitives/icon-context';
5
3
  export type { ArtifactManifest } from './artifact';
6
4
  export * from './shell-shard/protocol';
package/dist/index.js CHANGED
@@ -11,6 +11,4 @@
11
11
  */
12
12
  export * from './api';
13
13
  export { default as Shell } from './Shell.svelte';
14
- export { default as Button } from './primitives/Button.svelte';
15
- export { provideIcons, getIconSprite } from './primitives/icon-context';
16
14
  export * from './shell-shard/protocol';
@@ -23,6 +23,26 @@ export declare function attachApp(app: App): void;
23
23
  * only and let renderers own all refcounts.
24
24
  */
25
25
  export declare function acquireAppSlotHolds(): void;
26
+ /**
27
+ * Rebuild the currently-attached app's active preset from a fresh copy
28
+ * of `app.initialLayout`. Discards in-place customizations (split sizes,
29
+ * tab order, drops, floats) and re-scopes slot-host refcount holds so
30
+ * the pool tears down hosts the new tree no longer references.
31
+ *
32
+ * The active preset name is preserved unless the app's initialLayout no
33
+ * longer declares it (the app shipped a new version that dropped the
34
+ * preset name); in that case the active preset is updated to the first
35
+ * canonical preset.
36
+ *
37
+ * Used by the `sh3.app.reset-layout` action — a recovery affordance
38
+ * available from the command palette only. Other presets are left alone;
39
+ * the user's customizations on them survive.
40
+ *
41
+ * Note: this re-scopes holds for the reset path only. The
42
+ * `presetManager.switch` slot-hold leak (TODO above on
43
+ * `acquireAppSlotHolds`) is a separate concern.
44
+ */
45
+ export declare function resetActivePresetToDefault(): void;
26
46
  /**
27
47
  * Detach the currently-attached app. Releases its refcount holds; the
28
48
  * pool's microtask cleanup drops the pooled hosts if they also have no
@@ -63,3 +83,10 @@ export declare const layoutStore: {
63
83
  * tests import this submodule path directly.
64
84
  */
65
85
  export declare function __resetLayoutStoreForTest(): void;
86
+ /**
87
+ * Test-only inspection: returns a shallow copy of the currently-attached
88
+ * app's held slot ids, in acquisition order. Returns `null` when no app
89
+ * is attached. Not exported from `src/index.ts` — tests import this
90
+ * submodule path directly.
91
+ */
92
+ export declare function __inspectAppEntryHeldSlotIdsForTest(): string[] | null;