sh3-core 0.10.4 → 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 (122) hide show
  1. package/dist/Shell.svelte +12 -31
  2. package/dist/__test__/fixtures.js +1 -0
  3. package/dist/__test__/reset.js +6 -0
  4. package/dist/actions/CommandPalette.svelte +68 -0
  5. package/dist/actions/CommandPalette.svelte.d.ts +11 -0
  6. package/dist/actions/ContextMenu.svelte +97 -0
  7. package/dist/actions/ContextMenu.svelte.d.ts +9 -0
  8. package/dist/actions/bindings-store.d.ts +8 -0
  9. package/dist/actions/bindings-store.js +27 -0
  10. package/dist/actions/bindings-store.test.d.ts +1 -0
  11. package/dist/actions/bindings-store.test.js +25 -0
  12. package/dist/actions/bindings.d.ts +4 -0
  13. package/dist/actions/bindings.js +17 -0
  14. package/dist/actions/bindings.test.d.ts +1 -0
  15. package/dist/actions/bindings.test.js +30 -0
  16. package/dist/actions/contextMenuModel.d.ts +16 -0
  17. package/dist/actions/contextMenuModel.js +71 -0
  18. package/dist/actions/contextMenuModel.test.d.ts +1 -0
  19. package/dist/actions/contextMenuModel.test.js +44 -0
  20. package/dist/actions/dispatcher.svelte.d.ts +34 -0
  21. package/dist/actions/dispatcher.svelte.js +117 -0
  22. package/dist/actions/dispatcher.test.d.ts +1 -0
  23. package/dist/actions/dispatcher.test.js +155 -0
  24. package/dist/actions/listeners.d.ts +11 -0
  25. package/dist/actions/listeners.js +180 -0
  26. package/dist/actions/listeners.test.d.ts +1 -0
  27. package/dist/actions/listeners.test.js +149 -0
  28. package/dist/actions/palette-scorer.d.ts +11 -0
  29. package/dist/actions/palette-scorer.js +49 -0
  30. package/dist/actions/palette-scorer.test.d.ts +1 -0
  31. package/dist/actions/palette-scorer.test.js +40 -0
  32. package/dist/actions/paletteModel.d.ts +4 -0
  33. package/dist/actions/paletteModel.js +40 -0
  34. package/dist/actions/paletteModel.test.d.ts +1 -0
  35. package/dist/actions/paletteModel.test.js +33 -0
  36. package/dist/actions/registry.d.ts +10 -0
  37. package/dist/actions/registry.js +36 -0
  38. package/dist/actions/registry.test.d.ts +1 -0
  39. package/dist/actions/registry.test.js +49 -0
  40. package/dist/actions/selection.svelte.d.ts +8 -0
  41. package/dist/actions/selection.svelte.js +44 -0
  42. package/dist/actions/selection.test.d.ts +1 -0
  43. package/dist/actions/selection.test.js +51 -0
  44. package/dist/actions/shardContext.test.d.ts +1 -0
  45. package/dist/actions/shardContext.test.js +41 -0
  46. package/dist/actions/shellActions.test.d.ts +1 -0
  47. package/dist/actions/shellActions.test.js +22 -0
  48. package/dist/actions/shortcuts.d.ts +5 -0
  49. package/dist/actions/shortcuts.js +87 -0
  50. package/dist/actions/shortcuts.test.d.ts +1 -0
  51. package/dist/actions/shortcuts.test.js +49 -0
  52. package/dist/actions/state.svelte.d.ts +16 -0
  53. package/dist/actions/state.svelte.js +76 -0
  54. package/dist/actions/state.test.d.ts +1 -0
  55. package/dist/actions/state.test.js +40 -0
  56. package/dist/actions/syncMountedViewIds.test.d.ts +1 -0
  57. package/dist/actions/syncMountedViewIds.test.js +97 -0
  58. package/dist/actions/types.d.ts +56 -0
  59. package/dist/actions/types.js +7 -0
  60. package/dist/api.d.ts +2 -2
  61. package/dist/api.js +1 -1
  62. package/dist/apps/lifecycle.js +13 -3
  63. package/dist/createShell.js +4 -1
  64. package/dist/host.js +6 -3
  65. package/dist/index.d.ts +2 -0
  66. package/dist/index.js +2 -0
  67. package/dist/layout/LayoutRenderer.browser.test.js +78 -0
  68. package/dist/layout/LayoutRenderer.svelte +1 -0
  69. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-6-fixed-slots-freezes-the-handle-adjacent-to-a-fixed-pane--dblclick-does-not-collapse-1.png +0 -0
  70. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-6-fixed-slots-hides-the-collapse-widget-on-a-fixed-pane-1.png +0 -0
  71. package/dist/layout/inspection.d.ts +11 -1
  72. package/dist/layout/inspection.js +13 -1
  73. package/dist/layout/ops-locate.test.d.ts +1 -0
  74. package/dist/layout/ops-locate.test.js +103 -0
  75. package/dist/layout/ops.d.ts +8 -0
  76. package/dist/layout/ops.js +27 -0
  77. package/dist/layout/slotHostPool.svelte.js +24 -0
  78. package/dist/layout/slotHostPool.test.js +14 -0
  79. package/dist/layout/types.d.ts +15 -0
  80. package/dist/overlays/FloatFrame.svelte +23 -11
  81. package/dist/overlays/ModalFrame.svelte +9 -1
  82. package/dist/overlays/ModalFrame.svelte.d.ts +1 -0
  83. package/dist/overlays/__test__/DummyFrame.svelte +6 -0
  84. package/dist/overlays/__test__/DummyFrame.svelte.d.ts +6 -0
  85. package/dist/overlays/float.d.ts +6 -0
  86. package/dist/overlays/float.js +24 -9
  87. package/dist/overlays/float.test.js +175 -0
  88. package/dist/overlays/floatDismiss.d.ts +8 -0
  89. package/dist/overlays/floatDismiss.js +68 -0
  90. package/dist/overlays/modal.js +5 -1
  91. package/dist/overlays/modal.test.d.ts +1 -0
  92. package/dist/overlays/modal.test.js +55 -0
  93. package/dist/overlays/popup.d.ts +2 -0
  94. package/dist/overlays/popup.js +24 -4
  95. package/dist/overlays/popup.test.d.ts +1 -0
  96. package/dist/overlays/popup.test.js +95 -0
  97. package/dist/overlays/types.d.ts +17 -1
  98. package/dist/primitives/Button.svelte +144 -0
  99. package/dist/primitives/Button.svelte.d.ts +18 -0
  100. package/dist/primitives/ResizableSplitter.svelte +38 -3
  101. package/dist/primitives/ResizableSplitter.svelte.d.ts +7 -0
  102. package/dist/primitives/icon-context.d.ts +15 -0
  103. package/dist/primitives/icon-context.js +29 -0
  104. package/dist/sh3core-shard/sh3coreShard.svelte.js +50 -0
  105. package/dist/shards/activate.svelte.js +14 -0
  106. package/dist/shards/types.d.ts +19 -0
  107. package/dist/shards/types.js +5 -4
  108. package/dist/shell-shard/locateSlot.test.d.ts +1 -0
  109. package/dist/shell-shard/locateSlot.test.js +101 -0
  110. package/dist/shell-shard/shellShard.svelte.d.ts +7 -0
  111. package/dist/shell-shard/shellShard.svelte.js +34 -1
  112. package/dist/shellRuntime.svelte.d.ts +19 -0
  113. package/dist/shellRuntime.svelte.js +30 -0
  114. package/dist/tokens.css +11 -1
  115. package/dist/verbs/types.d.ts +9 -0
  116. package/dist/version.d.ts +1 -1
  117. package/dist/version.js +1 -1
  118. package/package.json +1 -1
  119. package/dist/apps/terminal/manifest.d.ts +0 -8
  120. package/dist/apps/terminal/manifest.js +0 -14
  121. package/dist/apps/terminal/terminal-app.d.ts +0 -7
  122. package/dist/apps/terminal/terminal-app.js +0 -14
