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
@@ -0,0 +1,28 @@
1
+ import type { ActionEntry } from './registry';
2
+ import type { DispatcherState } from './dispatcher.svelte';
3
+ import type { MenuContainer } from '../apps/types';
4
+ export interface MenuBarItem {
5
+ id: string;
6
+ label: string;
7
+ shortcut: string | null;
8
+ group: string;
9
+ icon: string | undefined;
10
+ }
11
+ /**
12
+ * Resolved container list for the currently-active app:
13
+ * - activeAppId == null → returns []
14
+ * - declared has entries → returns declared, sorted by `order`
15
+ * ascending then declaration order
16
+ * for ties / undefined
17
+ * - declared is undefined → returns a copy of DEFAULT_MENU_CONTAINERS
18
+ *
19
+ * Callers can render unconditionally; the empty-array case naturally
20
+ * suppresses the menu bar at home.
21
+ */
22
+ export declare function resolveMenuContainers(activeAppId: string | null, declared: readonly MenuContainer[] | undefined): MenuContainer[];
23
+ /**
24
+ * Items targeting `containerId`, filtered by current scope activation
25
+ * and de-duplicated to the innermost active scope per action id (mirrors
26
+ * contextMenuModel).
27
+ */
28
+ export declare function resolveMenuItems(entries: readonly ActionEntry[], state: DispatcherState, containerId: string): MenuBarItem[];
@@ -0,0 +1,67 @@
1
+ /*
2
+ * Pure model layer for the menu bar: resolves container list for the
3
+ * active app, and resolves per-container item lists by filtering the
4
+ * action registry by `menuItem` + scope-activation. Mirrors the
5
+ * de-duplication semantics of contextMenuModel.
6
+ */
7
+ import { effectiveShortcut } from './bindings';
8
+ import { innermostActiveScope } from './scope-helpers';
9
+ import { DEFAULT_MENU_CONTAINERS } from './defaultMenuContainers';
10
+ /**
11
+ * Resolved container list for the currently-active app:
12
+ * - activeAppId == null → returns []
13
+ * - declared has entries → returns declared, sorted by `order`
14
+ * ascending then declaration order
15
+ * for ties / undefined
16
+ * - declared is undefined → returns a copy of DEFAULT_MENU_CONTAINERS
17
+ *
18
+ * Callers can render unconditionally; the empty-array case naturally
19
+ * suppresses the menu bar at home.
20
+ */
21
+ export function resolveMenuContainers(activeAppId, declared) {
22
+ if (activeAppId == null)
23
+ return [];
24
+ if (declared == null)
25
+ return DEFAULT_MENU_CONTAINERS.slice();
26
+ const indexed = declared.map((c, i) => ({ c, i }));
27
+ indexed.sort((a, b) => {
28
+ const ao = a.c.order;
29
+ const bo = b.c.order;
30
+ if (ao != null && bo != null)
31
+ return ao - bo || a.i - b.i;
32
+ if (ao != null)
33
+ return -1;
34
+ if (bo != null)
35
+ return 1;
36
+ return a.i - b.i;
37
+ });
38
+ return indexed.map((x) => x.c);
39
+ }
40
+ /**
41
+ * Items targeting `containerId`, filtered by current scope activation
42
+ * and de-duplicated to the innermost active scope per action id (mirrors
43
+ * contextMenuModel).
44
+ */
45
+ export function resolveMenuItems(entries, state, containerId) {
46
+ var _a;
47
+ const out = [];
48
+ const seen = new Set();
49
+ for (const entry of entries) {
50
+ if (entry.action.menuItem !== containerId)
51
+ continue;
52
+ if (seen.has(entry.action.id))
53
+ continue;
54
+ const winning = innermostActiveScope(entry.action.scope, state, entry.ownerShardId);
55
+ if (!winning)
56
+ continue;
57
+ seen.add(entry.action.id);
58
+ out.push({
59
+ id: entry.action.id,
60
+ label: entry.action.label,
61
+ shortcut: effectiveShortcut(entry.action, state.bindings, state.platform),
62
+ group: (_a = entry.action.group) !== null && _a !== void 0 ? _a : '',
63
+ icon: entry.action.icon,
64
+ });
65
+ }
66
+ return out;
67
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { resolveMenuContainers, resolveMenuItems, } from './menuBarModel';
3
+ const mkEntry = (a, owner = 'shard.x') => ({
4
+ ownerShardId: owner,
5
+ action: Object.assign({ id: 'a', label: 'A', scope: 'home', run: () => { } }, a),
6
+ });
7
+ const mkState = (o = {}) => (Object.assign({ activeAppId: null, activeAppRequiredShards: new Set(), autostartShards: new Set(), mountedViewIds: new Set(), focusedViewId: null, selection: null, bindings: {}, platform: 'other' }, o));
8
+ describe('resolveMenuContainers', () => {
9
+ it('returns [] when no app is active', () => {
10
+ expect(resolveMenuContainers(null, undefined)).toEqual([]);
11
+ });
12
+ it('returns DEFAULT_MENU_CONTAINERS when app has no manifest.menus', () => {
13
+ const out = resolveMenuContainers('app.a', undefined);
14
+ expect(out.map((c) => c.id)).toEqual(['file', 'edit', 'view', 'window', 'help']);
15
+ });
16
+ it('returns manifest.menus when declared', () => {
17
+ const declared = [
18
+ { id: 'project', label: 'Project' },
19
+ { id: 'help', label: 'Help' },
20
+ ];
21
+ expect(resolveMenuContainers('app.a', declared).map((c) => c.id))
22
+ .toEqual(['project', 'help']);
23
+ });
24
+ it('sorts by `order` ascending, then by declaration order for ties/undefined', () => {
25
+ const declared = [
26
+ { id: 'a', label: 'A', order: 10 },
27
+ { id: 'b', label: 'B' },
28
+ { id: 'c', label: 'C', order: 5 },
29
+ { id: 'd', label: 'D' },
30
+ ];
31
+ expect(resolveMenuContainers('app.a', declared).map((c) => c.id))
32
+ .toEqual(['c', 'a', 'b', 'd']);
33
+ });
34
+ });
35
+ describe('resolveMenuItems', () => {
36
+ const stateWithApp = mkState({
37
+ activeAppId: 'app.a',
38
+ activeAppRequiredShards: new Set(['shard.x']),
39
+ });
40
+ it('returns only actions whose menuItem matches the container id', () => {
41
+ const entries = [
42
+ mkEntry({ id: 'open', scope: 'app', menuItem: 'file', label: 'Open' }),
43
+ mkEntry({ id: 'copy', scope: 'app', menuItem: 'edit', label: 'Copy' }),
44
+ mkEntry({ id: 'close', scope: 'app', menuItem: 'file', label: 'Close' }),
45
+ ];
46
+ const out = resolveMenuItems(entries, stateWithApp, 'file');
47
+ expect(out.map((i) => i.id)).toEqual(['open', 'close']);
48
+ });
49
+ it('skips actions whose scope is not currently active', () => {
50
+ const entries = [
51
+ mkEntry({ id: 'open', scope: 'app', menuItem: 'file', label: 'Open' }),
52
+ mkEntry({ id: 'help', scope: 'home', menuItem: 'file', label: 'Help' }),
53
+ ];
54
+ const out = resolveMenuItems(entries, stateWithApp, 'file');
55
+ expect(out.map((i) => i.id)).toEqual(['open']);
56
+ });
57
+ it('skips actions without a menuItem field', () => {
58
+ const entries = [
59
+ mkEntry({ id: 'a', scope: 'app', menuItem: 'file', label: 'A' }),
60
+ mkEntry({ id: 'b', scope: 'app', label: 'B' }),
61
+ ];
62
+ const out = resolveMenuItems(entries, stateWithApp, 'file');
63
+ expect(out.map((i) => i.id)).toEqual(['a']);
64
+ });
65
+ it('returns [] for an unknown container id', () => {
66
+ const entries = [
67
+ mkEntry({ id: 'open', scope: 'app', menuItem: 'file', label: 'Open' }),
68
+ ];
69
+ expect(resolveMenuItems(entries, stateWithApp, 'sausage')).toEqual([]);
70
+ });
71
+ it('de-duplicates multi-scope actions by innermost active scope', () => {
72
+ const state = mkState({
73
+ activeAppId: 'app.a',
74
+ activeAppRequiredShards: new Set(['shard.x']),
75
+ autostartShards: new Set(['shard.x']),
76
+ });
77
+ const entries = [
78
+ mkEntry({ id: 'p', scope: ['home', 'app'], menuItem: 'file', label: 'P' }),
79
+ ];
80
+ const out = resolveMenuItems(entries, state, 'file');
81
+ expect(out).toHaveLength(1);
82
+ expect(out[0].id).toBe('p');
83
+ });
84
+ });
@@ -1,22 +1,11 @@
1
- import { isScopeActive } from './dispatcher.svelte';
1
+ /*
2
+ * Palette candidate builder — returns every active (paletteItem !== false)
3
+ * action, deduplicated, with shortcut and scope badge resolved. Uses
4
+ * innermost-first scope selection so the badge matches keyboard dispatch
5
+ * and context-menu tiering (audit: RFC #24).
6
+ */
2
7
  import { effectiveShortcut } from './bindings';
3
- function normalize(s) {
4
- return Array.isArray(s) ? s : [s];
5
- }
6
- function anyScopeActive(scope, state, owner) {
7
- for (const s of normalize(scope)) {
8
- if (isScopeActive(s, state, owner))
9
- return s;
10
- }
11
- return null;
12
- }
13
- function scopeBadge(scope) {
14
- if (scope === 'home' || scope === 'app')
15
- return null;
16
- if (typeof scope === 'string')
17
- return scope; // view:X / focus:X
18
- return scope.element;
19
- }
8
+ import { innermostActiveScope, scopeBadge } from './scope-helpers';
20
9
  export function buildPaletteCandidates(entries, state) {
21
10
  const out = [];
22
11
  const seen = new Set();
@@ -25,15 +14,15 @@ export function buildPaletteCandidates(entries, state) {
25
14
  continue;
26
15
  if (seen.has(entry.action.id))
27
16
  continue;
28
- const active = anyScopeActive(entry.action.scope, state, entry.ownerShardId);
29
- if (!active)
17
+ const winning = innermostActiveScope(entry.action.scope, state, entry.ownerShardId);
18
+ if (!winning)
30
19
  continue;
31
20
  seen.add(entry.action.id);
32
21
  out.push({
33
22
  id: entry.action.id,
34
23
  label: entry.action.label,
35
24
  shortcut: effectiveShortcut(entry.action, state.bindings, state.platform),
36
- scopeBadge: scopeBadge(active),
25
+ scopeBadge: scopeBadge(winning),
37
26
  });
38
27
  }
39
28
  return out;
@@ -30,4 +30,20 @@ describe('buildPaletteCandidates', () => {
30
30
  const out = buildPaletteCandidates(entries, state);
31
31
  expect(out).toHaveLength(1);
32
32
  });
33
+ it('reports the innermost active scope in scopeBadge for multi-tier actions', () => {
34
+ const entries = [
35
+ mkEntry({
36
+ id: 'm', label: 'M',
37
+ scope: ['app', 'view:editor'],
38
+ }, '__sh3core__'),
39
+ ];
40
+ const state = mkState({
41
+ activeAppId: 'a',
42
+ autostartShards: new Set(['__sh3core__']),
43
+ mountedViewIds: new Set(['editor']),
44
+ });
45
+ const out = buildPaletteCandidates(entries, state);
46
+ expect(out).toHaveLength(1);
47
+ expect(out[0].scopeBadge).toBe('view:editor');
48
+ });
33
49
  });
