sh3-core 0.1.0

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 (134) hide show
  1. package/dist/Shell.svelte +185 -0
  2. package/dist/Shell.svelte.d.ts +4 -0
  3. package/dist/api.d.ts +22 -0
  4. package/dist/api.js +45 -0
  5. package/dist/apps/lifecycle.d.ts +37 -0
  6. package/dist/apps/lifecycle.js +153 -0
  7. package/dist/apps/registry.svelte.d.ts +37 -0
  8. package/dist/apps/registry.svelte.js +60 -0
  9. package/dist/apps/types.d.ts +61 -0
  10. package/dist/apps/types.js +10 -0
  11. package/dist/assets/icons.svg +1119 -0
  12. package/dist/auth/auth.svelte.d.ts +44 -0
  13. package/dist/auth/auth.svelte.js +119 -0
  14. package/dist/auth/index.d.ts +1 -0
  15. package/dist/auth/index.js +1 -0
  16. package/dist/build.d.ts +29 -0
  17. package/dist/build.js +85 -0
  18. package/dist/contract.d.ts +20 -0
  19. package/dist/contract.js +28 -0
  20. package/dist/documents/backends.d.ts +17 -0
  21. package/dist/documents/backends.js +156 -0
  22. package/dist/documents/config.d.ts +7 -0
  23. package/dist/documents/config.js +27 -0
  24. package/dist/documents/handle.d.ts +6 -0
  25. package/dist/documents/handle.js +154 -0
  26. package/dist/documents/http-backend.d.ts +22 -0
  27. package/dist/documents/http-backend.js +78 -0
  28. package/dist/documents/index.d.ts +6 -0
  29. package/dist/documents/index.js +8 -0
  30. package/dist/documents/notifications.d.ts +9 -0
  31. package/dist/documents/notifications.js +39 -0
  32. package/dist/documents/types.d.ts +97 -0
  33. package/dist/documents/types.js +12 -0
  34. package/dist/host-entry.d.ts +9 -0
  35. package/dist/host-entry.js +15 -0
  36. package/dist/host.d.ts +13 -0
  37. package/dist/host.js +73 -0
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.js +13 -0
  40. package/dist/layout/DragPreview.svelte +63 -0
  41. package/dist/layout/DragPreview.svelte.d.ts +3 -0
  42. package/dist/layout/LayoutRenderer.svelte +260 -0
  43. package/dist/layout/LayoutRenderer.svelte.d.ts +6 -0
  44. package/dist/layout/SlotContainer.svelte +140 -0
  45. package/dist/layout/SlotContainer.svelte.d.ts +8 -0
  46. package/dist/layout/SlotDropZone.svelte +122 -0
  47. package/dist/layout/SlotDropZone.svelte.d.ts +8 -0
  48. package/dist/layout/drag.svelte.d.ts +45 -0
  49. package/dist/layout/drag.svelte.js +191 -0
  50. package/dist/layout/inspection.d.ts +52 -0
  51. package/dist/layout/inspection.js +157 -0
  52. package/dist/layout/ops.d.ts +78 -0
  53. package/dist/layout/ops.js +281 -0
  54. package/dist/layout/slotHostPool.svelte.d.ts +36 -0
  55. package/dist/layout/slotHostPool.svelte.js +229 -0
  56. package/dist/layout/store.svelte.d.ts +39 -0
  57. package/dist/layout/store.svelte.js +150 -0
  58. package/dist/layout/tree-walk.d.ts +15 -0
  59. package/dist/layout/tree-walk.js +33 -0
  60. package/dist/layout/types.d.ts +108 -0
  61. package/dist/layout/types.js +25 -0
  62. package/dist/overlays/ModalFrame.svelte +87 -0
  63. package/dist/overlays/ModalFrame.svelte.d.ts +10 -0
  64. package/dist/overlays/PopupFrame.svelte +85 -0
  65. package/dist/overlays/PopupFrame.svelte.d.ts +10 -0
  66. package/dist/overlays/ToastItem.svelte +77 -0
  67. package/dist/overlays/ToastItem.svelte.d.ts +9 -0
  68. package/dist/overlays/focusTrap.d.ts +1 -0
  69. package/dist/overlays/focusTrap.js +64 -0
  70. package/dist/overlays/modal.d.ts +9 -0
  71. package/dist/overlays/modal.js +141 -0
  72. package/dist/overlays/popup.d.ts +9 -0
  73. package/dist/overlays/popup.js +108 -0
  74. package/dist/overlays/roots.d.ts +4 -0
  75. package/dist/overlays/roots.js +31 -0
  76. package/dist/overlays/toast.d.ts +6 -0
  77. package/dist/overlays/toast.js +93 -0
  78. package/dist/overlays/types.d.ts +31 -0
  79. package/dist/overlays/types.js +15 -0
  80. package/dist/primitives/.gitkeep +0 -0
  81. package/dist/primitives/ResizableSplitter.svelte +333 -0
  82. package/dist/primitives/ResizableSplitter.svelte.d.ts +35 -0
  83. package/dist/primitives/TabbedPanel.svelte +305 -0
  84. package/dist/primitives/TabbedPanel.svelte.d.ts +50 -0
  85. package/dist/registry/client.d.ts +74 -0
  86. package/dist/registry/client.js +118 -0
  87. package/dist/registry/index.d.ts +13 -0
  88. package/dist/registry/index.js +14 -0
  89. package/dist/registry/installer.d.ts +53 -0
  90. package/dist/registry/installer.js +170 -0
  91. package/dist/registry/integrity.d.ts +32 -0
  92. package/dist/registry/integrity.js +92 -0
  93. package/dist/registry/loader.d.ts +50 -0
  94. package/dist/registry/loader.js +145 -0
  95. package/dist/registry/schema.d.ts +47 -0
  96. package/dist/registry/schema.js +180 -0
  97. package/dist/registry/storage.d.ts +37 -0
  98. package/dist/registry/storage.js +101 -0
  99. package/dist/registry/types.d.ts +245 -0
  100. package/dist/registry/types.js +14 -0
  101. package/dist/registry-shard/RegistryView.svelte +561 -0
  102. package/dist/registry-shard/RegistryView.svelte.d.ts +3 -0
  103. package/dist/registry-shard/registryApp.d.ts +10 -0
  104. package/dist/registry-shard/registryApp.js +24 -0
  105. package/dist/registry-shard/registryShard.svelte.d.ts +45 -0
  106. package/dist/registry-shard/registryShard.svelte.js +125 -0
  107. package/dist/shards/activate.svelte.d.ts +45 -0
  108. package/dist/shards/activate.svelte.js +124 -0
  109. package/dist/shards/registry.d.ts +4 -0
  110. package/dist/shards/registry.js +28 -0
  111. package/dist/shards/types.d.ts +155 -0
  112. package/dist/shards/types.js +20 -0
  113. package/dist/shell-shard/ShellHome.svelte +285 -0
  114. package/dist/shell-shard/ShellHome.svelte.d.ts +3 -0
  115. package/dist/shell-shard/shellShard.svelte.d.ts +2 -0
  116. package/dist/shell-shard/shellShard.svelte.js +47 -0
  117. package/dist/shellRuntime.svelte.d.ts +27 -0
  118. package/dist/shellRuntime.svelte.js +27 -0
  119. package/dist/state/backends.d.ts +26 -0
  120. package/dist/state/backends.js +99 -0
  121. package/dist/state/types.d.ts +38 -0
  122. package/dist/state/types.js +15 -0
  123. package/dist/state/zones.svelte.d.ts +52 -0
  124. package/dist/state/zones.svelte.js +141 -0
  125. package/dist/store/InstalledView.svelte +201 -0
  126. package/dist/store/InstalledView.svelte.d.ts +3 -0
  127. package/dist/store/StoreView.svelte +470 -0
  128. package/dist/store/StoreView.svelte.d.ts +3 -0
  129. package/dist/store/storeApp.d.ts +11 -0
  130. package/dist/store/storeApp.js +26 -0
  131. package/dist/store/storeShard.svelte.d.ts +29 -0
  132. package/dist/store/storeShard.svelte.js +99 -0
  133. package/dist/tokens.css +79 -0
  134. package/package.json +50 -0