@@ -7,3 +7,5 @@ export interface PopupManager {
7
7
  close(): void;
8
8
  }
9
9
  export declare const popupManager: PopupManager;
10
+ /** @internal — test helper only. Closes any active popup and resets state. */
11
+ export declare function __resetPopupManagerForTest(): void;
@@ -9,8 +9,8 @@
9
9
  * - Popups do NOT stack. Opening a second popup dismisses the first.
10
10
  * - Clicking outside the popup dismisses it.
11
11
  * - Pressing Escape dismisses it.
12
- * - The caller provides an HTMLElement anchor; the popup positions
13
- * itself relative to the anchor's current viewport rect.
12
+ * - The caller provides a PopupAnchor (HTMLElement or {x,y} point);
13
+ * the popup positions itself relative to the anchor's viewport rect.
14
14
  *
15
15
  * Implementation notes:
16
16
  * - The manager keeps at most one active entry. show() closes any
@@ -28,6 +28,17 @@
28
28
  import { mount, unmount } from 'svelte';
29
29
  import PopupFrame from './PopupFrame.svelte';
30
30
  import { getLayerRoot } from './roots';
31
+ /**
32
+ * Convert a PopupAnchor to a DOMRect.
33
+ * - HTMLElement: uses its live bounding rect.
34
+ * - { x, y } virtual point: zero-size rect at the viewport coordinates so
35
+ * PopupFrame places itself at bottom-start of the cursor position.
36
+ */
37
+ function anchorRect(anchor) {
38
+ if (anchor instanceof HTMLElement)
39
+ return anchor.getBoundingClientRect();
40
+ return new DOMRect(anchor.x, anchor.y, 0, 0);
41
+ }
31
42
  let current = null;