@@ -0,0 +1,11 @@
1
+ import type { AtomicScope, ActionScope } from './types';
2
+ import { type DispatcherState, type TierName } from './dispatcher.svelte';
3
+ export declare function scopeToTier(scope: AtomicScope): TierName;
4
+ export declare function normalizeScope(scope: ActionScope): AtomicScope[];
5
+ export declare function scopeBadge(scope: AtomicScope): string | null;
6
+ /**
7
+ * Walk TIER_ORDER innermost→outermost; return the first `AtomicScope` of
8
+ * the given action whose tier is currently active. Returns `null` if no
9
+ * scope is active.
10
+ */
11
+ export declare function innermostActiveScope(scope: ActionScope, state: DispatcherState, ownerShardId: string): AtomicScope | null;
@@ -0,0 +1,51 @@
1
+ /*
2
+ * Pure scope/tier helpers shared by the dispatcher, context menu,
3
+ * palette, and listActive(). Keep this file side-effect free — it is
4
+ * imported by both reactive and non-reactive modules.
5
+ */
6
+ import { isScopeActive, TIER_ORDER, } from './dispatcher.svelte';
7
+ export function scopeToTier(scope) {
8
+ if (scope === 'home')
9
+ return 'home';
10
+ if (scope === 'app')
11
+ return 'app';
12
+ if (typeof scope === 'string' && scope.startsWith('view:'))
13
+ return 'view';
14
+ if (typeof scope === 'string' && scope.startsWith('focus:'))
15
+ return 'focus';
16
+ return 'element';
17
+ }
18
+ export function normalizeScope(scope) {
19
+ return Array.isArray(scope) ? scope : [scope];
20
+ }
21
+ export function scopeBadge(scope) {
22
+ if (scope === 'home' || scope === 'app')
23
+ return null;
24
+ if (typeof scope === 'string')
25
+ return scope;
26
+ return scope.element;
27
+ }
28
+ /**
29
+ * Walk TIER_ORDER innermost→outermost; return the first `AtomicScope` of
30
+ * the given action whose tier is currently active. Returns `null` if no
31
+ * scope is active.
32
+ */
33
+ export function innermostActiveScope(scope, state, ownerShardId) {
34
+ var _a;
35
+ const scopes = normalizeScope(scope);
36
+ const buckets = {};
37
+ for (const s of scopes) {
38
+ const tier = scopeToTier(s);
39
+ ((_a = buckets[tier]) !== null && _a !== void 0 ? _a : (buckets[tier] = [])).push(s);
40
+ }
41
+ for (const tier of TIER_ORDER) {
42
+ const bucket = buckets[tier];
43
+ if (!bucket)
44
+ continue;
45
+ for (const s of bucket) {
46
+ if (isScopeActive(s, state, ownerShardId))
47
+ return s;
48
+ }
49
+ }
50
+ return null;
51
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { scopeToTier, normalizeScope, scopeBadge, innermostActiveScope, } from './scope-helpers';
3
+ const mkState = (o = {}) => (Object.assign({ activeAppId: null, activeAppRequiredShards: new Set(), autostartShards: new Set(), mountedViewIds: new Set(), focusedViewId: null, selection: null, bindings: {}, platform: 'other' }, o));
4
+ describe('scopeToTier', () => {
5
+ it('maps atoms to tier names', () => {
6
+ expect(scopeToTier('home')).toBe('home');
7
+ expect(scopeToTier('app')).toBe('app');
8
+ expect(scopeToTier('view:editor')).toBe('view');
9
+ expect(scopeToTier('focus:pane-1')).toBe('focus');
10
+ expect(scopeToTier({ element: 'row' })).toBe('element');
11
+ });
12
+ });
13
+ describe('normalizeScope', () => {
14
+ it('wraps single scope', () => {
15
+ expect(normalizeScope('home')).toEqual(['home']);
16
+ });
17
+ it('returns arrays as-is', () => {
18
+ expect(normalizeScope(['home', 'app'])).toEqual(['home', 'app']);
19
+ });
20
+ });
21
+ describe('scopeBadge', () => {
22
+ it('returns null for home/app', () => {
23
+ expect(scopeBadge('home')).toBeNull();
24
+ expect(scopeBadge('app')).toBeNull();
25
+ });
26
+ it('returns the full string for view/focus scopes', () => {
27
+ expect(scopeBadge('view:editor')).toBe('view:editor');
28
+ expect(scopeBadge('focus:pane-1')).toBe('focus:pane-1');
29
+ });
30
+ it('returns the element type for element scopes', () => {
31
+ expect(scopeBadge({ element: 'row' })).toBe('row');
32
+ });
33
+ });
34
+ describe('innermostActiveScope', () => {
35
+ it('returns null when no scope is active', () => {
36
+ expect(innermostActiveScope('app', mkState(), 'owner')).toBeNull();
37
+ });
38
+ it('picks the innermost active tier across a multi-scope action', () => {
39
+ const state = mkState({
40
+ activeAppId: 'a', autostartShards: new Set(['owner']),
41
+ mountedViewIds: new Set(['editor']),
42
+ });
43
+ const winner = innermostActiveScope(['app', 'view:editor'], state, 'owner');
44
+ expect(winner).toBe('view:editor');
45
+ });
46
+ it('falls back to outer tier when inner is inactive', () => {
47
+ const state = mkState({
48
+ activeAppId: 'a', autostartShards: new Set(['owner']),
49
+ });
50
+ const winner = innermostActiveScope(['app', 'view:editor'], state, 'owner');
51
+ expect(winner).toBe('app');
52
+ });
53
+ it('element scope beats view scope when both active', () => {
54
+ const state = mkState({
55
+ activeAppId: 'a', autostartShards: new Set(['owner']),
56
+ mountedViewIds: new Set(['editor']),
57
+ selection: { type: 'row', ref: {}, ownerShardId: 'owner' },
58
+ });
59
+ const winner = innermostActiveScope(['view:editor', { element: 'row' }], state, 'owner');
60
+ expect(winner).toEqual({ element: 'row' });
61
+ });
62
+ });
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { shell } from '../shellRuntime.svelte';
3
3
  import { __setBindingsZone } from './bindings-store';
4
4
  import { __resetDispatcherStateForTest, setActiveApp } from './state.svelte';
5
+ import { registerAction, __resetActionsRegistryForTest } from './registry';
5
6
  describe('shell.actions facade', () => {
6
7
  beforeEach(() => {
7
8
  __setBindingsZone({ bindings: {} });
@@ -20,3 +21,52 @@ describe('shell.actions facade', () => {
20
21
  expect(now['shard.x.save']).toBeUndefined();
21
22
  });
22
23
  });
24
+ describe('shell.actions.listActive', () => {
25
+ beforeEach(() => {
26
+ __resetActionsRegistryForTest();
27
+ __resetDispatcherStateForTest();
28
+ });
29
+ it('returns descriptors for currently-active registered actions', () => {
30
+ const dispose = registerAction({
31
+ id: 'home.hello', label: 'Hello', scope: 'home',
32
+ defaultShortcut: 'Mod+H', run: () => { },
33
+ }, 'shard.test');
34
+ const snap = shell.actions.listActive();
35
+ expect(snap.map((d) => d.id)).toContain('home.hello');
36
+ dispose();
37
+ });
38
+ it('snapshot is stable across calls (returns fresh array)', () => {
39
+ registerAction({
40
+ id: 'home.a', label: 'A', scope: 'home', run: () => { },
41
+ }, 'shard.test');
42
+ const a = shell.actions.listActive();
43
+ const b = shell.actions.listActive();
44
+ expect(a).not.toBe(b);
45
+ expect(a.map((d) => d.id)).toEqual(b.map((d) => d.id));
46
+ });
47
+ });
48
+ describe('shell.actions.onActiveChange', () => {
49
+ beforeEach(() => {
50
+ __resetActionsRegistryForTest();
51
+ __resetDispatcherStateForTest();
52
+ });
53
+ it('fires on dispatcher state change', () => {
54
+ let n = 0;
55
+ const off = shell.actions.onActiveChange(() => { n++; });
56
+ setActiveApp('a', new Set());
57
+ expect(n).toBe(1);
58
+ off();
59
+ });
60
+ it('fires when an action is registered or unregistered', () => {
61
+ let n = 0;
62
+ const off = shell.actions.onActiveChange(() => { n++; });
63
+ const dispose = registerAction({
64
+ id: 't', label: 'T', scope: 'home', run: () => { },
65
+ }, 'shard.test');
66
+ expect(n).toBeGreaterThanOrEqual(1);
67
+ const afterRegister = n;
68
+ dispose();
69
+ expect(n).toBeGreaterThan(afterRegister);
70
+ off();
71
+ });
72
+ });
@@ -1,4 +1,16 @@
1
1
  import type { DispatcherState } from './dispatcher.svelte';
2
+ /**
3
+ * Subscribe to any change that could affect the set of currently-active
4
+ * actions or their resolved shortcuts (app/view/focus/selection/bindings
5
+ * transitions). Call sites outside this module (e.g., the shell
6
+ * assembling registry-change notifications) dispatch via
7
+ * {@link __notifyActiveChange}.
8
+ */
9
+ export declare function onActiveChange(cb: () => void): () => void;
10
+ /** Internal — fired by the shell runtime when the action registry mutates. */
11
+ export declare function __notifyActiveChange(): void;
12
+ /** Test-only alias for the internal notifier. */
13
+ export declare const __notifyActiveChangeForTest: typeof __notifyActiveChange;
2
14
  export declare function setActiveApp(appId: string | null, requiredShards: Set<string>): void;
3
15
  export declare function setAutostartShards(shards: Set<string>): void;
4
16
  export declare function setMountedViewIds(ids: Set<string>): void;
@@ -18,15 +18,46 @@ let mountedViewIds = $state(new Set());
18
18
  let focusedViewId = $state(null);
19
19
  let userBindings = $state({});
20
20
  const platform = detectPlatform();
21
+ const activeChangeListeners = new Set();
22
+ function notifyActiveChange() {
23
+ for (const cb of activeChangeListeners) {
24
+ try {
25
+ cb();
26
+ }
27
+ catch (err) {
28
+ console.error('[sh3] onActiveChange listener threw', err);
29
+ }
30
+ }
31
+ }
32
+ /**
33
+ * Subscribe to any change that could affect the set of currently-active
34
+ * actions or their resolved shortcuts (app/view/focus/selection/bindings
35
+ * transitions). Call sites outside this module (e.g., the shell
36
+ * assembling registry-change notifications) dispatch via
37
+ * {@link __notifyActiveChange}.
38
+ */
39
+ export function onActiveChange(cb) {
40
+ activeChangeListeners.add(cb);
41
+ return () => { activeChangeListeners.delete(cb); };
42
+ }
43
+ /** Internal — fired by the shell runtime when the action registry mutates. */
44
+ export function __notifyActiveChange() {
45
+ notifyActiveChange();
46
+ }
47
+ /** Test-only alias for the internal notifier. */
48
+ export const __notifyActiveChangeForTest = __notifyActiveChange;
21
49
  export function setActiveApp(appId, requiredShards) {
22
50
  activeAppId = appId;
23
51
  activeAppRequiredShards = new Set(requiredShards);
52
+ notifyActiveChange();
24
53
  }
25
54
  export function setAutostartShards(shards) {
26
55
  autostartShards = new Set(shards);
56
+ notifyActiveChange();
27
57
  }
28
58
  export function setMountedViewIds(ids) {
29
59
  mountedViewIds = new Set(ids);
60
+ notifyActiveChange();
30
61
  }
31
62
  /**
32
63
  * One-shot snapshot: walk the active layout tree and update
@@ -42,12 +73,15 @@ export function syncMountedViewIdsFromLayout() {
42
73
  ids.add(r.viewId);
43
74
  }
44
75
  mountedViewIds = ids;
76
+ notifyActiveChange();
45
77
  }
46
78
  export function setFocusedViewId(id) {
47
79
  focusedViewId = id;
80
+ notifyActiveChange();
48
81
  }
49
82
  export function setUserBindings(bindings) {
50
83
  userBindings = Object.assign({}, bindings);
84
+ notifyActiveChange();
51
85
  }
52
86
  export function getLiveDispatcherState() {
53
87
  return {
@@ -64,6 +98,7 @@ export function getLiveDispatcherState() {
64
98
  export function addAutostartShard(id) {
65
99
  if (!autostartShards.has(id)) {
66
100
  autostartShards = new Set([...autostartShards, id]);
101
+ notifyActiveChange();
67
102
  }
68
103
  }
69
104
  export function __resetDispatcherStateForTest() {
@@ -73,4 +108,5 @@ export function __resetDispatcherStateForTest() {
73
108
  mountedViewIds = new Set();
74
109
  focusedViewId = null;
75
110
  userBindings = {};
111
+ activeChangeListeners.clear();
76
112
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
- import { setActiveApp, setFocusedViewId, setMountedViewIds, setUserBindings, getLiveDispatcherState, __resetDispatcherStateForTest, } from './state.svelte';
2
+ import { setActiveApp, setAutostartShards, setFocusedViewId, setMountedViewIds, setUserBindings, getLiveDispatcherState, onActiveChange, __notifyActiveChangeForTest, __resetDispatcherStateForTest, } from './state.svelte';
3
3
  import { __resetSelectionForTest, makeSelectionApi } from './selection.svelte';
4
4
  describe('live dispatcher state', () => {
5
5
  beforeEach(() => {
@@ -38,3 +38,28 @@ describe('live dispatcher state', () => {
38
38
  expect((_a = getLiveDispatcherState().selection) === null || _a === void 0 ? void 0 : _a.type).toBe('orb');
39
39
  });
40
40
  });
41
+ describe('onActiveChange', () => {
42
+ beforeEach(() => {
43
+ __resetDispatcherStateForTest();
44
+ __resetSelectionForTest();
45
+ });
46
+ it('fires on every setter', () => {
47
+ let n = 0;
48
+ const off = onActiveChange(() => { n++; });
49
+ setActiveApp('a', new Set(['s']));
50
+ setAutostartShards(new Set(['s']));
51
+ setMountedViewIds(new Set(['v']));
52
+ setFocusedViewId('v');
53
+ setUserBindings({ foo: 'Ctrl+K' });
54
+ expect(n).toBe(5);
55
+ off();
56
+ setActiveApp(null, new Set());
57
+ expect(n).toBe(5);
58
+ });
59
+ it('fires on external notify (used for registry change)', () => {
60
+ let n = 0;
61
+ onActiveChange(() => { n++; });
62
+ __notifyActiveChangeForTest();
63
+ expect(n).toBe(1);
64
+ });
65
+ });
@@ -8,6 +8,14 @@ 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;
13
21
  group?: string;
@@ -54,3 +62,44 @@ export interface ResolvedAction {
54
62
  ownerShardId: string;
55
63
  effectiveShortcut: string | null;
56
64
  }
65
+ /**
66
+ * Where an action's effective shortcut came from:
67
+ * - `'default'` — `defaultShortcut` resolved for the current platform
68
+ * - `'user'` — the user supplied an explicit override
69
+ * - `'disabled'` — the user rebound to `null` to disable dispatch
70
+ * - `'none'` — no default and no override
71
+ */
72
+ export type BindingSource = 'default' | 'user' | 'disabled' | 'none';
73
+ /**
74
+ * Read-only snapshot describing one action that is currently active.
75
+ * Produced by `shell.actions.listActive()`. One descriptor per action id,
76
+ * reporting the innermost active scope (same tier order as keyboard
77
+ * dispatch: element > focus > view > app > home).
78
+ */
79
+ export interface ActiveActionDescriptor {
80
+ /** Stable action id as registered. */
81
+ id: string;
82
+ /** Human-readable label as registered. */
83
+ label: string;
84
+ /**
85
+ * Shortcut string as it would dispatch right now (platform-resolved,
86
+ * user-rebind applied). `null` when `bindingSource` is `'disabled'` or
87
+ * `'none'`.
88
+ */
89
+ effectiveShortcut: string | null;
90
+ /**
91
+ * Where the effective shortcut came from. Help UIs can distinguish
92
+ * user-disabled (render greyed) from no-shortcut (hide).
93
+ */
94
+ bindingSource: BindingSource;
95
+ /** The innermost active tier of the action's scope. */
96
+ scope: AtomicScope;
97
+ /** Display hint: `null` for home/app, else full string or element type. */
98
+ scopeBadge: string | null;
99
+ /** Carried through from the registered action. */
100
+ group?: string;
101
+ icon?: string;
102
+ ownerShardId: string;
103
+ paletteItem: boolean;
104
+ contextItem: boolean;
105
+ }
package/dist/api.d.ts CHANGED
@@ -2,6 +2,7 @@ export { shell } from './shellRuntime.svelte';
2
2
  export type { Shell } from './shellRuntime.svelte';
3
3
  export type { Shard, ShardManifest, SourceShard, SourceShardManifest, ShardContext, ViewDeclaration, ViewFactory, ViewHandle, MountContext, } from './shards/types';
4
4
  export type { LayoutNode, SplitNode, TabsNode, SlotNode, TabEntry, SplitDirection, SizeMode, LayoutTree, FloatEntry, LayoutPreset, CanonicalPreset, TreeRootRef as SlotLocation, } from './layout/types';
5
+ export type { ActiveActionDescriptor, BindingSource, AtomicScope, ActionScope, } from './actions/types';
5
6
  export type { FloatManager, FloatOptions } from './overlays/float';
6
7
  export type { ModalManager } from './overlays/modal';
7
8
  export type { PopupManager } from './overlays/popup';
@@ -24,6 +25,8 @@ export type { SyncPolicy, SyncPolicyRule, DocStatus, ConflictFile, ConflictBranc
24
25
  export { PERMISSION_SYNC_PEER, PERMISSION_SYNC_POLICY } from './documents/sync-types';
25
26
  export type { ConflictItem, ConflictBranch as ConflictManagerBranch, ResolveOptions as ConflictResolveOptions, ResolveOutcome as ConflictResolveOutcome, ResolveDocumentsInput as ConflictResolveDocumentsInput, DocsResolveOutcome as ConflictDocsResolveOutcome, ConflictRenderer, ConflictRendererProps, ConflictsApi, } from './conflicts/api';
26
27
  export { CONFLICT_RENDERER_POINT, ConflictPermissionError, ConflictSessionOrphanedError, } from './conflicts/api';
28
+ export type { ColorPickOptions, ColorContribution, ColorApi, } from './color/api';
29
+ export { COLOR_PICKER_POINT } from './color/api';
27
30
  export { registeredShards, activeShards } from './shards/activate.svelte';
28
31
  export type { RegistryIndex, PackageEntry, PackageVersion, RequiredDependency, InstalledPackage, InstallResult, PackageMeta, } from './registry/types';
29
32
  export type { ResolvedPackage } from './registry/client';
@@ -47,3 +50,5 @@ export { listVerbs } from './shards/registry';
47
50
  export { VERSION } from './version';
48
51
  export declare const FRAMEWORK_SHARD_IDS: readonly string[];
49
52
  export { setTokenOverrides, clearTokenOverrides, getTokenOverrides, } from './theme';
53
+ export { default as Button } from './primitives/Button.svelte';
54
+ export { provideIcons, getIconSprite, type ButtonVariant } from './primitives/icon-context';