sh3-core 0.17.2 → 0.19.1

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 (97) hide show
  1. package/dist/Sh3.svelte +59 -4
  2. package/dist/actions/CommandPalette.svelte +1 -2
  3. package/dist/actions/listeners.js +12 -1
  4. package/dist/api.d.ts +4 -0
  5. package/dist/app/store/storeShard.svelte.js +1 -21
  6. package/dist/app/store/version.d.ts +11 -0
  7. package/dist/app/store/version.js +39 -0
  8. package/dist/app/store/version.test.d.ts +1 -0
  9. package/dist/app/store/version.test.js +44 -0
  10. package/dist/apps/lifecycle.d.ts +6 -0
  11. package/dist/apps/lifecycle.js +5 -2
  12. package/dist/apps/lifecycle.test.js +30 -0
  13. package/dist/apps/types.d.ts +12 -0
  14. package/dist/assets/iconIds.generated.d.ts +1 -1
  15. package/dist/assets/iconIds.generated.js +5 -0
  16. package/dist/assets/icons.svg +31 -0
  17. package/dist/auth/auth.svelte.js +18 -8
  18. package/dist/auth/types.d.ts +6 -0
  19. package/dist/chrome/CompactChrome.svelte +54 -20
  20. package/dist/chrome/CompactChrome.svelte.test.js +112 -5
  21. package/dist/createShell.d.ts +9 -0
  22. package/dist/createShell.js +20 -7
  23. package/dist/createShell.remoteAuth.test.d.ts +1 -0
  24. package/dist/createShell.remoteAuth.test.js +71 -0
  25. package/dist/documents/http-backend.js +12 -11
  26. package/dist/env/client.js +11 -5
  27. package/dist/files/types.d.ts +106 -0
  28. package/dist/files/types.js +1 -0
  29. package/dist/gestures/gestureRegistry.d.ts +6 -0
  30. package/dist/gestures/gestureRegistry.js +190 -0
  31. package/dist/gestures/gestureRegistry.test.d.ts +1 -0
  32. package/dist/gestures/gestureRegistry.test.js +120 -0
  33. package/dist/gestures/index.d.ts +6 -0
  34. package/dist/gestures/index.js +12 -0
  35. package/dist/gestures/pointerClaim.d.ts +7 -0
  36. package/dist/gestures/pointerClaim.js +36 -0
  37. package/dist/gestures/pointerClaim.test.d.ts +1 -0
  38. package/dist/gestures/pointerClaim.test.js +64 -0
  39. package/dist/gestures/types.d.ts +83 -0
  40. package/dist/gestures/types.js +1 -0
  41. package/dist/host-entry.d.ts +1 -0
  42. package/dist/host-entry.js +1 -0
  43. package/dist/layout/LayoutRenderer.browser.test.js +15 -3
  44. package/dist/layout/LayoutRenderer.svelte +16 -3
  45. package/dist/layout/LayoutRenderer.svelte.d.ts +2 -0
  46. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-3-splitter-drag-updates-split-sizes-when-the-splitter-handle-is-dragged-1.png +0 -0
  47. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-5-splitter-collapse-toggle-toggles-collapsed-i--on-double-click-1.png +0 -0
  48. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-6-fixed-slots-hides-the-collapse-widget-on-a-fixed-pane-but-keeps-it-on-panes-with-a-non-fixed-neighbor-1.png +0 -0
  49. package/dist/layout/compact/CarouselTabs.svelte +362 -0
  50. package/dist/layout/compact/CarouselTabs.svelte.d.ts +10 -0
  51. package/dist/layout/compact/CarouselTabs.svelte.test.d.ts +1 -0
  52. package/dist/layout/compact/CarouselTabs.svelte.test.js +300 -0
  53. package/dist/layout/compact/CompactRenderer.svelte +1 -1
  54. package/dist/layout/compact/CompactRenderer.svelte.test.js +49 -0
  55. package/dist/layout/compact/derive.js +2 -0
  56. package/dist/layout/compact/derive.test.js +37 -0
  57. package/dist/layout/compact/enrichCarousels.d.ts +8 -0
  58. package/dist/layout/compact/enrichCarousels.js +44 -0
  59. package/dist/layout/compact/enrichCarousels.test.d.ts +1 -0
  60. package/dist/layout/compact/enrichCarousels.test.js +88 -0
  61. package/dist/layout/compact/types.d.ts +3 -0
  62. package/dist/layout/drag.svelte.js +13 -0
  63. package/dist/layout/store.schemaVersion.test.js +2 -2
  64. package/dist/layout/types.d.ts +9 -1
  65. package/dist/layout/types.js +1 -1
  66. package/dist/layout/types.test.d.ts +1 -0
  67. package/dist/layout/types.test.js +26 -0
  68. package/dist/overlays/ModalFrame.svelte +3 -1
  69. package/dist/overlays/ModalFrame.svelte.d.ts +1 -0
  70. package/dist/overlays/floatDismiss.js +5 -0
  71. package/dist/overlays/focusTrap.d.ts +11 -1
  72. package/dist/overlays/focusTrap.js +11 -9
  73. package/dist/overlays/modal.js +1 -0
  74. package/dist/overlays/popup.js +4 -0
  75. package/dist/overlays/types.d.ts +9 -0
  76. package/dist/primitives/Button.svelte +18 -0
  77. package/dist/primitives/Button.svelte.d.ts +6 -0
  78. package/dist/primitives/ResizableSplitter.svelte +71 -11
  79. package/dist/primitives/ResizableSplitter.svelte.d.ts +8 -0
  80. package/dist/primitives/ResizableSplitter.svelte.test.d.ts +1 -0
  81. package/dist/primitives/ResizableSplitter.svelte.test.js +74 -0
  82. package/dist/server-shard/types.d.ts +2 -1
  83. package/dist/shards/activate.svelte.js +16 -0
  84. package/dist/shards/ctx-fetch.test.d.ts +1 -0
  85. package/dist/shards/ctx-fetch.test.js +136 -0
  86. package/dist/shards/types.d.ts +29 -0
  87. package/dist/transport/apiFetch.d.ts +1 -0
  88. package/dist/transport/apiFetch.js +65 -0
  89. package/dist/transport/apiFetch.test.d.ts +1 -0
  90. package/dist/transport/apiFetch.test.js +37 -0
  91. package/dist/transport/authToken.d.ts +2 -0
  92. package/dist/transport/authToken.js +53 -0
  93. package/dist/transport/authToken.test.d.ts +1 -0
  94. package/dist/transport/authToken.test.js +33 -0
  95. package/dist/version.d.ts +1 -1
  96. package/dist/version.js +1 -1
  97. package/package.json +1 -1
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { LAYOUT_SCHEMA_VERSION } from './types';
3
+ describe('TabsNode.wrap', () => {
4
+ it('accepts the optional wrap field', () => {
5
+ const tabs = {
6
+ type: 'tabs',
7
+ tabs: [{ slotId: 's1', viewId: null, label: 'A' }],
8
+ activeTab: 0,
9
+ wrap: true,
10
+ };
11
+ expect(tabs.wrap).toBe(true);
12
+ });
13
+ it('defaults to undefined (consumers treat as false)', () => {
14
+ const tabs = {
15
+ type: 'tabs',
16
+ tabs: [{ slotId: 's1', viewId: null, label: 'A' }],
17
+ activeTab: 0,
18
+ };
19
+ expect(tabs.wrap).toBeUndefined();
20
+ });
21
+ });
22
+ describe('LAYOUT_SCHEMA_VERSION', () => {
23
+ it('is 6 (bumped for TabsNode.wrap)', () => {
24
+ expect(LAYOUT_SCHEMA_VERSION).toBe(6);
25
+ });
26
+ });
@@ -35,19 +35,21 @@
35
35
  close,