@@ -0,0 +1,333 @@
1
+ <script lang="ts">
2
+ /*
3
+ * ResizableSplitter — a generic N-pane split container with drag handles.
4
+ *
5
+ * Used by <LayoutRenderer> to implement `split` nodes. Also available as a
6
+ * standalone primitive for shards that need internal split UI.
7
+ *
8
+ * Sizing model mirrors docs/design/layout.md:
9
+ * - Each pane has a size value + a mode ('fr' | 'px').
10
+ * - 'fr' panes are proportional flex-grow children and absorb window
11
+ * resize deltas and drag deltas against each other.
12
+ * - 'px' panes are pixel-pinned (flex: 0 0 Npx) and stay fixed during
13
+ * window resize; dragging a handle adjacent to a px pane resizes the
14
+ * px pane in absolute pixels.
15
+ *
16
+ * `sizes` is bindable so the parent (layout tree) can observe user drags.
17
+ * Phase 2 does not yet persist sizes — phase 7 wires that up.
18
+ */
19
+
20
+ import type { Snippet } from 'svelte';
21
+ import type { SizeMode, SplitDirection } from '../layout/types';
22
+
23
+ const MIN_PX = 40;
24
+ const COLLAPSED_PX = 28;
25
+
26
+ let {
27
+ direction,
28
+ sizes,
29
+ pinned,
30
+ collapsed,
31
+ count,
32
+ pane,
33
+ onResize,
34
+ onCollapseToggle,
35
+ }: {
36
+ direction: SplitDirection;
37
+ /**
38
+ * Per-pane sizes, read-only. The splitter computes flex bases
39
+ * from this array but does not mutate it — writes go out through
40
+ * `onResize`. Treating sizes as read-only keeps the primitive
41
+ * decoupled from how the caller stores its layout, and sidesteps
42
+ * Svelte 5's `ownership_invalid_mutation` warning that fires when
43
+ * a child component writes into a prop it didn't own.
44
+ */
45
+ sizes: number[];
46
+ pinned?: SizeMode[];
47
+ /** Per-pane collapsed state. Omitted entries default to false. */
48
+ collapsed?: boolean[];
49
+ /** Number of panes — `sizes.length` should match. */
50
+ count: number;
51
+ /** Snippet invoked once per pane with the pane index. */
52
+ pane: Snippet<[number]>;
53
+ /**
54
+ * Called whenever the splitter wants to update a pane's size.
55
+ * The parent is expected to write the value back into whatever
56
+ * it stores sizes in. A single per-index callback (rather than a
57
+ * whole-array setter) matches the drag math, which updates at
58
+ * most two panes per move, and avoids allocating a new array on
59
+ * every pointermove frame.
60
+ */
61
+ onResize?: (index: number, value: number) => void;
62
+ /** Called when a collapsed pane's header is clicked to toggle. */
63
+ onCollapseToggle?: (index: number, collapsed: boolean) => void;
64
+ } = $props();
65
+
66
+ let container: HTMLDivElement;
67
+
68
+ const modeOf = (i: number): SizeMode => pinned?.[i] ?? 'fr';
69
+ const isCollapsed = (i: number): boolean => collapsed?.[i] ?? false;
70
+
71
+ /** CSS `flex` shorthand for pane i. */
72
+ function flexFor(i: number): string {
73
+ if (isCollapsed(i)) return `0 0 ${COLLAPSED_PX}px`;
74
+ if (modeOf(i) === 'px') return `0 0 ${Math.max(MIN_PX, sizes[i])}px`;
75
+ // Proportional: grow = sizes[i], shrink = 1, basis = 0
76
+ return `${Math.max(0.0001, sizes[i])} 1 0`;
77
+ }
78
+
79
+ type DragState = {
80
+ handleIndex: number; // boundary between child handleIndex and handleIndex+1
81
+ startClient: number; // pointer x/y at drag start
82
+ startSizes: number[]; // sizes snapshot at drag start
83
+ containerPx: number; // container length along split axis
84
+ totalFr: number; // sum of 'fr' sizes at drag start
85
+ frAvailPx: number; // pixels available to fr children at drag start
86
+ };
87
+
88
+ let drag: DragState | null = $state(null);
89
+
90
+ function beginDrag(e: PointerEvent, handleIndex: number) {
91
+ // Disable resize handles adjacent to collapsed panes.
92
+ if (isCollapsed(handleIndex) || isCollapsed(handleIndex + 1)) return;
93
+
94
+ e.preventDefault();
95
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
96
+
97
+ const rect = container.getBoundingClientRect();
98
+ const containerPx = direction === 'horizontal' ? rect.width : rect.height;
99
+
100
+ // Sum fr weights and subtract pixel-pinned pane sizes to get the pixel
101
+ // space the fr children collectively occupy.
102
+ let totalFr = 0;
103
+ let pxUsed = 0;
104
+ for (let i = 0; i < sizes.length; i++) {
105
+ if (modeOf(i) === 'fr') totalFr += sizes[i];
106
+ else pxUsed += Math.max(MIN_PX, sizes[i]);
107
+ }
108
+
109
+ drag = {
110
+ handleIndex,
111
+ startClient: direction === 'horizontal' ? e.clientX : e.clientY,
112
+ startSizes: sizes.slice(),
113
+ containerPx,
114
+ totalFr,
115
+ frAvailPx: Math.max(1, containerPx - pxUsed),
116
+ };
117
+ }
118
+
119
+ function moveDrag(e: PointerEvent) {
120
+ if (!drag) return;
121
+ const client = direction === 'horizontal' ? e.clientX : e.clientY;
122
+ const deltaPx = client - drag.startClient;
123
+
124
+ const a = drag.handleIndex;
125
+ const b = a + 1;
126
+ const modeA = modeOf(a);
127
+ const modeB = modeOf(b);
128
+
129
+ // Send updates through onResize rather than mutating the prop
130
+ // directly. Writing into `sizes` would trip Svelte 5's ownership
131
+ // warning; the parent owns the array and re-derives it for us.
132
+ const frPerPx = drag.totalFr / drag.frAvailPx;
133
+
134
+ if (modeA === 'fr' && modeB === 'fr') {
135
+ // Convert delta px to fr; clamp both sides to MIN_PX worth of fr.
136
+ // Positive deltaFr grows pane a and shrinks pane b, so:
137
+ // - upper bound: deltaFr ≤ startSizes[b] - minFr (stop when b hits min)
138
+ // - lower bound: deltaFr ≥ -(startSizes[a] - minFr) (stop when a hits min)
139
+ const minFr = MIN_PX * frPerPx;
140
+ const deltaFr = deltaPx * frPerPx;
141
+ const maxDelta = drag.startSizes[b] - minFr;
142
+ const minDelta = -(drag.startSizes[a] - minFr);
143
+ const clamped = Math.min(Math.max(deltaFr, minDelta), maxDelta);
144
+ onResize?.(a, drag.startSizes[a] + clamped);
145
+ onResize?.(b, drag.startSizes[b] - clamped);
146
+ } else if (modeA === 'px' && modeB === 'fr') {
147
+ const maxDelta = drag.frAvailPx - MIN_PX; // fr side must keep MIN_PX
148
+ const minDelta = MIN_PX - drag.startSizes[a];
149
+ onResize?.(
150
+ a,
151
+ drag.startSizes[a] + Math.min(Math.max(deltaPx, minDelta), maxDelta),
152
+ );
153
+ } else if (modeA === 'fr' && modeB === 'px') {
154
+ const maxDelta = drag.startSizes[b] - MIN_PX;
155
+ const minDelta = -(drag.frAvailPx - MIN_PX);
156
+ onResize?.(
157
+ b,
158
+ drag.startSizes[b] - Math.min(Math.max(deltaPx, minDelta), maxDelta),
159
+ );
160
+ } else {
161
+ // both px
162
+ const maxDelta = drag.startSizes[b] - MIN_PX;
163
+ const minDelta = -(drag.startSizes[a] - MIN_PX);
164
+ const clamped = Math.min(Math.max(deltaPx, minDelta), maxDelta);
165
+ onResize?.(a, drag.startSizes[a] + clamped);
166
+ onResize?.(b, drag.startSizes[b] - clamped);
167
+ }
168
+ }
169
+
170
+ function endDrag(e: PointerEvent) {
171
+ if (!drag) return;
172
+ (e.target as HTMLElement).releasePointerCapture(e.pointerId);
173
+ drag = null;
174
+ }
175
+ </script>
176
+
177
+ <div
178
+ class="splitter"
179
+ class:horizontal={direction === 'horizontal'}
180
+ class:vertical={direction === 'vertical'}
181
+ bind:this={container}
182
+ >
183
+ {#each Array(count) as _, i (i)}
184
+ <div
185
+ class="splitter-pane"
186
+ class:collapsed={isCollapsed(i)}
187
+ style="flex: {flexFor(i)};"
188
+ >
189
+ {#if isCollapsed(i)}
190
+ <button
191
+ type="button"
192
+ class="collapse-header"
193
+ class:horizontal={direction === 'horizontal'}
194
+ class:vertical={direction === 'vertical'}
195
+ onclick={() => onCollapseToggle?.(i, false)}
196
+ aria-label="Expand pane"
197
+ >
198
+ <span class="collapse-icon">{direction === 'horizontal' ? '▸' : '▾'}</span>
199
+ </button>
200
+ {:else}
201
+ {#if onCollapseToggle}
202
+ <button
203
+ type="button"
204
+ class="collapse-header expanded"
205
+ class:horizontal={direction === 'horizontal'}
206
+ class:vertical={direction === 'vertical'}
207
+ onclick={() => onCollapseToggle?.(i, true)}
208
+ aria-label="Collapse pane"
209
+ >
210
+ <span class="collapse-icon">{direction === 'horizontal' ? '◂' : '▴'}</span>
211
+ </button>
212
+ {/if}
213
+ <div class="pane-content">
214
+ {@render pane(i)}
215
+ </div>
216
+ {/if}
217
+ </div>
218
+ {#if i < count - 1}
219
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
220
+ <div
221
+ class="splitter-handle"
222
+ class:dragging={drag?.handleIndex === i}
223
+ class:disabled={isCollapsed(i) || isCollapsed(i + 1)}
224
+ onpointerdown={(e) => beginDrag(e, i)}
225
+ onpointermove={moveDrag}
226
+ onpointerup={endDrag}
227
+ onpointercancel={endDrag}
228
+ role="separator"
229
+ aria-orientation={direction === 'horizontal' ? 'vertical' : 'horizontal'}
230
+ ></div>
231
+ {/if}
232
+ {/each}
233
+ </div>
234
+
235
+ <style>
236
+ .splitter {
237
+ display: flex;
238
+ width: 100%;
239
+ height: 100%;
240
+ min-width: 0;
241
+ min-height: 0;
242
+ }
243
+ .splitter.horizontal { flex-direction: row; }
244
+ .splitter.vertical { flex-direction: column; }
245
+
246
+ .splitter-pane {
247
+ position: relative;
248
+ min-width: 0;
249
+ min-height: 0;
250
+ overflow: hidden;
251
+ display: flex;
252
+ }
253
+ .horizontal > .splitter-pane { flex-direction: row; }
254
+ .vertical > .splitter-pane { flex-direction: column; }
255
+ .splitter-pane.collapsed {
256
+ overflow: visible;
257
+ }
258
+ .pane-content {
259
+ flex: 1 1 0;
260
+ position: relative;
261
+ min-width: 0;
262
+ min-height: 0;
263
+ overflow: hidden;
264
+ }
265
+
266
+ .collapse-header {
267
+ appearance: none;
268
+ flex: 0 0 auto;
269
+ display: flex;
270
+ align-items: center;
271
+ justify-content: center;
272
+ background: var(--shell-bg-elevated);
273
+ border: none;
274
+ color: var(--shell-fg-muted);
275
+ cursor: pointer;
276
+ padding: 0;
277
+ font-size: 10px;
278
+ }
279
+ .collapse-header:hover {
280
+ color: var(--shell-fg);
281
+ background: var(--shell-accent-muted);
282
+ }
283
+ /* Suppress misleading hover feedback during drag-reorganize. */
284
+ :global(body[data-dragging]) .collapse-header {
285
+ pointer-events: none;
286
+ }
287
+ .collapse-header.horizontal {
288
+ width: 100%;
289
+ height: 100%;
290
+ writing-mode: vertical-rl;
291
+ }
292
+ .collapse-header.vertical {
293
+ width: 100%;
294
+ height: 100%;
295
+ }
296
+ .collapse-header.expanded.horizontal {
297
+ width: 16px;
298
+ height: 100%;
299
+ border-right: 1px solid var(--shell-border);
300
+ }
301
+ .collapse-header.expanded.vertical {
302
+ width: 100%;
303
+ height: 16px;
304
+ border-bottom: 1px solid var(--shell-border);
305
+ }
306
+
307
+ .splitter-handle {
308
+ flex: 0 0 auto;
309
+ background: var(--shell-border);
310
+ transition: background-color 120ms ease;
311
+ touch-action: none;
312
+ }
313
+ .splitter-handle:hover,
314
+ .splitter-handle.dragging {
315
+ background: var(--shell-accent);
316
+ }
317
+ :global(body[data-dragging]) .splitter-handle {
318
+ pointer-events: none;
319
+ }
320
+ .splitter-handle.disabled {
321
+ cursor: default;
322
+ pointer-events: none;
323
+ }
324
+
325
+ .horizontal > .splitter-handle {
326
+ width: 4px;
327
+ cursor: col-resize;
328
+ }
329
+ .vertical > .splitter-handle {
330
+ height: 4px;
331
+ cursor: row-resize;
332
+ }
333
+ </style>
@@ -0,0 +1,35 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { SizeMode, SplitDirection } from '../layout/types';
3
+ type $$ComponentProps = {
4
+ direction: SplitDirection;
5
+ /**
6
+ * Per-pane sizes, read-only. The splitter computes flex bases
7
+ * from this array but does not mutate it — writes go out through
8
+ * `onResize`. Treating sizes as read-only keeps the primitive
9
+ * decoupled from how the caller stores its layout, and sidesteps
10
+ * Svelte 5's `ownership_invalid_mutation` warning that fires when
11
+ * a child component writes into a prop it didn't own.
12
+ */
13
+ sizes: number[];
14
+ pinned?: SizeMode[];
15
+ /** Per-pane collapsed state. Omitted entries default to false. */
16
+ collapsed?: boolean[];
17
+ /** Number of panes — `sizes.length` should match. */
18
+ count: number;
19
+ /** Snippet invoked once per pane with the pane index. */
20
+ pane: Snippet<[number]>;
21
+ /**
22
+ * Called whenever the splitter wants to update a pane's size.
23
+ * The parent is expected to write the value back into whatever
24
+ * it stores sizes in. A single per-index callback (rather than a
25
+ * whole-array setter) matches the drag math, which updates at
26
+ * most two panes per move, and avoids allocating a new array on
27
+ * every pointermove frame.
28
+ */
29
+ onResize?: (index: number, value: number) => void;
30
+ /** Called when a collapsed pane's header is clicked to toggle. */
31
+ onCollapseToggle?: (index: number, collapsed: boolean) => void;
32
+ };
33
+ declare const ResizableSplitter: import("svelte").Component<$$ComponentProps, {}, "">;
34
+ type ResizableSplitter = ReturnType<typeof ResizableSplitter>;
35
+ export default ResizableSplitter;
@@ -0,0 +1,305 @@
1
+ <script lang="ts" module>
2
+ /**
3
+ * Controller plugged in by a layout-aware parent to turn tab drags
4
+ * into layout mutations. The primitive itself is layout-agnostic:
5
+ * it just calls `onPointerDown` when a tab is grabbed, and asks the
6
+ * controller to hit-test the strip during a drag via `onStripHover`.
7
+ */
8
+ export interface TabDragController {
9
+ onPointerDown(index: number, event: PointerEvent, element: HTMLElement): void;
10
+ onStripHover(
11
+ stripRect: DOMRect,
12
+ pointerX: number,
13
+ pointerY: number,
14
+ tabRects: DOMRect[],
15
+ ): number | null;
16
+ onStripLeave(): void;
17
+ readonly isDragging: boolean;
18
+ }
19
+
20
+ /**
21
+ * Pure helper: given an insert index and the tab button rects,
22
+ * compute the indicator's position in strip-local coordinates.
23
+ */
24
+ export function computeIndicatorRect(
25
+ insertIndex: number,
26
+ tabEls: (HTMLButtonElement | undefined)[],
27
+ stripEl: HTMLDivElement | undefined,
28
+ ): { left: number; top: number; height: number } | null {
29
+ if (!stripEl) return null;
30
+ const stripRect = stripEl.getBoundingClientRect();
31
+ const els = tabEls.filter((el): el is HTMLButtonElement => !!el);
32
+ if (els.length === 0) {
33
+ return { left: 4, top: 2, height: stripRect.height - 4 };
34
+ }
35
+ const clamped = Math.max(0, Math.min(insertIndex, els.length));
36
+ let leftViewport: number;
37
+ if (clamped === els.length) {
38
+ const last = els[els.length - 1].getBoundingClientRect();
39
+ leftViewport = last.right;
40
+ } else {
41
+ const at = els[clamped].getBoundingClientRect();
42
+ leftViewport = at.left;
43
+ }
44
+ return {
45
+ left: leftViewport - stripRect.left - 1,
46
+ top: 2,
47
+ height: stripRect.height - 4,
48
+ };
49
+ }
50
+ </script>
51
+
52
+ <script lang="ts">
53
+ /*
54
+ * TabbedPanel — a tab strip over a single active body.
55
+ *
56
+ * Scope: render a strip of tab labels, click to switch active tab,
57
+ * render every tab's body (hiding inactive ones via `display: none`).
58
+ * If a `dragController` is provided, tab pointerdown starts a drag
59
+ * and the strip becomes a drop zone with an insertion indicator.
60
+ *
61
+ * All body snippets are rendered concurrently so every tab's
62
+ * SlotContainer stays alive while the tab is inactive. The
63
+ * re-parenting contract relies on this — see slotHostPool.ts.
64
+ */
65
+
66
+ import type { Snippet } from 'svelte';
67
+
68
+ let {
69
+ labels,
70
+ icons,
71
+ body,
72
+ activeTab,
73
+ onActiveChange,
74
+ dragController,
75
+ clickGuard,
76
+ closable,
77
+ dirty,
78
+ onClose,
79
+ }: {
80
+ labels: string[];
81
+ icons?: (string | undefined)[];
82
+ /** Snippet invoked once per tab with its index. */
83
+ body: Snippet<[number]>;
84
+ activeTab: number;
85
+ /** Called when the user picks a different tab. The parent is
86
+ * expected to write the new value back into whatever it is
87
+ * storing activeTab in. We use a callback rather than a
88
+ * $bindable prop because the parent's activeTab typically lives
89
+ * inside a larger $state object (a LayoutNode), and `bind:` on a
90
+ * sub-property trips Svelte 5's ownership warning. */
91
+ onActiveChange?: (index: number) => void;
92
+ dragController?: TabDragController;
93
+ /** Optional: called by the tab click handler; if it returns true,
94
+ * the click is ignored. Used to swallow the synthetic click that
95
+ * fires on the source tab after a drag commit. */
96
+ clickGuard?: () => boolean;
97
+ /** Per-tab closability. True if the tab can be closed. */
98
+ closable?: (boolean | undefined)[];
99
+ /** Per-tab dirty state. True if the tab has unsaved changes. */
100
+ dirty?: (boolean | undefined)[];
101
+ /** Called when the user clicks a tab's close button. */
102
+ onClose?: (index: number) => void;
103
+ } = $props();
104
+
105
+ function select(i: number) {
106
+ if (clickGuard?.()) return;
107
+ onActiveChange?.(i);
108
+ }
109
+
110
+ function handleClose(i: number, e: Event) {
111
+ e.stopPropagation(); // Don't also trigger tab selection.
112
+ onClose?.(i);
113
+ }
114
+
115
+ let stripEl: HTMLDivElement | undefined = $state();
116
+ const tabEls: (HTMLButtonElement | undefined)[] = $state([]);
117
+ let hoverInsertIndex: number | null = $state(null);
118
+
119
+ function onTabPointerDown(i: number, e: PointerEvent) {
120
+ if (!dragController) return;
121
+ const el = tabEls[i];
122
+ if (!el) return;
123
+ dragController.onPointerDown(i, e, el);
124
+ }
125
+
126
+ function onStripPointerMove(e: PointerEvent) {
127
+ if (!dragController || !dragController.isDragging || !stripEl) return;
128
+ const stripRect = stripEl.getBoundingClientRect();
129
+ const rects = tabEls
130
+ .filter((el): el is HTMLButtonElement => !!el)
131
+ .map((el) => el.getBoundingClientRect());
132
+ hoverInsertIndex = dragController.onStripHover(stripRect, e.clientX, e.clientY, rects);
133
+ }
134
+
135
+ function onStripPointerLeave() {
136
+ if (!dragController) return;
137
+ hoverInsertIndex = null;
138
+ dragController.onStripLeave();
139
+ }
140
+ </script>
141
+
142
+ <div class="tabbed-panel">
143
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
144
+ <div
145
+ class="tab-strip"
146
+ role="tablist"
147
+ tabindex="-1"
148
+ bind:this={stripEl}
149
+ onpointermove={onStripPointerMove}
150
+ onpointerleave={onStripPointerLeave}
151
+ >
152
+ {#each labels as label, i (i)}
153
+ <button
154
+ type="button"
155
+ class="tab"
156
+ class:active={activeTab === i}
157
+ role="tab"
158
+ aria-selected={activeTab === i}
159
+ bind:this={tabEls[i]}
160
+ onclick={() => select(i)}
161
+ onpointerdown={(e) => onTabPointerDown(i, e)}
162
+ onauxclick={(e) => { if (e.button === 1 && closable?.[i]) handleClose(i, e); }}
163
+ >
164
+ {#if dirty?.[i]}
165
+ <span class="tab-dirty" title="Unsaved changes"></span>
166
+ {/if}
167
+ {#if icons?.[i]}<span class="tab-icon">{icons[i]}</span>{/if}
168
+ <span class="tab-label">{label}</span>
169
+ {#if closable?.[i]}
170
+ <span
171
+ class="tab-close"
172
+ role="button"
173
+ tabindex="-1"
174
+ title="Close"
175
+ onclick={(e) => handleClose(i, e)}
176
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleClose(i, e); }}
177
+ >&#x2715;</span>
178
+ {/if}
179
+ </button>
180
+ {/each}
181
+ {#if hoverInsertIndex !== null && dragController?.isDragging}
182
+ {@const rect = computeIndicatorRect(hoverInsertIndex, tabEls, stripEl)}
183
+ {#if rect}
184
+ <div
185
+ class="drop-indicator"
186
+ style="left: {rect.left}px; height: {rect.height}px; top: {rect.top}px;"
187
+ ></div>
188
+ {/if}
189
+ {/if}
190
+ </div>
191
+
192
+ <div class="tab-body" role="tabpanel">
193
+ {#each labels as _label, i (i)}
194
+ <div class="tab-body-pane" class:active={activeTab === i}>
195
+ {@render body(i)}
196
+ </div>
197
+ {/each}
198
+ </div>
199
+ </div>
200
+
201
+ <style>
202
+ .tabbed-panel {
203
+ display: flex;
204
+ flex-direction: column;
205
+ width: 100%;
206
+ height: 100%;
207
+ min-width: 0;
208
+ min-height: 0;
209
+ background: var(--shell-bg);
210
+ }
211
+
212
+ .tab-strip {
213
+ position: relative;
214
+ flex: 0 0 auto;
215
+ display: flex;
216
+ gap: 1px;
217
+ background: var(--shell-bg-sunken);
218
+ border-bottom: 1px solid var(--shell-border);
219
+ padding: 0 var(--shell-pad-sm);
220
+ user-select: none;
221
+ }
222
+
223
+ .tab {
224
+ appearance: none;
225
+ background: transparent;
226
+ border: none;
227
+ color: var(--shell-fg-muted);
228
+ font: inherit;
229
+ font-size: 12px;
230
+ padding: var(--shell-pad-sm) var(--shell-pad-md);
231
+ margin-top: 2px;
232
+ display: inline-flex;
233
+ align-items: center;
234
+ gap: var(--shell-pad-sm);
235
+ cursor: pointer;
236
+ border-top: 2px solid transparent;
237
+ border-radius: 2px 2px 0 0;
238
+ /* While dragging we still need pointerdown on tabs, but we want
239
+ the browser's native drag image suppressed — PointerEvent path
240
+ doesn't start an HTML5 drag, but preventing text selection here
241
+ avoids spurious selection rectangles during a drag. */
242
+ touch-action: none;
243
+ }
244
+ .tab:hover {
245
+ color: var(--shell-fg);
246
+ background: var(--shell-bg-elevated);
247
+ }
248
+ .tab.active {
249
+ color: var(--shell-fg);
250
+ background: var(--shell-bg);
251
+ border-top-color: var(--shell-accent);
252
+ }
253
+ .tab-icon { font-size: 11px; }
254
+ .tab-label { white-space: nowrap; }
255
+
256
+ .tab-dirty {
257
+ width: 8px;
258
+ height: 8px;
259
+ border-radius: 50%;
260
+ background: var(--shell-accent);
261
+ flex-shrink: 0;
262
+ }
263
+ .tab-close {
264
+ display: inline-flex;
265
+ font-size: 10px;
266
+ line-height: 1;
267
+ padding: 2px;
268
+ border-radius: 3px;
269
+ color: var(--shell-fg-muted);
270
+ cursor: pointer;
271
+ flex-shrink: 0;
272
+ margin-left: auto;
273
+ }
274
+ .tab-close:hover {
275
+ color: var(--shell-fg);
276
+ background: var(--shell-bg-sunken);
277
+ }
278
+
279
+ .drop-indicator {
280
+ position: absolute;
281
+ width: 2px;
282
+ background: var(--shell-accent);
283
+ box-shadow: 0 0 6px var(--shell-accent);
284
+ pointer-events: none;
285
+ border-radius: 1px;
286
+ }
287
+
288
+ .tab-body {
289
+ flex: 1 1 auto;
290
+ position: relative;
291
+ min-width: 0;
292
+ min-height: 0;
293
+ overflow: hidden;
294
+ }
295
+ .tab-body-pane {
296
+ position: absolute;
297
+ inset: 0;
298
+ min-width: 0;
299
+ min-height: 0;
300
+ display: none;
301
+ }
302
+ .tab-body-pane.active {
303
+ display: block;
304
+ }
305
+ </style>