32
43
  function onDocumentPointerDown(e) {
33
44
  if (!current)
@@ -75,7 +86,7 @@ function showPopup(Content, options, props) {
75
86
  host.style.inset = '0';
76
87
  host.style.pointerEvents = 'none'; // only the frame captures pointer events
77
88
  root.appendChild(host);
78
- const anchorRect = options.anchor.getBoundingClientRect();
89
+ const rect = anchorRect(options.anchor);
79
90
  const entry = {};
80
91
  const handle = {
81
92
  close: () => removeEntry(entry),
@@ -85,7 +96,7 @@ function showPopup(Content, options, props) {
85
96
  props: {
86
97
  Content: Content,
87
98
  contentProps: (props !== null && props !== void 0 ? props : {}),
88
- anchorRect,
99
+ anchorRect: rect,
89
100
  close: handle.close,
90
101
  },
91
102
  });
@@ -106,3 +117,12 @@ export const popupManager = {
106
117
  show: showPopup,
107
118
  close: closeCurrent,
108
119
  };
120
+ /** @internal — test helper only. Closes any active popup and resets state. */
121
+ export function __resetPopupManagerForTest() {
122
+ if (current) {
123
+ removeDismissListeners();
124
+ unmount(current.frame);
125
+ current.host.remove();
126
+ current = null;
127
+ }
128
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -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;
@@ -28,6 +28,7 @@
28
28
  sizes,
29
29
  pinned,
30
30
  collapsed,
31
+ fixed,
31
32
  count,
32
33
  pane,
33
34
  onResize,
@@ -46,6 +47,13 @@
46
47
  pinned?: SizeMode[];
47
48
  /** Per-pane collapsed state. Omitted entries default to false. */
48
49
  collapsed?: boolean[];
50
+ /**
51
+ * Per-pane fixed flag. A fixed pane has no collapse widget and
52
+ * the handles on either side of it are frozen (non-interactive,
53
+ * rendered thinner). A non-fixed pane whose every neighbor is
54
+ * fixed also loses its collapse widget.
55
+ */
56
+ fixed?: boolean[];
49
57
  /** Number of panes — `sizes.length` should match. */
50
58
  count: number;
51
59
  /** Snippet invoked once per pane with the pane index. */
@@ -67,6 +75,15 @@
67
75
 
68
76
  const modeOf = (i: number): SizeMode => pinned?.[i] ?? 'fr';
69
77
  const isCollapsed = (i: number): boolean => collapsed?.[i] ?? false;
78
+ const isFixed = (i: number): boolean => fixed?.[i] ?? false;
79
+ const isHandleFrozen = (i: number): boolean => isFixed(i) || isFixed(i + 1);
80
+
81
+ function canCollapse(i: number): boolean {
82
+ if (isFixed(i)) return false;
83
+ const left = i > 0 ? !isFixed(i - 1) : false;
84
+ const right = i < count - 1 ? !isFixed(i + 1) : false;
85
+ return left || right;
86
+ }
70
87
 
71
88
  /** CSS `flex` shorthand for pane i. */
72
89
  function flexFor(i: number): string {
@@ -88,8 +105,9 @@
88
105
  let drag: DragState | null = $state(null);
89
106
 
90
107
  function beginDrag(e: PointerEvent, handleIndex: number) {
91
- // Disable resize handles adjacent to collapsed panes.
108
+ // Disable resize handles adjacent to collapsed or fixed panes.
92
109
  if (isCollapsed(handleIndex) || isCollapsed(handleIndex + 1)) return;
110
+ if (isHandleFrozen(handleIndex)) return;
93
111
 
94
112
  e.preventDefault();
95
113
  (e.target as HTMLElement).setPointerCapture(e.pointerId);
@@ -198,12 +216,13 @@
198
216
  <span class="collapse-icon">{direction === 'horizontal' ? '▸' : '▾'}</span>
199
217
  </button>
200
218
  {:else}
201
- {#if onCollapseToggle}
219
+ {#if onCollapseToggle && canCollapse(i)}
202
220
  <button
203
221
  type="button"
204
222
  class="collapse-header expanded"
205
223
  class:horizontal={direction === 'horizontal'}
206
224
  class:vertical={direction === 'vertical'}
225
+ data-testid="collapse-toggle-{i}"
207
226
  onclick={() => onCollapseToggle?.(i, true)}
208
227
  aria-label="Collapse pane"
209
228
  >
@@ -221,12 +240,17 @@
221
240
  class="splitter-handle"
222
241
  class:dragging={drag?.handleIndex === i}
223
242
  class:disabled={isCollapsed(i) || isCollapsed(i + 1)}
243
+ class:frozen={isHandleFrozen(i)}
224
244
  data-testid="splitter-handle-{i}"
225
245
  onpointerdown={(e) => beginDrag(e, i)}
226
246
  onpointermove={moveDrag}
227
247
  onpointerup={endDrag}
228
248
  onpointercancel={endDrag}
229
- ondblclick={() => onCollapseToggle?.(i, !isCollapsed(i))}
249
+ ondblclick={() => {
250
+ if (isHandleFrozen(i)) return;
251
+ if (!canCollapse(i) && !isCollapsed(i)) return;
252
+ onCollapseToggle?.(i, !isCollapsed(i));
253
+ }}
230
254
  role="separator"
231
255
  aria-orientation={direction === 'horizontal' ? 'vertical' : 'horizontal'}
232
256
  ></div>
@@ -323,6 +347,15 @@
323
347
  cursor: default;
324
348
  pointer-events: none;
325
349
  }
350
+ .splitter-handle.frozen {
351
+ cursor: default;
352
+ pointer-events: none;
353
+ background: var(--shell-border);
354
+ opacity: 0.5;
355
+ }
356
+ .splitter-handle.frozen:hover {
357
+ background: var(--shell-border);
358
+ }
326
359
 
327
360
  .horizontal > .splitter-handle {
328
361
  width: 4px;
@@ -332,4 +365,6 @@
332
365
  height: 4px;
333
366
  cursor: row-resize;
334
367
  }
368
+ .horizontal > .splitter-handle.frozen { width: 1px; }
369
+ .vertical > .splitter-handle.frozen { height: 1px; }
335
370
  </style>
@@ -14,6 +14,13 @@ type $$ComponentProps = {
14
14
  pinned?: SizeMode[];
15
15
  /** Per-pane collapsed state. Omitted entries default to false. */
16
16
  collapsed?: boolean[];
17
+ /**
18
+ * Per-pane fixed flag. A fixed pane has no collapse widget and
19
+ * the handles on either side of it are frozen (non-interactive,
20
+ * rendered thinner). A non-fixed pane whose every neighbor is
21
+ * fixed also loses its collapse widget.
22
+ */
23
+ fixed?: boolean[];
17
24
  /** Number of panes — `sizes.length` should match. */
18
25
  count: number;
19
26
  /** Snippet invoked once per pane with the pane index. */
@@ -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.