sh3-core 0.17.2 → 0.19.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 (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 +119 -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 +361 -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 +10 -0
  84. package/dist/shards/ctx-fetch.test.d.ts +1 -0
  85. package/dist/shards/ctx-fetch.test.js +66 -0
  86. package/dist/shards/types.d.ts +13 -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,14 @@ export async function activateShard(id, opts) {
157
159
  entry.cleanupFns.push(() => handle.dispose());
158
160
  return handle;
159
161
  },
162
+ fetch(path, init) {
163
+ const isAbsolute = path.startsWith('http://') || path.startsWith('https://');
164
+ if (isAbsolute)
165
+ return apiFetch(path, init);
166
+ const base = getEnvServerUrl();
167
+ const sep = path.startsWith('/') ? '' : '/';
168
+ return apiFetch(`${base}${sep}${path}`, init);
169
+ },
160
170
  env(defaults) {
161
171
  if (envState.proxy) {
162
172
  console.warn(`[sh3] Shard "${id}" called ctx.env() more than once; extra calls are ignored.`);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,66 @@
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
+ });
@@ -205,6 +205,19 @@ export interface ShardContext {
205
205
  registerVerb(verb: Verb): void;
206
206
  /** Obtain a file-oriented document handle scoped to this shard. */
207
207
  documents(options: DocumentHandleOptions): DocumentHandle;
208
+ /**
209
+ * Cross-origin-safe HTTP helper. Resolves relative `/api/...` paths
210
+ * against the configured serverUrl. In Tauri, routes through
211
+ * @tauri-apps/plugin-http (no browser CORS, cookies via reqwest).
212
+ * On web, behaves like `fetch` with `credentials: 'include'`.
213
+ *
214
+ * Server-shards must use this instead of global `fetch` to remain
215
+ * compatible with cross-origin clients (e.g. Tauri Android).
216
+ *
217
+ * @param path - Relative `/api/...` path or fully-qualified URL.
218
+ * @param init - Standard RequestInit.
219
+ */
220
+ fetch(path: string, init?: RequestInit): Promise<Response>;
208
221
  /**
209
222
  * Declare environment state for this shard and receive a hydrated snapshot.
210
223
  * Env state is server-authoritative, fetched once at activation, and
@@ -0,0 +1 @@
1
+ export declare function apiFetch(url: string, init?: RequestInit): Promise<Response>;
@@ -0,0 +1,65 @@
1
+ /*
2
+ * apiFetch — single source of truth for HTTP calls against the
3
+ * configured sh3-server, transparently using Tauri's plugin-http
4
+ * when running inside a Tauri webview.
5
+ *
6
+ * Browser fetch enforces CORS and respects SameSite cookie rules,
7
+ * which breaks cross-origin requests from a Tauri webview at e.g.
8
+ * `tauri://localhost` to a remote sh3-server. plugin-http (reqwest
9
+ * under the hood) is not subject to browser CORS and uses a
10
+ * per-host cookie store, so existing same-origin auth keeps working
11
+ * across origins.
12
+ *
13
+ * The plugin-http import is dynamic and behind try/catch — same
14
+ * defensive pattern as `platform/index.ts`. Vite code-splits it
15
+ * into a Tauri-only chunk that never loads in web builds.
16
+ */
17
+ import { getAuthToken } from './authToken';
18
+ let tauriFetch = null;
19
+ let tauriProbed = false;
20
+ function inTauriRuntime() {
21
+ // The plugin's fetch implementation calls into IPC via window.__TAURI__,
22
+ // so the *package being installed* is not enough — we have to be running
23
+ // inside a Tauri webview where these globals are injected.
24
+ if (typeof window === 'undefined')
25
+ return false;
26
+ return '__TAURI_INTERNALS__' in window || '__TAURI__' in window;
27
+ }
28
+ async function getTauriFetch() {
29
+ if (tauriProbed)
30
+ return tauriFetch;
31
+ tauriProbed = true;
32
+ if (!inTauriRuntime())
33
+ return null;
34
+ try {
35
+ // Static analysis is skipped so Vite/svelte-package don't try to bundle
36
+ // the optional Tauri-only dependency in pure-web builds.
37
+ const specifier = '@tauri-apps/plugin-http';
38
+ const mod = await import(/* @vite-ignore */ specifier);
39
+ tauriFetch = mod.fetch;
40
+ }
41
+ catch (_a) {
42
+ tauriFetch = null;
43
+ }
44
+ return tauriFetch;
45
+ }
46
+ export async function apiFetch(url, init) {
47
+ var _a;
48
+ // Inject Authorization: Bearer <session-token> if a session is active
49
+ // and the caller didn't already supply one. Cookies don't survive the
50
+ // cross-origin hop (SameSite=Lax + plugin-http has no cookie store),
51
+ // so the header carries the session for both transports uniformly.
52
+ const token = getAuthToken();
53
+ let finalInit = init !== null && init !== void 0 ? init : {};
54
+ if (token) {
55
+ const headers = new Headers((_a = finalInit.headers) !== null && _a !== void 0 ? _a : {});
56
+ if (!headers.has('Authorization')) {
57
+ headers.set('Authorization', `Bearer ${token}`);
58
+ finalInit = Object.assign(Object.assign({}, finalInit), { headers });
59
+ }
60
+ }
61
+ const tf = await getTauriFetch();
62
+ if (tf)
63
+ return tf(url, finalInit);
64
+ return fetch(url, Object.assign({ credentials: 'include' }, finalInit));
65
+ }
@@ -0,0 +1 @@
1
+ export {};