sh3-core 0.10.5 → 0.11.2

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 (115) hide show
  1. package/dist/Shell.svelte +12 -31
  2. package/dist/__test__/reset.js +6 -0
  3. package/dist/actions/CommandPalette.svelte +68 -0
  4. package/dist/actions/CommandPalette.svelte.d.ts +11 -0
  5. package/dist/actions/ContextMenu.svelte +97 -0
  6. package/dist/actions/ContextMenu.svelte.d.ts +9 -0
  7. package/dist/actions/bindings-store.d.ts +8 -0
  8. package/dist/actions/bindings-store.js +27 -0
  9. package/dist/actions/bindings-store.test.d.ts +1 -0
  10. package/dist/actions/bindings-store.test.js +25 -0
  11. package/dist/actions/bindings.d.ts +4 -0
  12. package/dist/actions/bindings.js +17 -0
  13. package/dist/actions/bindings.test.d.ts +1 -0
  14. package/dist/actions/bindings.test.js +30 -0
  15. package/dist/actions/contextMenuModel.d.ts +16 -0
  16. package/dist/actions/contextMenuModel.js +71 -0
  17. package/dist/actions/contextMenuModel.test.d.ts +1 -0
  18. package/dist/actions/contextMenuModel.test.js +44 -0
  19. package/dist/actions/dispatcher.svelte.d.ts +34 -0
  20. package/dist/actions/dispatcher.svelte.js +117 -0
  21. package/dist/actions/dispatcher.test.d.ts +1 -0
  22. package/dist/actions/dispatcher.test.js +155 -0
  23. package/dist/actions/listeners.d.ts +11 -0
  24. package/dist/actions/listeners.js +180 -0
  25. package/dist/actions/listeners.test.d.ts +1 -0
  26. package/dist/actions/listeners.test.js +149 -0
  27. package/dist/actions/palette-scorer.d.ts +11 -0
  28. package/dist/actions/palette-scorer.js +49 -0
  29. package/dist/actions/palette-scorer.test.d.ts +1 -0
  30. package/dist/actions/palette-scorer.test.js +40 -0
  31. package/dist/actions/paletteModel.d.ts +4 -0
  32. package/dist/actions/paletteModel.js +40 -0
  33. package/dist/actions/paletteModel.test.d.ts +1 -0
  34. package/dist/actions/paletteModel.test.js +33 -0
  35. package/dist/actions/registry.d.ts +10 -0
  36. package/dist/actions/registry.js +36 -0
  37. package/dist/actions/registry.test.d.ts +1 -0
  38. package/dist/actions/registry.test.js +49 -0
  39. package/dist/actions/selection.svelte.d.ts +8 -0
  40. package/dist/actions/selection.svelte.js +44 -0
  41. package/dist/actions/selection.test.d.ts +1 -0
  42. package/dist/actions/selection.test.js +51 -0
  43. package/dist/actions/shardContext.test.d.ts +1 -0
  44. package/dist/actions/shardContext.test.js +41 -0
  45. package/dist/actions/shellActions.test.d.ts +1 -0
  46. package/dist/actions/shellActions.test.js +22 -0
  47. package/dist/actions/shortcuts.d.ts +5 -0
  48. package/dist/actions/shortcuts.js +87 -0
  49. package/dist/actions/shortcuts.test.d.ts +1 -0
  50. package/dist/actions/shortcuts.test.js +49 -0
  51. package/dist/actions/state.svelte.d.ts +16 -0
  52. package/dist/actions/state.svelte.js +76 -0
  53. package/dist/actions/state.test.d.ts +1 -0
  54. package/dist/actions/state.test.js +40 -0
  55. package/dist/actions/syncMountedViewIds.test.d.ts +1 -0
  56. package/dist/actions/syncMountedViewIds.test.js +97 -0
  57. package/dist/actions/types.d.ts +56 -0
  58. package/dist/actions/types.js +7 -0
  59. package/dist/api.d.ts +2 -2
  60. package/dist/api.js +1 -1
  61. package/dist/apps/lifecycle.js +13 -3
  62. package/dist/createShell.js +4 -1
  63. package/dist/host.js +6 -3
  64. package/dist/index.d.ts +2 -0
  65. package/dist/index.js +2 -0
  66. package/dist/layout/inspection.d.ts +11 -1
  67. package/dist/layout/inspection.js +13 -1
  68. package/dist/layout/ops-locate.test.d.ts +1 -0
  69. package/dist/layout/ops-locate.test.js +103 -0
  70. package/dist/layout/ops.d.ts +8 -0
  71. package/dist/layout/ops.js +27 -0
  72. package/dist/layout/slotHostPool.svelte.js +24 -0
  73. package/dist/layout/slotHostPool.test.js +14 -0
  74. package/dist/layout/types.d.ts +7 -0
  75. package/dist/overlays/FloatFrame.svelte +23 -11
  76. package/dist/overlays/ModalFrame.svelte +9 -1
  77. package/dist/overlays/ModalFrame.svelte.d.ts +1 -0
  78. package/dist/overlays/__test__/DummyFrame.svelte +6 -0
  79. package/dist/overlays/__test__/DummyFrame.svelte.d.ts +6 -0
  80. package/dist/overlays/float.d.ts +6 -0
  81. package/dist/overlays/float.js +24 -9
  82. package/dist/overlays/float.test.js +175 -0
  83. package/dist/overlays/floatDismiss.d.ts +8 -0
  84. package/dist/overlays/floatDismiss.js +68 -0
  85. package/dist/overlays/modal.js +5 -1
  86. package/dist/overlays/modal.test.d.ts +1 -0
  87. package/dist/overlays/modal.test.js +55 -0
  88. package/dist/overlays/popup.d.ts +2 -0
  89. package/dist/overlays/popup.js +24 -4
  90. package/dist/overlays/popup.test.d.ts +1 -0
  91. package/dist/overlays/popup.test.js +95 -0
  92. package/dist/overlays/types.d.ts +17 -1
  93. package/dist/primitives/Button.svelte +144 -0
  94. package/dist/primitives/Button.svelte.d.ts +18 -0
  95. package/dist/primitives/icon-context.d.ts +15 -0
  96. package/dist/primitives/icon-context.js +29 -0
  97. package/dist/sh3core-shard/sh3coreShard.svelte.js +50 -0
  98. package/dist/shards/activate.svelte.js +14 -0
  99. package/dist/shards/types.d.ts +19 -0
  100. package/dist/shards/types.js +5 -4
  101. package/dist/shell-shard/locateSlot.test.d.ts +1 -0
  102. package/dist/shell-shard/locateSlot.test.js +101 -0
  103. package/dist/shell-shard/shellShard.svelte.d.ts +7 -0
  104. package/dist/shell-shard/shellShard.svelte.js +34 -1
  105. package/dist/shellRuntime.svelte.d.ts +19 -0
  106. package/dist/shellRuntime.svelte.js +30 -0
  107. package/dist/tokens.css +11 -1
  108. package/dist/verbs/types.d.ts +9 -0
  109. package/dist/version.d.ts +1 -1
  110. package/dist/version.js +1 -1
  111. package/package.json +1 -1
  112. package/dist/apps/terminal/manifest.d.ts +0 -8
  113. package/dist/apps/terminal/manifest.js +0 -14
  114. package/dist/apps/terminal/terminal-app.d.ts +0 -7
  115. package/dist/apps/terminal/terminal-app.js +0 -14
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { tick } from 'svelte';
3
+ import { popupManager, __resetPopupManagerForTest } from './popup';
4
+ import { registerLayerRoot, unregisterLayerRoot } from './roots';
5
+ import DummyFrame from './__test__/DummyFrame.svelte';
6
+ // ---------------------------------------------------------------------------
7
+ // Helpers
8
+ // ---------------------------------------------------------------------------
9
+ function makeLayerRoot() {
10
+ const el = document.createElement('div');
11
+ el.style.position = 'relative';
12
+ document.body.appendChild(el);
13
+ registerLayerRoot('popup', el);
14
+ return el;
15
+ }
16
+ function teardownLayerRoot(el) {
17
+ unregisterLayerRoot('popup');
18
+ el.remove();
19
+ }
20
+ // ---------------------------------------------------------------------------
21
+ // P.1 — virtual-point anchor (context-menu use case)
22
+ // ---------------------------------------------------------------------------
23
+ describe('popup — P.1 virtual-point anchor', () => {
24
+ let layerRoot;
25
+ beforeEach(() => {
26
+ // Give the viewport a real size so the overflow clamp in PopupFrame
27
+ // doesn't crush our anchor coordinates to the clamped minimum.
28
+ vi.stubGlobal('innerWidth', 2000);
29
+ vi.stubGlobal('innerHeight', 2000);
30
+ layerRoot = makeLayerRoot();
31
+ });
32
+ afterEach(() => {
33
+ __resetPopupManagerForTest();
34
+ teardownLayerRoot(layerRoot);
35
+ vi.unstubAllGlobals();
36
+ });
37
+ it('accepts { x, y } as anchor without throwing', () => {
38
+ expect(() => {
39
+ popupManager.show(DummyFrame, { anchor: { x: 100, y: 200 } }, {});
40
+ }).not.toThrow();
41
+ });
42
+ it('mounts the popup host inside the layer root', () => {
43
+ popupManager.show(DummyFrame, { anchor: { x: 100, y: 200 } }, {});
44
+ const host = layerRoot.querySelector('.sh3-popup-host');
45
+ expect(host).not.toBeNull();
46
+ });
47
+ it('renders the dummy frame content inside the host', async () => {
48
+ popupManager.show(DummyFrame, { anchor: { x: 100, y: 200 } }, {});
49
+ await tick();
50
+ const content = layerRoot.querySelector('.dummy-popup-content');
51
+ expect(content).not.toBeNull();
52
+ });
53
+ it('positions the popup frame at bottom-start of the virtual point', async () => {
54
+ popupManager.show(DummyFrame, { anchor: { x: 100, y: 200 } }, {});
55
+ await tick();
56
+ // PopupFrame sets top = anchor.bottom + 4 = 200 + 4 = 204 (zero-size rect: bottom === y).
57
+ // left = anchor.left = 100 (no overflow with 2000px viewport).
58
+ const frame = layerRoot.querySelector('.popup-frame');
59
+ expect(frame).not.toBeNull();
60
+ expect(frame.style.top).toBe('204px');
61
+ expect(frame.style.left).toBe('100px');
62
+ });
63
+ it('close() removes the host from the DOM', async () => {
64
+ popupManager.show(DummyFrame, { anchor: { x: 50, y: 80 } }, {});
65
+ await tick();
66
+ expect(layerRoot.querySelector('.sh3-popup-host')).not.toBeNull();
67
+ popupManager.close();
68
+ await tick();
69
+ expect(layerRoot.querySelector('.sh3-popup-host')).toBeNull();
70
+ });
71
+ });
72
+ // ---------------------------------------------------------------------------
73
+ // P.2 — HTMLElement anchor still works (regression guard)
74
+ // ---------------------------------------------------------------------------
75
+ describe('popup — P.2 HTMLElement anchor regression', () => {
76
+ let layerRoot;
77
+ beforeEach(() => {
78
+ vi.stubGlobal('innerWidth', 2000);
79
+ vi.stubGlobal('innerHeight', 2000);
80
+ layerRoot = makeLayerRoot();
81
+ });
82
+ afterEach(() => {
83
+ __resetPopupManagerForTest();
84
+ teardownLayerRoot(layerRoot);
85
+ vi.unstubAllGlobals();
86
+ });
87
+ it('accepts an HTMLElement anchor without throwing', () => {
88
+ const anchor = document.createElement('button');
89
+ document.body.appendChild(anchor);
90
+ expect(() => {
91
+ popupManager.show(DummyFrame, { anchor }, {});
92
+ }).not.toThrow();
93
+ anchor.remove();
94
+ });
95
+ });
@@ -9,8 +9,17 @@ export type ToastHandle = OverlayHandle;
9
9
  export type ToastLevel = 'info' | 'warn' | 'error' | 'success';