36
36
  boxStyle,
37
37
  onBackdropClick,
38
+ initialFocus = true,
38
39
  }: {
39
40
  Content: Component<Record<string, unknown>>;
40
41
  contentProps: Record<string, unknown>;
41
42
  close: () => void;
42
43
  boxStyle?: string;
43
44
  onBackdropClick?: () => void;
45
+ initialFocus?: boolean;
44
46
  } = $props();
45
47
 
46
48
  let box: HTMLDivElement;
47
49
 
48
50
  $effect(() => {
49
51
  if (!box) return;
50
- return createFocusTrap(box);
52
+ return createFocusTrap(box, { initialFocus });
51
53
  });
52
54
 
53
55
  function handleFrameClick(ev: MouseEvent): void {
@@ -5,6 +5,7 @@ type $$ComponentProps = {
5
5
  close: () => void;
6
6
  boxStyle?: string;
7
7
  onBackdropClick?: () => void;
8
+ initialFocus?: boolean;
8
9
  };
9
10
  declare const ModalFrame: Component<$$ComponentProps, {}, "">;
10
11
  type ModalFrame = ReturnType<typeof ModalFrame>;
@@ -12,11 +12,16 @@
12
12
  * own action handlers fire on `click`/`pointerup` and are unaffected.
13
13
  */
14
14
  import { floatManager } from './float';
15
+ import { getOwner } from '../gestures';
15
16
  const registry = new Map();
16
17
  let listenerAttached = false;
17
18
  function onDocumentPointerDown(event) {
18
19
  if (registry.size === 0)
19
20
  return;
21
+ // Skip dismiss if a gesture has claimed this pointer — a swipe or drag
22
+ // is in progress and this pointerdown is not a real outside-click.
23
+ if (getOwner(event.pointerId))
24
+ return;
20
25
  const target = event.target;
21
26
  if (!target)
22
27
  return;
@@ -1 +1,11 @@
1
- export declare function createFocusTrap(container: HTMLElement): () => void;
1
+ export interface FocusTrapOptions {
2
+ /**
3
+ * When false, skip moving focus into the container on install. Tab
4
+ * cycling and previous-focus restoration still work — this only
5
+ * suppresses the initial auto-focus. Used by the command palette on
6
+ * touch-only devices to keep the on-screen keyboard from popping
7
+ * up unprompted.
8
+ */
9
+ initialFocus?: boolean;
10
+ }
11
+ export declare function createFocusTrap(container: HTMLElement, options?: FocusTrapOptions): () => void;
@@ -20,7 +20,7 @@ const FOCUSABLE_SELECTOR = [
20
20
  'textarea:not([disabled])',
21
21
  '[tabindex]:not([tabindex="-1"])',
22
22
  ].join(',');
23
- export function createFocusTrap(container) {
23
+ export function createFocusTrap(container, options = {}) {
24
24
  const previouslyFocused = document.activeElement;
25
25
  function getFocusables() {
26
26
  return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR));
@@ -47,14 +47,16 @@ export function createFocusTrap(container) {
47
47
  }
48
48
  }
49
49
  container.addEventListener('keydown', onKeydown);
50
- // Defer initial focus to the next microtask so the container contents
51
- // (which may still be rendering if createFocusTrap was called mid-mount)
52
- // have a chance to appear in the DOM.
53
- queueMicrotask(() => {
54
- var _a;
55
- const focusables = getFocusables();
56
- ((_a = focusables[0]) !== null && _a !== void 0 ? _a : container).focus();
57
- });
50
+ if (options.initialFocus !== false) {
51
+ // Defer initial focus to the next microtask so the container contents
52
+ // (which may still be rendering if createFocusTrap was called mid-mount)
53
+ // have a chance to appear in the DOM.
54
+ queueMicrotask(() => {
55
+ var _a;
56
+ const focusables = getFocusables();
57
+ ((_a = focusables[0]) !== null && _a !== void 0 ? _a : container).focus();
58
+ });
59
+ }
58
60
  return () => {
59
61
  container.removeEventListener('keydown', onKeydown);
60
62
  if (previouslyFocused && document.contains(previouslyFocused)) {
@@ -132,6 +132,7 @@ function openModal(Content, props, options) {
132
132
  close: handle.close,
133
133
  boxStyle: options === null || options === void 0 ? void 0 : options.boxStyle,
134
134
  onBackdropClick: (options === null || options === void 0 ? void 0 : options.dismissOnBackdrop) ? handle.close : undefined,
135
+ initialFocus: options === null || options === void 0 ? void 0 : options.initialFocus,
135
136
  },
136
137
  });
137
138
  entry.host = host;
@@ -26,6 +26,7 @@
26
26
  * - close() is idempotent.
27
27
  */
28
28
  import { mount, unmount } from 'svelte';
29
+ import { getOwner } from '../gestures';
29
30
  import PopupFrame from './PopupFrame.svelte';
30
31
  import { getLayerRoot } from './roots';
31
32
  import { registerDismissable } from '../navigation/back-stack';
@@ -44,6 +45,9 @@ let current = null;
44
45
  function onDocumentPointerDown(e) {
45
46
  if (!current)
46
47
  return;
48
+ // Skip dismiss if a gesture owns this pointer.
49
+ if (getOwner(e.pointerId))
50
+ return;
47
51
  const target = e.target;
48
52
  if (target && current.host.contains(target))
49
53
  return;
@@ -44,4 +44,13 @@ export interface ModalOptions {
44
44
  * expected dismissal gesture.
45
45
  */
46
46
  dismissOnBackdrop?: boolean;
47
+ /**
48
+ * When false, suppress the focus trap's initial auto-focus on the
49
+ * modal's first focusable descendant. Tab cycling within the trap
50
+ * still works, and previous-focus is still restored on close.
51
+ * Default true. Opt-out is for cases where focusing an input pops
52
+ * an on-screen keyboard the user didn't ask for (e.g. command
53
+ * palette on touch-only devices).
54
+ */
55
+ initialFocus?: boolean;
47
56
  }
@@ -21,6 +21,7 @@
21
21
  sprite,
22
22
  disabled = false,
23
23
  loading,
24
+ pressed,
24
25
  type = 'button',
25
26
  title,
26
27
  ariaLabel,
@@ -35,6 +36,12 @@
35
36
  disabled?: boolean;
36
37
  /** Controlled pending state. When true, spinner + disabled + aria-busy. */
37
38
  loading?: boolean;
39
+ /**
40
+ * Toggle state for toolbar/action buttons. When provided, sets
41
+ * aria-pressed and applies a pressed visual treatment. Orthogonal
42
+ * to `variant` — works with any variant.
43
+ */
44
+ pressed?: boolean;
38
45
  type?: 'button' | 'submit' | 'reset';
39
46
  title?: string;
40
47
  ariaLabel?: string;
@@ -77,8 +84,10 @@
77
84
  {type}
78
85
  class="sh3-btn sh3-btn--{variant}"
79
86
  class:sh3-btn--icon-only={iconOnly}
87
+ class:sh3-btn--pressed={pressed}
80
88
  disabled={disabled || pending}
81
89
  aria-busy={pending || undefined}
90
+ aria-pressed={pressed ?? undefined}
82
91
  {title}
83
92
  aria-label={ariaLabel ?? (iconOnly ? title : undefined)}
84
93
  onclick={handleClick}
@@ -134,6 +143,15 @@
134
143
  cursor: not-allowed;
135
144
  }
136
145
 
146
+ /* pressed=true: inset shadow + slight dimming to signal toggle-on state */
147
+ .sh3-btn--pressed {
148
+ box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.35);
149
+ filter: brightness(0.88);
150
+ }
151
+ .sh3-btn--pressed:hover:not(:disabled) {
152
+ filter: brightness(0.96);
153
+ }
154
+
137
155
  .sh3-btn--alert {
138
156
  background: var(--sh3-error);
139
157
  color: var(--sh3-fg-on-error);
@@ -9,6 +9,12 @@ type $$ComponentProps = {
9
9
  disabled?: boolean;
10
10
  /** Controlled pending state. When true, spinner + disabled + aria-busy. */
11
11
  loading?: boolean;
12
+ /**
13
+ * Toggle state for toolbar/action buttons. When provided, sets
14
+ * aria-pressed and applies a pressed visual treatment. Orthogonal
15
+ * to `variant` — works with any variant.
16
+ */
17
+ pressed?: boolean;
12
18
  type?: 'button' | 'submit' | 'reset';
13
19
  title?: string;
14
20
  ariaLabel?: string;
@@ -19,6 +19,8 @@
19
19
 
20
20
  import type { Snippet } from 'svelte';
21
21
  import type { SizeMode, SplitDirection } from '../layout/types';
22
+ import { claim, revoke } from '../gestures/pointerClaim';
23
+ import { ancestorCount } from '../gestures';
22
24
 
23
25
  const MIN_PX = 40;
24
26
  const COLLAPSED_PX = 28;
@@ -33,6 +35,7 @@
33
35
  pane,
34
36
  onResize,
35
37
  onCollapseToggle,
38
+ compact = false,
36
39
  }: {
37
40
  direction: SplitDirection;
38
41
  /**
@@ -69,6 +72,14 @@
69
72
  onResize?: (index: number, value: number) => void;
70
73
  /** Called when a collapsed pane's header is clicked to toggle. */
71
74
  onCollapseToggle?: (index: number, collapsed: boolean) => void;
75
+ /**
76
+ * Compact-mode rendering: drops the per-pane collapse arrows (the
77
+ * 16px side rail eats too much room on a phone) and freezes the
78
+ * resize handles into thin visual separators with no drag gesture.
79
+ * Existing collapsed panes stay visually collapsed but lose the
80
+ * expand affordance until the viewport flips back.
81
+ */
82
+ compact?: boolean;
72
83
  } = $props();
73
84
 
74
85
  let container: HTMLDivElement;
@@ -103,12 +114,19 @@
103
114
  };
104
115
 
105
116
  let drag: DragState | null = $state(null);
117
+ let activeDragPointerId: number | null = null;
106
118
 
107
119
  function beginDrag(e: PointerEvent, handleIndex: number) {
108
120
  // Disable resize handles adjacent to collapsed or fixed panes.
121
+ if (compact) return;
109
122
  if (isCollapsed(handleIndex) || isCollapsed(handleIndex + 1)) return;
110
123
  if (isHandleFrozen(handleIndex)) return;
111
124
 
125
+ const depth = container ? ancestorCount(container) : 0;
126
+ const claimGranted = claim(e.pointerId, { ownerId: 'sh3:splitter', axis: 'xy', priority: 'normal', depth });
127
+ if (!claimGranted) return;
128
+ activeDragPointerId = e.pointerId;
129
+
112
130
  e.preventDefault();
113
131
  (e.target as HTMLElement).setPointerCapture(e.pointerId);
114
132
 
@@ -188,6 +206,10 @@
188
206
  function endDrag(e: PointerEvent) {
189
207
  if (!drag) return;
190
208
  (e.target as HTMLElement).releasePointerCapture(e.pointerId);
209
+ if (activeDragPointerId !== null) {
210
+ revoke(activeDragPointerId, 'sh3:splitter');
211
+ activeDragPointerId = null;
212
+ }
191
213
  drag = null;
192
214
  }
193
215
  </script>
@@ -196,6 +218,7 @@
196
218
  class="splitter"
197
219
  class:horizontal={direction === 'horizontal'}
198
220
  class:vertical={direction === 'vertical'}
221
+ class:compact
199
222
  bind:this={container}
200
223
  >
201
224
  {#each Array(count) as _, i (i)}
@@ -205,18 +228,33 @@
205
228
  style="flex: {flexFor(i)};"
206
229
  >
207
230
  {#if isCollapsed(i)}
208
- <button
209
- type="button"
210
- class="collapse-header"
211
- class:horizontal={direction === 'horizontal'}
212
- class:vertical={direction === 'vertical'}
213
- onclick={() => onCollapseToggle?.(i, false)}
214
- aria-label="Expand pane"
215
- >
216
- <span class="collapse-icon">{direction === 'horizontal' ? '▸' : '▾'}</span>
217
- </button>
231
+ {#if compact}
232
+ <!--
233
+ Compact mode strands collapsed panes — the toggle isn't shown
234
+ so they can't be expanded until the viewport flips back to
235
+ desktop. Render an inert placeholder rather than a disabled
236
+ button so taps don't register at all.
237
+ -->
238
+ <div
239
+ class="collapse-header inert"
240
+ class:horizontal={direction === 'horizontal'}
241
+ class:vertical={direction === 'vertical'}
242
+ aria-hidden="true"
243
+ ></div>
244
+ {:else}
245
+ <button
246
+ type="button"
247
+ class="collapse-header"
248
+ class:horizontal={direction === 'horizontal'}
249
+ class:vertical={direction === 'vertical'}
250
+ onclick={() => onCollapseToggle?.(i, false)}
251
+ aria-label="Expand pane"
252
+ >
253
+ <span class="collapse-icon">{direction === 'horizontal' ? '▸' : '▾'}</span>
254
+ </button>
255
+ {/if}
218
256
  {:else}
219
- {#if onCollapseToggle && canCollapse(i)}
257
+ {#if !compact && onCollapseToggle && canCollapse(i)}
220
258
  <button
221
259
  type="button"
222
260
  class="collapse-header expanded"
@@ -367,4 +405,26 @@
367
405
  }
368
406
  .horizontal > .splitter-handle.frozen { width: 1px; }
369
407
  .vertical > .splitter-handle.frozen { height: 1px; }
408
+
409
+ /*
410
+ * Compact mode: handles are pure visual separators — 1px, no cursor
411
+ * change, no pointer events (so taps fall through to the panes
412
+ * beneath). Mirrors how the .frozen variant degrades a fixed-pane
413
+ * boundary, but applies to every handle in the splitter.
414
+ */
415
+ .splitter.compact > .splitter-handle {
416
+ pointer-events: none;
417
+ cursor: default;
418
+ opacity: 0.6;
419
+ }
420
+ .splitter.compact.horizontal > .splitter-handle { width: 1px; }
421
+ .splitter.compact.vertical > .splitter-handle { height: 1px; }
422
+ .splitter.compact > .splitter-handle:hover {
423
+ background: var(--sh3-border);
424
+ }
425
+ .collapse-header.inert {
426
+ pointer-events: none;
427
+ background: var(--sh3-bg-elevated);
428
+ flex: 0 0 auto;
429
+ }
370
430
  </style>
@@ -36,6 +36,14 @@ type $$ComponentProps = {
36
36
  onResize?: (index: number, value: number) => void;
37
37
  /** Called when a collapsed pane's header is clicked to toggle. */
38
38
  onCollapseToggle?: (index: number, collapsed: boolean) => void;
39
+ /**
40
+ * Compact-mode rendering: drops the per-pane collapse arrows (the
41
+ * 16px side rail eats too much room on a phone) and freezes the
42
+ * resize handles into thin visual separators with no drag gesture.
43
+ * Existing collapsed panes stay visually collapsed but lose the
44
+ * expand affordance until the viewport flips back.
45
+ */
46
+ compact?: boolean;
39
47
  };
40
48
  declare const ResizableSplitter: import("svelte").Component<$$ComponentProps, {}, "">;
41
49
  type ResizableSplitter = ReturnType<typeof ResizableSplitter>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ /*
2
+ * Compact-mode behavior pin for ResizableSplitter — when `compact=true`,
3
+ * the per-pane collapse arrow is not rendered, the root carries the
4
+ * `.compact` class (which CSS uses to thin handles + freeze pointer
5
+ * events), and pointer-down on a handle does not start a drag.
6
+ *
7
+ * Resize / drag / collapse interactions in desktop mode are covered by
8
+ * LayoutRenderer.browser.test.ts; this file pins the new gate only.
9
+ */
10
+ import { describe, it, expect, afterEach } from 'vitest';
11
+ import { claim, __resetForTest as resetClaims } from '../gestures/pointerClaim';
12
+ import { mount, unmount, flushSync } from 'svelte';
13
+ import ResizableSplitter from './ResizableSplitter.svelte';
14
+ const SplitterAny = ResizableSplitter;
15
+ let mounted = null;
16
+ let host = null;
17
+ function renderHost(props) {
18
+ host = document.createElement('div');
19
+ host.style.cssText = 'position: relative; width: 600px; height: 400px;';
20
+ document.body.appendChild(host);
21
+ mounted = mount(SplitterAny, { target: host, props });
22
+ flushSync();
23
+ return host;
24
+ }
25
+ afterEach(() => {
26
+ if (mounted) {
27
+ unmount(mounted);
28
+ mounted = null;
29
+ }
30
+ if (host) {
31
+ host.remove();
32
+ host = null;
33
+ }
34
+ resetClaims();
35
+ });
36
+ describe('ResizableSplitter compact-mode (dom)', () => {
37
+ const baseProps = (extra = {}) => (Object.assign({ direction: 'horizontal', sizes: [0.5, 0.5], count: 2, pane: () => null, onResize: () => { }, onCollapseToggle: () => { } }, extra));
38
+ it('desktop mode: collapse-toggle buttons render for each pane', () => {
39
+ const el = renderHost(baseProps({ compact: false }));
40
+ expect(el.querySelectorAll('[data-testid^="collapse-toggle-"]').length).toBe(2);
41
+ expect(el.querySelector('.splitter').classList.contains('compact')).toBe(false);
42
+ });
43
+ it('compact mode: no collapse-toggle buttons render', () => {
44
+ const el = renderHost(baseProps({ compact: true }));
45
+ expect(el.querySelectorAll('[data-testid^="collapse-toggle-"]').length).toBe(0);
46
+ });
47
+ it('compact mode: root carries .compact class', () => {
48
+ const el = renderHost(baseProps({ compact: true }));
49
+ expect(el.querySelector('.splitter').classList.contains('compact')).toBe(true);
50
+ });
51
+ it('compact mode: pointerdown on a handle does not begin a drag (no .dragging class)', () => {
52
+ const el = renderHost(baseProps({ compact: true }));
53
+ const handle = el.querySelector('.splitter-handle');
54
+ expect(handle).not.toBeNull();
55
+ // Simulate pointerdown — beginDrag should bail before adding .dragging.
56
+ const evt = new PointerEvent('pointerdown', { bubbles: true, pointerId: 1 });
57
+ handle.dispatchEvent(evt);
58
+ flushSync();
59
+ expect(handle.classList.contains('dragging')).toBe(false);
60
+ });
61
+ });
62
+ describe('ResizableSplitter PointerClaim integration', () => {
63
+ const baseProps = (extra = {}) => (Object.assign({ direction: 'horizontal', sizes: [0.5, 0.5], count: 2, pane: () => null, onResize: () => { }, onCollapseToggle: () => { } }, extra));
64
+ it('does not begin drag when pointer is already claimed', () => {
65
+ const el = renderHost(baseProps({ compact: false }));
66
+ const handle = el.querySelector('.splitter-handle');
67
+ expect(handle).not.toBeNull();
68
+ claim(1, { ownerId: 'app:pan', axis: 'xy', priority: 'normal', depth: 99 });
69
+ const evt = new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, isPrimary: true });
70
+ handle.dispatchEvent(evt);
71
+ flushSync();
72
+ expect(handle.classList.contains('dragging')).toBe(false);
73
+ });
74
+ });
@@ -33,7 +33,8 @@ export interface TenantDocumentAPI {
33
33
  shardId: string;
34
34
  path: string;
35
35
  content: string | Uint8Array;
36
- incomingVersion: number;
36
+ /** The exact version the store will write. Not auto-incremented. */
37
+ assignedVersion: number;
37
38
  expectedLocalVersion: number;
38
39
  origin: string;
39
40
  deleted?: boolean;
@@ -21,6 +21,8 @@ import { registerView, unregisterView, registerVerb as fwRegisterVerb, unregiste
21
21
  import { makeSh3Api } from '../sh3Api/headless';
22
22
  import { createDocumentHandle, getTenantId, getDocumentBackend } from '../documents';
23
23
  import { fetchEnvState, putEnvState } from '../env/client';
24
+ import { getEnvServerUrl } from '../env/index';
25
+ import { apiFetch } from '../transport/apiFetch';
24
26
  import { isAdmin as checkIsAdmin } from '../auth/index';
25
27
  import { createZoneManager } from '../state/manage';
26
28
  import { PERMISSION_STATE_MANAGE } from '../state/types';
@@ -157,6 +159,20 @@ export async function activateShard(id, opts) {
157
159
  entry.cleanupFns.push(() => handle.dispose());
158
160
  return handle;
159
161
  },
162
+ fetch(path, init) {
163
+ return apiFetch(this.resolveUrl(path), init);
164
+ },
165
+ get serverUrl() {
166
+ return getEnvServerUrl();
167
+ },
168
+ resolveUrl(path) {
169
+ const isAbsolute = path.startsWith('http://') || path.startsWith('https://');
170
+ if (isAbsolute)
171
+ return path;
172
+ const base = getEnvServerUrl();
173
+ const sep = path.startsWith('/') ? '' : '/';
174
+ return `${base}${sep}${path}`;
175
+ },
160
176
  env(defaults) {
161
177
  if (envState.proxy) {
162
178
  console.warn(`[sh3] Shard "${id}" called ctx.env() more than once; extra calls are ignored.`);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,136 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2
+ import { MemoryDocumentBackend } from '../documents/backends';
3
+ import { __setDocumentBackend, __setTenantId } from '../documents/config';
4
+ import { __setEnvServerUrl } from '../env/index';
5
+ import { registerShard, activateShard, __resetShardRegistryForTest, } from './activate.svelte';
6
+ import { __resetViewRegistryForTest } from './registry';
7
+ describe('ctx.fetch', () => {
8
+ let originalFetch;
9
+ beforeEach(() => {
10
+ originalFetch = globalThis.fetch;
11
+ __resetShardRegistryForTest();
12
+ __resetViewRegistryForTest();
13
+ __setDocumentBackend(new MemoryDocumentBackend());
14
+ __setTenantId('tenant-test');
15
+ __setEnvServerUrl('https://example.com');
16
+ });
17
+ afterEach(() => {
18
+ globalThis.fetch = originalFetch;
19
+ __setEnvServerUrl('');
20
+ });
21
+ it('resolves relative paths against the configured serverUrl', async () => {
22
+ const calls = [];
23
+ globalThis.fetch = vi.fn(async (input) => {
24
+ calls.push(String(input));
25
+ return new Response('ok');
26
+ });
27
+ let captured = null;
28
+ registerShard({
29
+ manifest: { id: 'test', label: 'test', version: '0.0.0', views: [] },
30
+ activate(ctx) { captured = ctx; },
31
+ });
32
+ await activateShard('test');
33
+ await captured.fetch('/api/foo');
34
+ expect(calls[0]).toBe('https://example.com/api/foo');
35
+ });
36
+ it('passes absolute URLs through unchanged', async () => {
37
+ const calls = [];
38
+ globalThis.fetch = vi.fn(async (input) => {
39
+ calls.push(String(input));
40
+ return new Response('ok');
41
+ });
42
+ let captured = null;
43
+ registerShard({
44
+ manifest: { id: 'test', label: 'test', version: '0.0.0', views: [] },
45
+ activate(ctx) { captured = ctx; },
46
+ });
47
+ await activateShard('test');
48
+ await captured.fetch('https://other.example.com/api/bar');
49
+ expect(calls[0]).toBe('https://other.example.com/api/bar');
50
+ });
51
+ it('prepends a slash to bare relative paths', async () => {
52
+ const calls = [];
53
+ globalThis.fetch = vi.fn(async (input) => {
54
+ calls.push(String(input));
55
+ return new Response('ok');
56
+ });
57
+ let captured = null;
58
+ registerShard({
59
+ manifest: { id: 'test', label: 'test', version: '0.0.0', views: [] },
60
+ activate(ctx) { captured = ctx; },
61
+ });
62
+ await activateShard('test');
63
+ await captured.fetch('api/baz');
64
+ expect(calls[0]).toBe('https://example.com/api/baz');
65
+ });
66
+ });
67
+ describe('ctx.serverUrl', () => {
68
+ beforeEach(() => {
69
+ __resetShardRegistryForTest();
70
+ __resetViewRegistryForTest();
71
+ __setDocumentBackend(new MemoryDocumentBackend());
72
+ __setTenantId('tenant-test');
73
+ __setEnvServerUrl('https://example.com');
74
+ });
75
+ afterEach(() => {
76
+ __setEnvServerUrl('');
77
+ });
78
+ it('returns the configured server base URL', async () => {
79
+ let captured = null;
80
+ registerShard({
81
+ manifest: { id: 'test', label: 'test', version: '0.0.0', views: [] },
82
+ activate(ctx) { captured = ctx; },
83
+ });
84
+ await activateShard('test');
85
+ expect(captured.serverUrl).toBe('https://example.com');
86
+ });
87
+ it('returns empty string when no server URL is configured', async () => {
88
+ __setEnvServerUrl('');
89
+ let captured = null;
90
+ registerShard({
91
+ manifest: { id: 'test', label: 'test', version: '0.0.0', views: [] },
92
+ activate(ctx) { captured = ctx; },
93
+ });
94
+ await activateShard('test');
95
+ expect(captured.serverUrl).toBe('');
96
+ });
97
+ });
98
+ describe('ctx.resolveUrl', () => {
99
+ beforeEach(() => {
100
+ __resetShardRegistryForTest();
101
+ __resetViewRegistryForTest();
102
+ __setDocumentBackend(new MemoryDocumentBackend());
103
+ __setTenantId('tenant-test');
104
+ __setEnvServerUrl('https://example.com');
105
+ });
106
+ afterEach(() => {
107
+ __setEnvServerUrl('');
108
+ });
109
+ it('resolves a relative path to an absolute URL', async () => {
110
+ let captured = null;
111
+ registerShard({
112
+ manifest: { id: 'test', label: 'test', version: '0.0.0', views: [] },
113
+ activate(ctx) { captured = ctx; },
114
+ });
115
+ await activateShard('test');
116
+ expect(captured.resolveUrl('/api/foo')).toBe('https://example.com/api/foo');
117
+ });
118
+ it('passes absolute URLs through unchanged', async () => {
119
+ let captured = null;
120
+ registerShard({
121
+ manifest: { id: 'test', label: 'test', version: '0.0.0', views: [] },
122
+ activate(ctx) { captured = ctx; },
123
+ });
124
+ await activateShard('test');
125
+ expect(captured.resolveUrl('https://other.example.com/ws')).toBe('https://other.example.com/ws');
126
+ });
127
+ it('prepends a slash to bare relative paths', async () => {
128
+ let captured = null;
129
+ registerShard({
130
+ manifest: { id: 'test', label: 'test', version: '0.0.0', views: [] },
131
+ activate(ctx) { captured = ctx; },
132
+ });
133
+ await activateShard('test');
134
+ expect(captured.resolveUrl('api/ws')).toBe('https://example.com/api/ws');
135
+ });
136
+ });