10
10
  /** Where a popup should sit relative to its anchor. Phase 5 ships bottom-start. */
11
11
  export type PopupPlacement = 'bottom-start';
12
+ /**
13
+ * Anchor for a popup — either an HTMLElement whose bounding rect is used, or
14
+ * a virtual point `{ x, y }` in viewport coordinates (used by context menus
15
+ * that anchor at pointer coordinates).
16
+ */
17
+ export type PopupAnchor = HTMLElement | {
18
+ x: number;
19
+ y: number;
20
+ };
12
21
  export interface PopupOptions {
13
- anchor: HTMLElement;
22
+ anchor: PopupAnchor;
14
23
  placement?: PopupPlacement;
15
24
  }
16
25
  export interface ToastOptions {
@@ -28,4 +37,11 @@ export interface ModalOptions {
28
37
  * views like non-centered palettes or inspectors.
29
38
  */
30
39
  boxStyle?: string;
40
+ /**
41
+ * When true, a click on the frame area outside the dialog box closes the
42
+ * modal. Default false — content owns its own close UI. Opt-in is for
43
+ * picker-style modals like the command palette where outside-click is the
44
+ * expected dismissal gesture.
45
+ */
46
+ dismissOnBackdrop?: boolean;
31
47
  }
@@ -0,0 +1,144 @@
1
+ <script lang="ts">
2
+ /*
3
+ * Button — themed button primitive for shards and apps.
4
+ *
5
+ * Icons resolve via a three-tier lookup (see primitives/icon-context.ts).
6
+ * `icon` accepts either a sprite symbol id ("save") or a direct URL
7
+ * ending in .svg / containing a slash ("./foo.svg"); the URL form
8
+ * bypasses the active sprite entirely.
9
+ */
10
+
11
+ import type { Snippet } from 'svelte';
12
+ import coreSpriteUrl from '../assets/icons.svg';
13
+ import { getIconSprite, type ButtonVariant } from './icon-context';
14
+
15
+ let {
16
+ variant = 'default',
17
+ icon,
18
+ sprite,
19
+ disabled = false,
20
+ type = 'button',
21
+ title,
22
+ ariaLabel,
23
+ onclick,
24
+ children,
25
+ }: {
26
+ variant?: ButtonVariant;
27
+ /** Sprite symbol id, or a direct .svg URL. */
28
+ icon?: string;
29
+ /** Override the sprite sheet URL for this button only. */
30
+ sprite?: string;
31
+ disabled?: boolean;
32
+ type?: 'button' | 'submit' | 'reset';
33
+ title?: string;
34
+ ariaLabel?: string;
35
+ onclick?: (event: MouseEvent) => void;
36
+ children?: Snippet;
37
+ } = $props();
38
+
39
+ const contextSprite = getIconSprite();
40
+
41
+ function isDirectUrl(value: string): boolean {
42
+ return value.includes('/') || value.endsWith('.svg');
43
+ }
44
+
45
+ const iconHref = $derived.by(() => {
46
+ if (!icon) return undefined;
47
+ if (isDirectUrl(icon)) return icon;
48
+ const base = sprite ?? contextSprite ?? coreSpriteUrl;
49
+ return `${base}#${icon}`;
50
+ });
51
+
52
+ const iconOnly = $derived(variant === 'icon' || (!!icon && !children));
53
+ </script>
54
+
55
+ <button
56
+ {type}
57
+ class="sh3-btn sh3-btn--{variant}"
58
+ class:sh3-btn--icon-only={iconOnly}
59
+ {disabled}
60
+ {title}
61
+ aria-label={ariaLabel ?? (iconOnly ? title : undefined)}
62
+ {onclick}
63
+ >
64
+ {#if iconHref}
65
+ <svg class="sh3-btn__icon" aria-hidden="true">
66
+ <use href={iconHref} />
67
+ </svg>
68
+ {/if}
69
+ {#if children}
70
+ <span class="sh3-btn__label">{@render children()}</span>
71
+ {/if}
72
+ </button>
73
+
74
+ <style>
75
+ .sh3-btn {
76
+ appearance: none;
77
+ display: inline-flex;
78
+ align-items: center;
79
+ justify-content: center;
80
+ gap: var(--shell-pad-sm);
81
+ padding: 6px 14px;
82
+ background: var(--shell-accent);
83
+ color: var(--shell-fg-on-accent);
84
+ border: none;
85
+ border-radius: var(--shell-radius);
86
+ cursor: pointer;
87
+ font-family: inherit;
88
+ font-size: 0.875rem;
89
+ line-height: var(--shell-line);
90
+ }
91
+ .sh3-btn:hover:not(:disabled) { filter: brightness(1.12); }
92
+ .sh3-btn:active:not(:disabled) { filter: brightness(0.92); }
93
+ .sh3-btn:focus-visible {
94
+ box-shadow: var(--shell-focus-ring);
95
+ outline: none;
96
+ }
97
+ .sh3-btn:disabled {
98
+ opacity: 0.55;
99
+ cursor: not-allowed;
100
+ }
101
+
102
+ .sh3-btn--alert {
103
+ background: var(--shell-error);
104
+ color: var(--shell-fg-on-error);
105
+ }
106
+
107
+ .sh3-btn--ghost {
108
+ background: transparent;
109
+ color: var(--shell-fg);
110
+ border: 1px solid var(--shell-border);
111
+ }
112
+ .sh3-btn--ghost:hover:not(:disabled) {
113
+ background: var(--shell-bg-elevated);
114
+ filter: none;
115
+ }
116
+
117
+ .sh3-btn--icon {
118
+ background: transparent;
119
+ color: var(--shell-fg-muted);
120
+ padding: var(--shell-pad-sm);
121
+ }
122
+ .sh3-btn--icon:hover:not(:disabled) {
123
+ background: var(--shell-bg-elevated);
124
+ color: var(--shell-fg);
125
+ filter: none;
126
+ }
127
+
128
+ .sh3-btn--icon-only {
129
+ padding: var(--shell-pad-sm);
130
+ width: 26px;
131
+ height: 26px;
132
+ }
133
+
134
+ .sh3-btn__icon {
135
+ width: 16px;
136
+ height: 16px;
137
+ flex-shrink: 0;
138
+ }
139
+
140
+ .sh3-btn__label {
141
+ display: inline-flex;
142
+ white-space: nowrap;
143
+ }
144
+ </style>
@@ -0,0 +1,18 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type ButtonVariant } from './icon-context';
3
+ type $$ComponentProps = {
4
+ variant?: ButtonVariant;
5
+ /** Sprite symbol id, or a direct .svg URL. */
6
+ icon?: string;
7
+ /** Override the sprite sheet URL for this button only. */
8
+ sprite?: string;
9
+ disabled?: boolean;
10
+ type?: 'button' | 'submit' | 'reset';
11
+ title?: string;
12
+ ariaLabel?: string;
13
+ onclick?: (event: MouseEvent) => void;
14
+ children?: Snippet;
15
+ };
16
+ declare const Button: import("svelte").Component<$$ComponentProps, {}, "">;
17
+ type Button = ReturnType<typeof Button>;
18
+ export default Button;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Visual variants for <Button>. Exported from this module (rather than
3
+ * from Button.svelte itself) so TypeDoc, which only sees `.svelte`
4
+ * imports through the ambient `*.svelte` asset declaration, can
5
+ * discover it. Button.svelte re-imports this type.
6
+ */
7
+ export type ButtonVariant = 'default' | 'alert' | 'ghost' | 'icon';
8
+ /**
9
+ * Register an icon sprite URL for this subtree. Call from a view's
10
+ * <script> block to have every descendant <Button icon="..."/> resolve
11
+ * the icon name against this sprite.
12
+ */
13
+ export declare function provideIcons(spriteUrl: string): void;
14
+ /** Read the currently-active sprite URL, or undefined if none set. */
15
+ export declare function getIconSprite(): string | undefined;
@@ -0,0 +1,29 @@
1
+ /*
2
+ * Icon sprite context — lets a view opt all descendant <Button> (and any
3
+ * future icon-consuming primitive) into using a different sprite sheet
4
+ * than sh3-core's default assets/icons.svg.
5
+ *
6
+ * Resolution order inside Button is:
7
+ * 1. `sprite` prop on the Button (explicit one-off override)
8
+ * 2. nearest `provideIcons(url)` call up the Svelte tree (this module)
9
+ * 3. sh3-core's bundled sprite (fallback)
10
+ *
11
+ * This is tree-scoped (Svelte context), so it does not cross custom
12
+ * element / iframe boundaries. Shards that render inside such boundaries
13
+ * should pass `sprite` explicitly or call provideIcons() from their own
14
+ * root component.
15
+ */
16
+ import { getContext, setContext } from 'svelte';
17
+ const ICON_SPRITE_KEY = Symbol('sh3.icon-sprite');
18
+ /**
19
+ * Register an icon sprite URL for this subtree. Call from a view's
20
+ * <script> block to have every descendant <Button icon="..."/> resolve
21
+ * the icon name against this sprite.
22
+ */
23
+ export function provideIcons(spriteUrl) {
24
+ setContext(ICON_SPRITE_KEY, spriteUrl);
25
+ }
26
+ /** Read the currently-active sprite URL, or undefined if none set. */
27
+ export function getIconSprite() {
28
+ return getContext(ICON_SPRITE_KEY);
29
+ }
@@ -25,6 +25,9 @@ import { mount, unmount } from 'svelte';
25
25
  import ShellHome from './ShellHome.svelte';
26
26
  import KeysAndPeers from '../shell/views/KeysAndPeers.svelte';
27
27
  import { VERSION } from '../version';
28
+ import { __setBindingsZone } from '../actions/bindings-store';
29
+ import { registeredApps } from '../apps/registry.svelte';
30
+ import { launchApp } from '../apps/lifecycle';
28
31
  export const sh3coreShard = {
29
32
  manifest: {
30
33
  id: '__sh3core__',
@@ -36,6 +39,24 @@ export const sh3coreShard = {
36
39
  ],
37
40
  },
38
41
  activate(ctx) {
42
+ const zones = ctx.state({
43
+ user: {
44
+ bindings: {},
45
+ },
46
+ });
47
+ __setBindingsZone(zones.user);
48
+ ctx.actions.register({
49
+ id: 'sh3.palette.open',
50
+ label: 'Command Palette…',
51
+ scope: ['home', 'app'],
52
+ defaultShortcut: 'Mod+K',
53
+ contextItem: false,
54
+ paletteItem: false,
55
+ run(_dispatchCtx) {
56
+ // Lazy import to avoid circular module load at boot.
57
+ import('../actions/listeners').then(({ openPalette }) => openPalette());
58
+ },
59
+ });
39
60
  const factory = {
40
61
  mount(container, _context) {
41
62
  const instance = mount(ShellHome, { target: container });
@@ -55,6 +76,35 @@ export const sh3coreShard = {
55
76
  };
56
77
  ctx.registerView('sh3core:home', factory);
57
78
  ctx.registerView('shell:keys-and-peers', keysFactory);
79
+ // Dynamic launcher actions: one "Launch <App>" per registered app, kept
80
+ // in sync as packages install/uninstall via the registry. Re-launching
81
+ // the active app is harmless (lifecycle treats it as a resume).
82
+ const launcherUnregisters = new Map();
83
+ $effect.root(() => {
84
+ $effect(() => {
85
+ const currentIds = new Set();
86
+ for (const [id, app] of registeredApps) {
87
+ currentIds.add(id);
88
+ if (launcherUnregisters.has(id))
89
+ continue;
90
+ const off = ctx.actions.register({
91
+ id: `sh3.app.launch:${id}`,
92
+ label: `Launch ${app.manifest.label}`,
93
+ scope: ['home', 'app'],
94
+ run() {
95
+ void launchApp(id);
96
+ },
97
+ });
98
+ launcherUnregisters.set(id, off);
99
+ }
100
+ for (const id of [...launcherUnregisters.keys()]) {
101
+ if (!currentIds.has(id)) {
102
+ launcherUnregisters.get(id)();
103
+ launcherUnregisters.delete(id);
104
+ }
105
+ }
106
+ });
107
+ });
58
108
  },
59
109
  autostart() {
60
110
  // Intentionally empty. Defining this field is what puts the sh3core
@@ -29,6 +29,9 @@ import { createShardKeysApi } from '../keys/client';
29
29
  import { PERMISSION_KEYS_MINT } from '../keys/types';
30
30
  import { subscribe } from '../keys/revocation-bus.svelte';
31
31
  import { register as contributionsRegister, list as contributionsList, listPoints as contributionsListPoints, onChange as contributionsOnChange, } from '../contributions';
32
+ import { registerAction } from '../actions/registry';
33
+ import { makeSelectionApi, clearSelectionForShard } from '../actions/selection.svelte';
34
+ import { openContextMenu as shellOpenContextMenu, openPalette as shellOpenPalette } from '../actions/listeners';
32
35
  /**
33
36
  * Reactive registry of every shard known to the host. Keys are shard ids.
34
37
  * Populated once at boot by the glob-discovery loop in main.ts (through
@@ -171,6 +174,16 @@ export async function activateShard(id) {
171
174
  })
172
175
  : undefined,
173
176
  contributions,
177
+ actions: {
178
+ register(action) {
179
+ const dispose = registerAction(action, id);
180
+ entry.cleanupFns.push(async () => dispose());
181
+ return dispose;
182
+ },
183
+ selection: makeSelectionApi(id),
184
+ openContextMenu(opts) { shellOpenContextMenu(opts); },
185
+ openPalette(opts) { shellOpenPalette(opts); },
186
+ },
174
187
  };
175
188
  entry.ctx = ctx;
176
189
  // Wire onKeyRevoked hook: subscribe to the revocation bus for this shard.
@@ -228,6 +241,7 @@ export function deactivateShard(id) {
228
241
  fwUnregisterVerb(name);
229
242
  for (const viewId of entry.viewIds)
230
243
  unregisterView(viewId);
244
+ clearSelectionForShard(id);
231
245
  active.delete(id);
232
246
  activeShards.delete(id);
233
247
  }
@@ -6,6 +6,8 @@ import type { EnvState } from '../env/types';
6
6
  import type { Verb } from '../verbs/types';
7
7
  import type { ShardContextKeys } from '../keys/types';
8
8
  import type { ContributionsApi } from '../contributions/types';
9
+ import type { ActionsApi } from '../actions/types';
10
+ import type { TreeRootRef } from '../layout/types';
9
11
  export { PERMISSION_KEYS_MINT, type ShardContextKeys, type ApiKeyPublic, type MintOpts, ScopeEscalationError, ConsentDeniedError } from '../keys/types';
10
12
  /**
11
13
  * The object returned by `ViewFactory.mount`. The framework calls
@@ -59,6 +61,18 @@ export interface MountContext {
59
61
  * Call this whenever the view's save-state changes.
60
62
  */
61
63
  setDirty(dirty: boolean): void;
64
+ /**
65
+ * Snapshot of this view's current host location. Returns
66
+ * `{ kind: 'docked' }` when the containing slot is anywhere in the
67
+ * active docked tree, `{ kind: 'float', floatId }` when it lives
68
+ * inside a float's subtree, or `null` when the slot is no longer
69
+ * present in the active layout.
70
+ *
71
+ * Not reactive — each call walks the current layout. Call it when
72
+ * you need to decide something (e.g. toggle a pop-out button,
73
+ * branch in a context-menu handler).
74
+ */
75
+ location(): TreeRootRef | null;
62
76
  }
63
77
  /**
64
78
  * The shard-side adapter that knows how to bring a view to life inside a
@@ -232,6 +246,11 @@ export interface ShardContext {
232
246
  * docs/sh3-rfcs/2026-04-20-shard-contribution-points.md.
233
247
  */
234
248
  contributions: ContributionsApi;
249
+ /**
250
+ * Register UI actions (keyboard shortcuts, context-menu items, palette
251
+ * entries). Actions are auto-unregistered when the shard deactivates.
252
+ */
253
+ actions: ActionsApi;
235
254
  }
236
255
  /**
237
256
  * A shard module. Shards are the fundamental unit of contribution in SH3.
@@ -12,9 +12,10 @@
12
12
  * - A ViewFactory knows how to mount a view into a raw HTMLElement and
13
13
  * return a handle the framework uses to unmount / notify of resizes.
14
14
  *
15
- * Deferred to later phases: bus scoping, command/toolbar/menu/hotkey
16
- * registration, modal provider contributions, background services, lazy
17
- * activation events. They'll slot into `ShardContext` as new `register*`
18
- * methods without disturbing the phase-4 shape.
15
+ * Action contributions (commands, hotkeys, context menus) shipped in v0.11.0
16
+ * via `ctx.actions` (see `../actions/types.ts`). Still deferred to later
17
+ * phases: bus scoping, toolbar registration, modal provider contributions,
18
+ * background services, lazy activation events. They'll slot into `ShardContext`
19
+ * as new `register*` methods without disturbing the phase-4 shape.
19
20
  */
20
21
  export { PERMISSION_KEYS_MINT, ScopeEscalationError, ConsentDeniedError } from '../keys/types';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { resetFramework } from '../__test__/reset';
3
+ import { makeApp, makeAppManifest, makeTabEntry, makeTabsNode, makeTree } from '../__test__/fixtures';
4
+ import { registerApp } from '../apps/registry.svelte';
5
+ import { launchApp } from '../apps/lifecycle';
6
+ import { floatManager, bindFloatStore } from '../overlays/float';
7
+ import { layoutStore } from '../layout/store.svelte';
8
+ import { makeShellApiForTest } from './shellShard.svelte';
9
+ describe('ShellApi.locateSlot', () => {
10
+ beforeEach(resetFramework);
11
+ it('returns docked for a slot in the docked tree', async () => {
12
+ registerApp(makeApp({
13
+ manifest: makeAppManifest({ id: 'test-app-docked' }),
14
+ initialLayout: [
15
+ {
16
+ name: 'default',
17
+ tree: makeTree(makeTabsNode([makeTabEntry({ slotId: 'dock-x' })])),
18
+ },
19
+ ],
20
+ }));
21
+ await launchApp('test-app-docked');
22
+ const shell = makeShellApiForTest();
23
+ expect(shell.locateSlot('dock-x')).toEqual({ kind: 'docked' });
24
+ });
25
+ it('returns float for a slot in a float', async () => {
26
+ var _a;
27
+ registerApp(makeApp({
28
+ manifest: makeAppManifest({ id: 'test-app-float' }),
29
+ initialLayout: [
30
+ {
31
+ name: 'default',
32
+ tree: makeTree(makeTabsNode([makeTabEntry({ slotId: 'stay-docked' })])),
33
+ },
34
+ ],
35
+ }));
36
+ await launchApp('test-app-float');
37
+ // Shell.svelte normally binds the float manager to the active tree's
38
+ // floats array during boot. In tests we bind it manually so
39
+ // floatManager.open() writes into the tree that locateSlot walks.
40
+ bindFloatStore(layoutStore.floats, () => ({ w: 1024, h: 768 }));
41
+ const floatId = floatManager.open('v', { title: 'Float X' });
42
+ const openedFloat = layoutStore.floats.find((f) => f.id === floatId);
43
+ expect(openedFloat).toBeDefined();
44
+ const tabs = openedFloat.content.type === 'tabs' ? openedFloat.content : null;
45
+ const floatSlotId = (_a = tabs === null || tabs === void 0 ? void 0 : tabs.tabs[0]) === null || _a === void 0 ? void 0 : _a.slotId;
46
+ expect(floatSlotId).toBeTruthy();
47
+ const shell = makeShellApiForTest();
48
+ expect(shell.locateSlot(floatSlotId)).toEqual({ kind: 'float', floatId });
49
+ });
50
+ it('returns null for an absent slot', async () => {
51
+ registerApp(makeApp({
52
+ manifest: makeAppManifest({ id: 'test-app-absent' }),
53
+ initialLayout: [
54
+ {
55
+ name: 'default',
56
+ tree: makeTree(makeTabsNode([makeTabEntry({ slotId: 'existing' })])),
57
+ },
58
+ ],
59
+ }));
60
+ await launchApp('test-app-absent');
61
+ const shell = makeShellApiForTest();
62
+ expect(shell.locateSlot('nonexistent-slot')).toBeNull();
63
+ });
64
+ });
65
+ import { popoutView, inspectActiveLayout } from '../layout/inspection';
66
+ describe('ShellApi.locateSlot — round-trip', () => {
67
+ beforeEach(resetFramework);
68
+ it('tracks the docked → popout → float transition', async () => {
69
+ registerApp(makeApp({
70
+ manifest: makeAppManifest({ id: 'test-app-rt' }),
71
+ initialLayout: [
72
+ {
73
+ name: 'default',
74
+ tree: makeTree(makeTabsNode([
75
+ makeTabEntry({ slotId: 'rt-1', viewId: 'rt:one' }),
76
+ makeTabEntry({ slotId: 'rt-anchor', viewId: 'rt:anchor' }),
77
+ ])),
78
+ },
79
+ ],
80
+ }));
81
+ await launchApp('test-app-rt');
82
+ // Shell.svelte would bind this at boot; in tests we do it ourselves.
83
+ bindFloatStore(layoutStore.floats, () => ({ w: 1024, h: 768 }));
84
+ const shell = makeShellApiForTest();
85
+ // Starts docked.
86
+ expect(shell.locateSlot('rt-1')).toEqual({ kind: 'docked' });
87
+ // Popout removes the original docked tab and opens a float with a
88
+ // fresh slotId wrapped around the same view.
89
+ const floatId = popoutView('rt-1');
90
+ expect(floatId).not.toBeNull();
91
+ expect(shell.locateSlot('rt-1')).toBeNull();
92
+ // The float-era slotId resolves to the new float.
93
+ const { root } = inspectActiveLayout();
94
+ const fl = root.floats.find((f) => f.id === floatId);
95
+ const floatSlot = fl.content.type === 'tabs' ? fl.content.tabs[0].slotId : '';
96
+ expect(floatSlot).toBeTruthy();
97
+ expect(shell.locateSlot(floatSlot)).toEqual({ kind: 'float', floatId });
98
+ // Anchor tab is untouched and still docked.
99
+ expect(shell.locateSlot('rt-anchor')).toEqual({ kind: 'docked' });
100
+ });
101
+ });
@@ -1,2 +1,9 @@
1
1
  import type { Shard } from '../api';
2
+ import type { ShellApi } from './registry';
3
+ /**
4
+ * Test-only ShellApi constructor. Bypasses the admin gate and uses a
5
+ * stub ShardContext. Only methods that do not consult `ctx` are
6
+ * guaranteed to work — `locateSlot`, `listFloats`, `listApps`, etc.
7
+ */
8
+ export declare function makeShellApiForTest(): ShellApi;
2
9
  export declare const shellShard: Shard;
@@ -19,7 +19,7 @@ import { registerV1Verbs } from './verbs';
19
19
  import { listRegisteredApps, getActiveApp } from '../apps/registry.svelte';
20
20
  import { launchApp } from '../apps/lifecycle';
21
21
  import { registeredShards } from '../shards/activate.svelte';
22
- import { inspectActiveLayout, focusView, closeTab, popoutView, dockFloat, dockIntoActiveLayout } from '../layout/inspection';
22
+ import { inspectActiveLayout, focusView, closeTab, popoutView, dockFloat, dockIntoActiveLayout, locateSlot as locateSlotInActiveLayout } from '../layout/inspection';
23
23
  import { floatManager } from '../overlays/float';
24
24
  import { listStandaloneViews } from '../shards/activate.svelte';
25
25
  import { getUser, isAdmin } from '../auth/index';
@@ -121,6 +121,15 @@ function makeShellApi(_ctx) {
121
121
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
122
122
  }
123
123
  },
124
+ // → layout/inspection: locateSlot(slotId) returns TreeRootRef | null
125
+ locateSlot(slotId) {
126
+ try {
127
+ return locateSlotInActiveLayout(slotId);
128
+ }
129
+ catch (_a) {
130
+ return null;
131
+ }
132
+ },
124
133
  // → overlays/float: floatManager.list() returns FloatEntry[]
125
134
  listFloats() {
126
135
  return floatManager.list().map((f) => {
@@ -156,6 +165,14 @@ function makeShellApi(_ctx) {
156
165
  },
157
166
  };
158
167
  }
168
+ /**
169
+ * Test-only ShellApi constructor. Bypasses the admin gate and uses a
170
+ * stub ShardContext. Only methods that do not consult `ctx` are
171
+ * guaranteed to work — `locateSlot`, `listFloats`, `listApps`, etc.
172
+ */
173
+ export function makeShellApiForTest() {
174
+ return makeShellApi({});
175
+ }
159
176
  export const shellShard = {
160
177
  manifest,
161
178
  activate(ctx) {
@@ -165,6 +182,22 @@ export const shellShard = {
165
182
  }
166
183
  registerV1Verbs(ctx);
167
184
  const shell = makeShellApi(ctx);
185
+ // The AZERTY `²` key (top-left on FR keyboards, below Escape) opens the
186
+ // terminal view — focusing it if already mounted, floating it otherwise.
187
+ // Migrated from Shell.svelte's inline keydown handler as proof-of-concept
188
+ // for the Actions framework (Task 23 / DF1). Registered here because this
189
+ // shard owns the terminal view and is already admin-gated.
190
+ ctx.actions.register({
191
+ id: 'shell.terminal.toggle',
192
+ label: 'Open Terminal',
193
+ scope: ['home', 'app'],
194
+ defaultShortcut: '²',
195
+ allowInInputs: false,
196
+ run() {
197
+ if (!focusView('shell:terminal'))
198
+ floatManager.open('shell:terminal', { title: 'Shell' });
199
+ },
200
+ });
168
201
  const factory = {
169
202
  mount(container, _context) {
170
203
  var _a;