sh3-core 0.19.0 → 0.19.3

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 (58) hide show
  1. package/dist/Sh3.svelte +3 -1
  2. package/dist/actions/menuBarModel.js +8 -0
  3. package/dist/actions/menuBarModel.test.js +61 -0
  4. package/dist/app/admin/ApiKeysView.svelte +6 -5
  5. package/dist/app/store/PermissionConfirmModal.svelte +23 -0
  6. package/dist/app/store/PermissionConfirmModal.svelte.d.ts +1 -0
  7. package/dist/app/store/StoreView.svelte +6 -1
  8. package/dist/chrome/CompactChrome.svelte.test.js +7 -4
  9. package/dist/env/client.d.ts +5 -4
  10. package/dist/env/client.js +11 -17
  11. package/dist/env/serverUrl.d.ts +2 -0
  12. package/dist/env/serverUrl.js +8 -0
  13. package/dist/gestures/gestureRegistry.test.js +1 -0
  14. package/dist/gestures/index.d.ts +17 -0
  15. package/dist/gestures/index.js +27 -0
  16. package/dist/keys/client.js +6 -7
  17. package/dist/keys/revocation-bus.svelte.js +11 -1
  18. package/dist/layout/compact/CarouselTabs.svelte +152 -15
  19. package/dist/layout/compact/CarouselTabs.svelte.test.js +222 -2
  20. package/dist/layout/compact/CompactRenderer.svelte +1 -1
  21. package/dist/layout/compact/CompactRenderer.svelte.test.js +5 -3
  22. package/dist/layout/compact/derive.js +7 -16
  23. package/dist/layout/compact/derive.test.js +30 -9
  24. package/dist/layout/drag.svelte.js +16 -3
  25. package/dist/layout/inspection.d.ts +20 -9
  26. package/dist/layout/inspection.js +66 -11
  27. package/dist/layout/inspection.svelte.test.d.ts +1 -0
  28. package/dist/layout/inspection.svelte.test.js +114 -0
  29. package/dist/layout/store.schemaVersion.test.js +2 -2
  30. package/dist/layout/types.d.ts +11 -8
  31. package/dist/layout/types.js +1 -1
  32. package/dist/layout/types.test.js +2 -2
  33. package/dist/overlays/FloatFrame.svelte +93 -22
  34. package/dist/primitives/ResizableSplitter.svelte +42 -8
  35. package/dist/registry/checkFetch.d.ts +6 -0
  36. package/dist/registry/checkFetch.js +23 -0
  37. package/dist/sh3/views/KeysAndPeers.svelte +4 -3
  38. package/dist/shards/activate-runtime.test.js +99 -1
  39. package/dist/shards/activate.svelte.js +20 -5
  40. package/dist/shards/ctx-fetch.test.js +70 -0
  41. package/dist/shards/registry.d.ts +8 -1
  42. package/dist/shards/registry.js +13 -2
  43. package/dist/shards/registry.test.js +25 -4
  44. package/dist/shards/types.d.ts +30 -1
  45. package/dist/shell-shard/ScrollbackView.svelte +145 -67
  46. package/dist/shell-shard/ScrollbackView.svelte.test.d.ts +1 -0
  47. package/dist/shell-shard/ScrollbackView.svelte.test.js +182 -0
  48. package/dist/shell-shard/dispatch-gating.test.js +38 -2
  49. package/dist/shell-shard/dispatch.js +9 -1
  50. package/dist/shell-shard/registry-resolve.test.js +50 -0
  51. package/dist/shell-shard/registry.d.ts +2 -1
  52. package/dist/shell-shard/registry.js +12 -2
  53. package/dist/shell-shard/verbs/help.js +5 -4
  54. package/dist/shell-shard/verbs/help.svelte.test.js +5 -2
  55. package/dist/verbs/types.d.ts +10 -5
  56. package/dist/version.d.ts +1 -1
  57. package/dist/version.js +1 -1
  58. package/package.json +1 -1
@@ -18,8 +18,8 @@
18
18
  import type { TabsNode, TabEntry, TreeRootRef } from '../types';
19
19
  import SlotContainer from '../SlotContainer.svelte';
20
20
  import SlotDropZone from '../SlotDropZone.svelte';
21
- import { claim, revoke } from '../../gestures/pointerClaim';
22
- import { ancestorCount } from '../../gestures';
21
+ import { claim, revoke, isOwner } from '../../gestures/pointerClaim';
22
+ import { ancestorCount, EDGE_PX, logGesture } from '../../gestures';
23
23
 
24
24
  let {
25
25
  node,
@@ -62,6 +62,27 @@
62
62
  let dragging = $state(false);
63
63
  let claimed = $state(false);
64
64
  let activePointerId: number | null = null;
65
+ /**
66
+ * Set to true when the pointer-down landed inside the left/right edge
67
+ * gutter. Gutter-initiated drags get the "invincible" treatment: once
68
+ * the axis-lock threshold is crossed we explicitly call
69
+ * `setPointerCapture` on the carousel container so the descendant the
70
+ * touch originally landed on (textarea, scroll region, preview canvas
71
+ * — see the earlier `cancel-our-id` log targets) no longer receives the
72
+ * pointer stream and therefore cannot fire `pointercancel` to abort us.
73
+ *
74
+ * Mid-track drags don't get capture — the documented contract is that
75
+ * a descendant claiming the gesture there wins.
76
+ */
77
+ let startedInGutter = false;
78
+ /**
79
+ * Soft window (`performance.now()` deadline) during which a single
80
+ * `pointercancel` is treated as the synthetic Android transfer-cancel
81
+ * that fires when `setPointerCapture` moves implicit capture from a
82
+ * descendant up to the container. Without this filter, the carousel's
83
+ * own capture call would immediately abort itself.
84
+ */
85
+ let ignoreCancelUntil = 0;
65
86
 
66
87
  type PointerSnapshot = { id: number; x: number; y: number; t: number };
67
88
  let downSnap: PointerSnapshot | null = null;
@@ -72,6 +93,16 @@
72
93
  const COMMIT_FRACTION = 0.4;
73
94
  const COMMIT_VELOCITY = 500;
74
95
  const RUBBER_BAND_MAX_FRACTION = 0.3;
96
+ /** Window (ms) during which the post-transfer pointercancel is swallowed. */
97
+ const TRANSFER_CANCEL_WINDOW_MS = 100;
98
+
99
+ function dbg(label: string, ev: PointerEvent | null): void {
100
+ logGesture(`carousel:${label}`, ev, {
101
+ claimed,
102
+ dragging,
103
+ downId: downSnap?.id,
104
+ });
105
+ }
75
106
  /**
76
107
  * If the pointer takes longer than this to cross the axis-lock threshold,
77
108
  * treat the gesture as text-selection intent (slow drag over text) and
@@ -80,16 +111,45 @@
80
111
  */
81
112
  const SLOW_DRAG_TIMEOUT_MS = 250;
82
113
 
114
+ /**
115
+ * True only when an ancestor has scrollable horizontal overflow that
116
+ * *actually* overflows. `overflow-x: auto` on a wrapper that doesn't
117
+ * exceed its clientWidth used to disqualify every swipe inside it — a
118
+ * common false positive for body views that set `overflow: auto` to get
119
+ * vertical scrolling. We now also require `scrollWidth > clientWidth`
120
+ * so only genuinely scrollable regions block the carousel.
121
+ */
83
122
  function hasNativeHorizontalScroll(target: EventTarget | null): boolean {
84
123
  let el = target as HTMLElement | null;
85
124
  while (el && el !== containerEl) {
86
125
  const ox = getComputedStyle(el).overflowX;
87
- if (ox === 'auto' || ox === 'scroll') return true;
126
+ if ((ox === 'auto' || ox === 'scroll') && el.scrollWidth > el.clientWidth) {
127
+ return true;
128
+ }
88
129
  el = el.parentElement;
89
130
  }
90
131
  return false;
91
132
  }
92
133
 
134
+ /**
135
+ * True when the pointer-down x sits in the left or right EDGE_PX strip of
136
+ * the carousel container. Edge-gutter pointer-downs claim the swipe
137
+ * regardless of editable-target or native-horizontal-scroll content
138
+ * underneath — the gutter is documented as an unconditional swipe
139
+ * initiation zone. Without this, any input or `overflow: auto` region
140
+ * touching the edge silently kills edge swipes.
141
+ */
142
+ function isInEdgeGutter(clientX: number): boolean {
143
+ if (!containerEl || containerWidth === 0) return false;
144
+ // rect.left for the offset, containerWidth (reactive, ResizeObserver-fed)
145
+ // for the width — getBoundingClientRect().width is 0 under happy-dom and
146
+ // similar non-layout DOMs, while containerWidth is seeded from the
147
+ // ResizeObserver path used everywhere else in this component.
148
+ const rect = containerEl.getBoundingClientRect();
149
+ const local = clientX - rect.left;
150
+ return local < EDGE_PX || local > containerWidth - EDGE_PX;
151
+ }
152
+
93
153
  function isEditableTarget(target: EventTarget | null): boolean {
94
154
  const el = target as HTMLElement | null;
95
155
  if (!el || typeof el.tagName !== 'string') return false;
@@ -118,16 +178,28 @@
118
178
  }
119
179
 
120
180
  function onPointerDown(ev: PointerEvent) {
121
- if (isEditableTarget(ev.target)) return;
122
- if (hasNativeHorizontalScroll(ev.target)) return;
181
+ // Structural bails — apply regardless of where the touch lands. Without
182
+ // these the carousel can't even set up a meaningful gesture.
123
183
  if (tabCount < 2) return;
124
184
  if (containerWidth === 0) return;
125
185
  // Multi-touch (pinch-zoom etc.) is not a swipe — bail.
126
186
  if (ev.isPrimary === false) return;
187
+ // Edge gutter override: pointer-downs in the left/right EDGE_PX strip
188
+ // claim the swipe unconditionally. This is the published invariant for
189
+ // edge initiation; bailing here would re-introduce the bug where any
190
+ // editable target or overflow-auto wrapper sitting near the screen edge
191
+ // silently kills the gesture.
192
+ const inGutter = isInEdgeGutter(ev.clientX);
193
+ if (!inGutter) {
194
+ if (isEditableTarget(ev.target)) return;
195
+ if (hasNativeHorizontalScroll(ev.target)) return;
196
+ }
127
197
  const depth = containerEl ? ancestorCount(containerEl) : 0;
128
- const claimGranted = claim(ev.pointerId, { ownerId: 'sh3:carousel', axis: 'x', priority: 'edge', depth });
198
+ const claimGranted = claim(ev.pointerId, { ownerId: 'sh3:carousel', axis: 'x', priority: 'normal', depth });
129
199
  if (!claimGranted) return;
130
200
  activePointerId = ev.pointerId;
201
+ startedInGutter = inGutter;
202
+ ignoreCancelUntil = 0;
131
203
  downSnap = { id: ev.pointerId, x: ev.clientX, y: ev.clientY, t: performance.now() };
132
204
  lastSnap = { ...downSnap };
133
205
  dragging = false;
@@ -140,6 +212,11 @@
140
212
 
141
213
  function onPointerMove(ev: PointerEvent) {
142
214
  if (!downSnap || ev.pointerId !== downSnap.id) return;
215
+ if (!isOwner(ev.pointerId, 'sh3:carousel')) {
216
+ dbg('claim-stolen', ev);
217
+ endGesture();
218
+ return;
219
+ }
143
220
  const dx = ev.clientX - downSnap.x;
144
221
  const dy = ev.clientY - downSnap.y;
145
222
  if (!claimed) {
@@ -158,12 +235,29 @@
158
235
  if (!horizDominates) return;
159
236
  claimed = true;
160
237
  dragging = true;
161
- // Don't call setPointerCapture: transferring capture from a
162
- // descendant that had implicit capture (a button the finger
163
- // touched) makes Android fire pointercancel on the original
164
- // target, which our document-level cancel listener would then
165
- // treat as an abort. The pointercancel-aborts-no-commit logic
166
- // is enough on its own to fix the auto-commit bug.
238
+ // Gutter-initiated drags get the "invincible" guarantee: transfer
239
+ // implicit pointer capture from the descendant the touch landed on
240
+ // (a TEXTAREA, scroll region, etc.) up to the carousel container so
241
+ // those descendants can no longer fire pointercancel mid-pan. The
242
+ // transfer itself fires one synthetic pointercancel on Android (see
243
+ // CarouselTabs commit 638d75a for the original incident); the
244
+ // ignoreCancelUntil window below swallows just that one cancel —
245
+ // every other cancel still aborts as before.
246
+ //
247
+ // Mid-track drags intentionally skip this: the contract there is
248
+ // that a descendant claiming the gesture wins, matching the
249
+ // "carousel aborts if it's caught by an inner element" behavior
250
+ // the rest of the bail logic already encodes.
251
+ if (startedInGutter && containerEl) {
252
+ try {
253
+ containerEl.setPointerCapture(ev.pointerId);
254
+ ignoreCancelUntil = performance.now() + TRANSFER_CANCEL_WINDOW_MS;
255
+ } catch {
256
+ // setPointerCapture can throw if the pointer is no longer
257
+ // active (race with platform-level cancel). Safe to swallow —
258
+ // the gesture will run on implicit capture as before.
259
+ }
260
+ }
167
261
  // Clear any selection that began during the pre-claim window so
168
262
  // it doesn't visually streak across the slide while dragging.
169
263
  if (typeof window !== 'undefined') {
@@ -176,8 +270,14 @@
176
270
  }
177
271
 
178
272
  function onPointerUp(ev: PointerEvent) {
179
- if (!downSnap || ev.pointerId !== downSnap.id) {
180
- endGesture();
273
+ if (!downSnap) return;
274
+ // Filter by pointer id. A pointerup for a different pointer (e.g. a
275
+ // second finger lifting, a stylus releasing while a finger drag is
276
+ // active, or a phantom multi-pointer) must NOT tear down our gesture —
277
+ // earlier behavior here was to call endGesture() on any unrelated
278
+ // pointerup, which dropped legitimate drags on multi-touch devices.
279
+ if (ev.pointerId !== downSnap.id) {
280
+ dbg('pointerup-other-id', ev);
181
281
  return;
182
282
  }
183
283
  const dx = ev.clientX - downSnap.x;
@@ -197,12 +297,47 @@
197
297
  * mid-drag would be read as a release at the current dx and trip
198
298
  * the commit threshold, "auto-completing" the swipe with the
199
299
  * finger still down.
300
+ *
301
+ * MUST filter by pointer id. The previous unconditional implementation
302
+ * meant any pointercancel — palm contact, ghost touches, a stylus
303
+ * cancellation while a finger drag was active — terminated the carousel
304
+ * gesture. That's the most likely cause of "drag releases mid-pan on
305
+ * some screens" reports.
200
306
  */
201
- function onPointerCancel(_ev: PointerEvent) {
307
+ function onPointerCancel(ev: PointerEvent) {
308
+ if (!downSnap) return;
309
+ if (ev.pointerId !== downSnap.id) {
310
+ dbg('cancel-other-id', ev);
311
+ return;
312
+ }
313
+ // Single-shot swallow for the synthetic Android transfer-cancel that
314
+ // fires right after we call `setPointerCapture` on a gutter swipe.
315
+ // Clear the deadline once consumed so a real cancel later in the same
316
+ // gesture still aborts normally.
317
+ if (performance.now() < ignoreCancelUntil) {
318
+ dbg('cancel-ignored-transfer', ev);
319
+ ignoreCancelUntil = 0;
320
+ return;
321
+ }
322
+ dbg('cancel-our-id', ev);
202
323
  endGesture();
203
324
  }
204
325
 
205
326
  function endGesture() {
327
+ // Release explicit pointer capture if we acquired it for a gutter
328
+ // swipe. Browsers auto-release on pointerup/cancel, but doing it
329
+ // defensively here keeps the container's `hasPointerCapture` state
330
+ // clean if endGesture is called from a path that didn't naturally
331
+ // release (claim-stolen, unmount, etc.).
332
+ if (startedInGutter && containerEl && activePointerId !== null) {
333
+ try {
334
+ if (containerEl.hasPointerCapture?.(activePointerId)) {
335
+ containerEl.releasePointerCapture(activePointerId);
336
+ }
337
+ } catch {
338
+ // Defensive — happy-dom / older browsers may not implement it.
339
+ }
340
+ }
206
341
  if (activePointerId !== null) {
207
342
  revoke(activePointerId, 'sh3:carousel');
208
343
  activePointerId = null;
@@ -215,6 +350,8 @@
215
350
  dragging = false;
216
351
  claimed = false;
217
352
  dragDelta = 0;
353
+ startedInGutter = false;
354
+ ignoreCancelUntil = 0;
218
355
  }
219
356
 
220
357
  function commitOrSnap(dx: number, velocity: number) {
@@ -202,25 +202,32 @@ describe('CarouselTabs (gestures)', () => {
202
202
  flushSync();
203
203
  expect(node.activeTab).toBe(0);
204
204
  });
205
- it('pointerdown inside an overflow-x:scroll element does not initiate a swipe', () => {
205
+ it('pointerdown inside an overflow-x:scroll element with actual horizontal overflow does not initiate a swipe', () => {
206
206
  const node = makeNode(['A', 'B', 'C'], 0);
207
207
  mountCarousel({ node, wrap: false });
208
208
  const slide = host.querySelector('[data-sh3-slide]');
209
209
  const scroller = document.createElement('div');
210
210
  scroller.style.overflowX = 'scroll';
211
+ Object.defineProperty(scroller, 'scrollWidth', { value: 1000, configurable: true });
212
+ Object.defineProperty(scroller, 'clientWidth', { value: 200, configurable: true });
211
213
  slide.appendChild(scroller);
214
+ // Pointer-down in the center (x=250 < 276 right-gutter boundary).
215
+ // dx=-170 would commit if the bail were absent, so a passing test
216
+ // genuinely proves the gesture never started.
212
217
  scroller.dispatchEvent(fakePointer('pointerdown', 250, 100));
213
218
  document.dispatchEvent(fakePointer('pointermove', 80, 100));
214
219
  document.dispatchEvent(fakePointer('pointerup', 80, 100));
215
220
  flushSync();
216
221
  expect(node.activeTab).toBe(0);
217
222
  });
218
- it('pointerdown inside an overflow-x:auto element does not initiate a swipe', () => {
223
+ it('pointerdown inside an overflow-x:auto element with actual horizontal overflow does not initiate a swipe', () => {
219
224
  const node = makeNode(['A', 'B', 'C'], 0);
220
225
  mountCarousel({ node, wrap: false });
221
226
  const slide = host.querySelector('[data-sh3-slide]');
222
227
  const scroller = document.createElement('div');
223
228
  scroller.style.overflowX = 'auto';
229
+ Object.defineProperty(scroller, 'scrollWidth', { value: 1000, configurable: true });
230
+ Object.defineProperty(scroller, 'clientWidth', { value: 200, configurable: true });
224
231
  slide.appendChild(scroller);
225
232
  scroller.dispatchEvent(fakePointer('pointerdown', 250, 100));
226
233
  document.dispatchEvent(fakePointer('pointermove', 80, 100));
@@ -228,6 +235,25 @@ describe('CarouselTabs (gestures)', () => {
228
235
  flushSync();
229
236
  expect(node.activeTab).toBe(0);
230
237
  });
238
+ it('overflow-x:auto with no actual horizontal overflow does NOT block the swipe', () => {
239
+ // The common false-positive: a body view sets `overflow: auto` to get
240
+ // vertical scrolling; the resolved overflow-x is `auto` even though the
241
+ // content fits horizontally. Previously this killed every carousel swipe
242
+ // initiated inside the body. With the scrollWidth>clientWidth tightening,
243
+ // only genuinely scrollable regions block.
244
+ const node = makeNode(['A', 'B', 'C'], 0);
245
+ mountCarousel({ node, wrap: false });
246
+ const slide = host.querySelector('[data-sh3-slide]');
247
+ const wrapper = document.createElement('div');
248
+ wrapper.style.overflowX = 'auto';
249
+ // scrollWidth / clientWidth default to 0 in happy-dom — no actual overflow.
250
+ slide.appendChild(wrapper);
251
+ wrapper.dispatchEvent(fakePointer('pointerdown', 250, 100));
252
+ document.dispatchEvent(fakePointer('pointermove', 80, 100));
253
+ document.dispatchEvent(fakePointer('pointerup', 80, 100));
254
+ flushSync();
255
+ expect(node.activeTab).toBe(1);
256
+ });
231
257
  it('pointerdown inside an overflow-x:hidden element still allows swipe', () => {
232
258
  const node = makeNode(['A', 'B', 'C'], 0);
233
259
  mountCarousel({ node, wrap: false });
@@ -267,6 +293,200 @@ describe('CarouselTabs (gestures)', () => {
267
293
  expect(track.classList.contains('sh3-carousel-track--dragging')).toBe(false);
268
294
  });
269
295
  });
296
+ describe('CarouselTabs — multi-pointer filter', () => {
297
+ // The carousel attaches its pointermove / pointerup / pointercancel
298
+ // listeners on `document`, so any pointer's events for those types
299
+ // reach our handler. Without filtering by pointer id, an unrelated
300
+ // pointer's release or cancel (palm contact, second finger, stylus
301
+ // ghost) would tear down a legitimate drag.
302
+ it('pointercancel for a DIFFERENT pointer id does not abort the active drag', () => {
303
+ const node = makeNode(['A', 'B', 'C'], 0);
304
+ mountCarousel({ node, wrap: false });
305
+ const track = host.querySelector('[data-sh3-carousel-track]');
306
+ // Drag with pointer id 1.
307
+ track.dispatchEvent(fakePointer('pointerdown', 250, 100, 1));
308
+ document.dispatchEvent(fakePointer('pointermove', 200, 100, 1));
309
+ document.dispatchEvent(fakePointer('pointermove', 100, 100, 1));
310
+ // A different pointer (id 2) is cancelled — must NOT end our gesture.
311
+ document.dispatchEvent(fakePointer('pointercancel', 0, 0, 2));
312
+ // Our pointer keeps going past the commit threshold and releases.
313
+ document.dispatchEvent(fakePointer('pointermove', 80, 100, 1));
314
+ document.dispatchEvent(fakePointer('pointerup', 80, 100, 1));
315
+ flushSync();
316
+ expect(node.activeTab).toBe(1);
317
+ });
318
+ it('pointerup for a DIFFERENT pointer id does not end the active drag', () => {
319
+ const node = makeNode(['A', 'B', 'C'], 0);
320
+ mountCarousel({ node, wrap: false });
321
+ const track = host.querySelector('[data-sh3-carousel-track]');
322
+ track.dispatchEvent(fakePointer('pointerdown', 250, 100, 1));
323
+ document.dispatchEvent(fakePointer('pointermove', 200, 100, 1));
324
+ document.dispatchEvent(fakePointer('pointermove', 100, 100, 1));
325
+ // A different pointer (id 2) is released — must NOT end our gesture.
326
+ document.dispatchEvent(fakePointer('pointerup', 0, 0, 2));
327
+ document.dispatchEvent(fakePointer('pointermove', 80, 100, 1));
328
+ document.dispatchEvent(fakePointer('pointerup', 80, 100, 1));
329
+ flushSync();
330
+ expect(node.activeTab).toBe(1);
331
+ });
332
+ it('pointercancel for OUR pointer id still aborts', () => {
333
+ // Sanity check — the existing abort-on-cancel behavior is preserved
334
+ // when the cancellation is for our active pointer.
335
+ const node = makeNode(['A', 'B', 'C'], 0);
336
+ mountCarousel({ node, wrap: false });
337
+ const track = host.querySelector('[data-sh3-carousel-track]');
338
+ track.dispatchEvent(fakePointer('pointerdown', 250, 100, 1));
339
+ document.dispatchEvent(fakePointer('pointermove', 200, 100, 1));
340
+ document.dispatchEvent(fakePointer('pointermove', 100, 100, 1));
341
+ document.dispatchEvent(fakePointer('pointercancel', 100, 100, 1));
342
+ flushSync();
343
+ expect(node.activeTab).toBe(0);
344
+ });
345
+ });
346
+ describe('CarouselTabs — edge gutter invariant', () => {
347
+ // EDGE_PX = 24. Container is 300px wide. Left gutter: x < 24.
348
+ // Right gutter: x > 276.
349
+ it('left-edge pointer-down initiates a swipe even on an editable target', () => {
350
+ // Without the gutter override, an <input> sitting under the finger at
351
+ // the screen edge would silently swallow the drag (isEditableTarget bail).
352
+ const node = makeNode(['A', 'B', 'C'], 1);
353
+ mountCarousel({ node, wrap: false });
354
+ const slide = host.querySelectorAll('[data-sh3-slide]')[1];
355
+ const input = document.createElement('input');
356
+ slide.appendChild(input);
357
+ // x=10 — inside left edge gutter. Swipe right → previous tab.
358
+ input.dispatchEvent(fakePointer('pointerdown', 10, 100));
359
+ document.dispatchEvent(fakePointer('pointermove', 80, 100));
360
+ document.dispatchEvent(fakePointer('pointermove', 200, 100));
361
+ document.dispatchEvent(fakePointer('pointerup', 200, 100));
362
+ flushSync();
363
+ expect(node.activeTab).toBe(0);
364
+ });
365
+ it('right-edge pointer-down initiates a swipe even inside an overflow-x:auto region with real overflow', () => {
366
+ const node = makeNode(['A', 'B', 'C'], 0);
367
+ mountCarousel({ node, wrap: false });
368
+ const slide = host.querySelectorAll('[data-sh3-slide]')[0];
369
+ const scroller = document.createElement('div');
370
+ scroller.style.overflowX = 'auto';
371
+ Object.defineProperty(scroller, 'scrollWidth', { value: 1000, configurable: true });
372
+ Object.defineProperty(scroller, 'clientWidth', { value: 200, configurable: true });
373
+ slide.appendChild(scroller);
374
+ // x=290 — inside right edge gutter (300 - 24 = 276). Swipe left → next tab.
375
+ scroller.dispatchEvent(fakePointer('pointerdown', 290, 100));
376
+ document.dispatchEvent(fakePointer('pointermove', 200, 100));
377
+ document.dispatchEvent(fakePointer('pointermove', 80, 100));
378
+ document.dispatchEvent(fakePointer('pointerup', 80, 100));
379
+ flushSync();
380
+ expect(node.activeTab).toBe(1);
381
+ });
382
+ it('left-edge pointer-down on a contenteditable still initiates a swipe', () => {
383
+ const node = makeNode(['A', 'B', 'C'], 1);
384
+ mountCarousel({ node, wrap: false });
385
+ const slide = host.querySelectorAll('[data-sh3-slide]')[1];
386
+ const editable = document.createElement('div');
387
+ editable.setAttribute('contenteditable', 'true');
388
+ slide.appendChild(editable);
389
+ editable.dispatchEvent(fakePointer('pointerdown', 5, 100));
390
+ document.dispatchEvent(fakePointer('pointermove', 80, 100));
391
+ document.dispatchEvent(fakePointer('pointermove', 200, 100));
392
+ document.dispatchEvent(fakePointer('pointerup', 200, 100));
393
+ flushSync();
394
+ expect(node.activeTab).toBe(0);
395
+ });
396
+ it('gutter pointer-down: transfers pointer capture to the carousel container on threshold-cross', () => {
397
+ // Concrete proof that the "invincible gutter" mechanism actually fires:
398
+ // once horizontal dominance is established on a gutter-initiated drag,
399
+ // setPointerCapture must be called on the carousel container so the
400
+ // descendant the touch landed on no longer receives the pointer stream.
401
+ const node = makeNode(['A', 'B', 'C'], 1);
402
+ mountCarousel({ node, wrap: false });
403
+ const carousel = host.querySelector('.sh3-carousel');
404
+ const track = host.querySelector('[data-sh3-carousel-track]');
405
+ let captured = null;
406
+ carousel.setPointerCapture = (id) => { captured = id; };
407
+ carousel.hasPointerCapture = (id) => captured === id;
408
+ carousel.releasePointerCapture = (id) => { if (captured === id)
409
+ captured = null; };
410
+ track.dispatchEvent(fakePointer('pointerdown', 10, 100, 7)); // left gutter
411
+ document.dispatchEvent(fakePointer('pointermove', 80, 100, 7)); // crosses threshold
412
+ expect(captured).toBe(7);
413
+ });
414
+ it('mid-track pointer-down: does NOT transfer pointer capture (contract preserved)', () => {
415
+ // The "invincible" treatment is scoped to the gutter on purpose. Inside
416
+ // the slide, the existing rule still holds: descendants can claim and
417
+ // the carousel aborts.
418
+ const node = makeNode(['A', 'B', 'C'], 1);
419
+ mountCarousel({ node, wrap: false });
420
+ const carousel = host.querySelector('.sh3-carousel');
421
+ const track = host.querySelector('[data-sh3-carousel-track]');
422
+ let captured = null;
423
+ carousel.setPointerCapture = (id) => { captured = id; };
424
+ carousel.hasPointerCapture = (id) => captured === id;
425
+ carousel.releasePointerCapture = (id) => { if (captured === id)
426
+ captured = null; };
427
+ track.dispatchEvent(fakePointer('pointerdown', 150, 100, 7)); // center, not gutter
428
+ document.dispatchEvent(fakePointer('pointermove', 80, 100, 7));
429
+ expect(captured).toBeNull();
430
+ });
431
+ it('gutter swipe: a pointercancel within the transfer window is swallowed, drag continues', () => {
432
+ // Synthetic Android-style "transfer cancel": setPointerCapture on the
433
+ // container fires a pointercancel on the original target. Without the
434
+ // ignoreCancelUntil window, that cancel would abort the very drag the
435
+ // gutter just guaranteed.
436
+ const node = makeNode(['A', 'B', 'C'], 1);
437
+ mountCarousel({ node, wrap: false });
438
+ const carousel = host.querySelector('.sh3-carousel');
439
+ const track = host.querySelector('[data-sh3-carousel-track]');
440
+ carousel.setPointerCapture = () => { };
441
+ carousel.hasPointerCapture = () => false;
442
+ carousel.releasePointerCapture = () => { };
443
+ track.dispatchEvent(fakePointer('pointerdown', 10, 100, 9)); // left gutter
444
+ document.dispatchEvent(fakePointer('pointermove', 80, 100, 9)); // crosses threshold → capture armed
445
+ // Transfer cancel — should be swallowed:
446
+ document.dispatchEvent(fakePointer('pointercancel', 80, 100, 9));
447
+ // Drag continues rightward; with starting activeTab=1, rightward swipe
448
+ // (positive dx) goes to previous tab (activeTab=0).
449
+ document.dispatchEvent(fakePointer('pointermove', 200, 100, 9));
450
+ document.dispatchEvent(fakePointer('pointerup', 200, 100, 9));
451
+ flushSync();
452
+ expect(node.activeTab).toBe(0);
453
+ });
454
+ it('gutter swipe: only the FIRST cancel within the window is swallowed; a second cancel aborts', () => {
455
+ // Defensive: the swallow is single-shot. After one cancel the window
456
+ // closes so a subsequent real cancel still aborts the drag.
457
+ const node = makeNode(['A', 'B', 'C'], 1);
458
+ mountCarousel({ node, wrap: false });
459
+ const carousel = host.querySelector('.sh3-carousel');
460
+ const track = host.querySelector('[data-sh3-carousel-track]');
461
+ carousel.setPointerCapture = () => { };
462
+ carousel.hasPointerCapture = () => false;
463
+ carousel.releasePointerCapture = () => { };
464
+ track.dispatchEvent(fakePointer('pointerdown', 10, 100, 11));
465
+ document.dispatchEvent(fakePointer('pointermove', 80, 100, 11)); // claim, arm window
466
+ document.dispatchEvent(fakePointer('pointercancel', 80, 100, 11)); // swallow
467
+ document.dispatchEvent(fakePointer('pointercancel', 80, 100, 11)); // abort
468
+ // Subsequent move/up should now be ignored:
469
+ document.dispatchEvent(fakePointer('pointermove', 200, 100, 11));
470
+ document.dispatchEvent(fakePointer('pointerup', 200, 100, 11));
471
+ flushSync();
472
+ expect(node.activeTab).toBe(1);
473
+ });
474
+ it('center pointer-down on the same editable target still bails (gutter is not the whole carousel)', () => {
475
+ // Sanity check: the override is scoped to the edge zones only —
476
+ // mid-track pointer-downs on inputs continue to focus the input.
477
+ const node = makeNode(['A', 'B', 'C'], 0);
478
+ mountCarousel({ node, wrap: false });
479
+ const slide = host.querySelector('[data-sh3-slide]');
480
+ const input = document.createElement('input');
481
+ slide.appendChild(input);
482
+ // x=150 — mid-track, not in either gutter.
483
+ input.dispatchEvent(fakePointer('pointerdown', 150, 100));
484
+ document.dispatchEvent(fakePointer('pointermove', 30, 100));
485
+ document.dispatchEvent(fakePointer('pointerup', 30, 100));
486
+ flushSync();
487
+ expect(node.activeTab).toBe(0);
488
+ });
489
+ });
270
490
  describe('CarouselTabs — PointerClaim integration', () => {
271
491
  it('does not start gesture when pointer is already claimed by an app', () => {
272
492
  const node = makeNode(['A', 'B'], 0);
@@ -11,7 +11,7 @@
11
11
  * the docked content so the surfaces stack correctly.
12
12
  *
13
13
  * View-default role lookup is intentionally omitted in v1 — derive()
14
- * reads slot.role / tab.role directly. Apps that want non-body slots
14
+ * reads slot.role / tabs.role directly. Apps that want non-body slots
15
15
  * tag them at authoring time. View-default fall-through ships when the
16
16
  * registry exposes a pre-mount lookup (deferred from this PR).
17
17
  */
@@ -81,9 +81,10 @@ describe('CompactRenderer — carousels', () => {
81
81
  initialLayout: {
82
82
  type: 'tabs',
83
83
  activeTab: 0,
84
+ role: 'body',
84
85
  tabs: [
85
- { slotId: 's0', viewId: null, label: 'A', role: 'body' },
86
- { slotId: 's1', viewId: null, label: 'B', role: 'body' },
86
+ { slotId: 's0', viewId: null, label: 'A' },
87
+ { slotId: 's1', viewId: null, label: 'B' },
87
88
  ],
88
89
  },
89
90
  };
@@ -107,7 +108,8 @@ describe('CompactRenderer — carousels', () => {
107
108
  {
108
109
  type: 'tabs',
109
110
  activeTab: 0,
110
- tabs: [{ slotId: 'l0', viewId: null, label: 'L', role: 'body' }],
111
+ role: 'body',
112
+ tabs: [{ slotId: 'l0', viewId: null, label: 'L' }],
111
113
  },
112
114
  { type: 'slot', slotId: 'r', viewId: null, role: 'body' },
113
115
  ],
@@ -11,7 +11,7 @@
11
11
  * locks that anchor — inner splits don't retag (a vertical split
12
12
  * inside a left-anchored subtree keeps both children on the left).
13
13
  *
14
- * Note: this transform reads slot.role / tab.role only. View-level
14
+ * Note: this transform reads slot.role / tabs.role only. View-level
15
15
  * defaultRole resolution happens at the call site via resolveRole(),
16
16
  * which materializes a tree with effective roles before passing to
17
17
  * derive(). See layout/compact/CompactRenderer.svelte.
@@ -36,7 +36,7 @@ function collectSlots(node) {
36
36
  viewId: t.viewId,
37
37
  label: t.label,
38
38
  icon: t.icon,
39
- role: effectiveRole(t.role),
39
+ role: effectiveRole(node.role),
40
40
  }));
41
41
  }
42
42
  return node.children.flatMap(collectSlots);
@@ -49,16 +49,7 @@ function stripNonBody(node) {
49
49
  return effectiveRole(node.role) === 'body' ? node : null;
50
50
  }
51
51
  if (node.type === 'tabs') {
52
- const bodyTabs = node.tabs.filter((t) => effectiveRole(t.role) === 'body');
53
- if (bodyTabs.length === 0)
54
- return null;
55
- if (bodyTabs.length === node.tabs.length)
56
- return node;
57
- return {
58
- type: 'tabs',
59
- activeTab: Math.min(node.activeTab, bodyTabs.length - 1),
60
- tabs: bodyTabs,
61
- };
52
+ return effectiveRole(node.role) === 'body' ? node : null;
62
53
  }
63
54
  // split
64
55
  const survivors = [];
@@ -95,11 +86,11 @@ function partitionDrawers(node) {
95
86
  return;
96
87
  }
97
88
  if (n.type === 'tabs') {
89
+ const role = effectiveRole(n.role);
90
+ if (role === 'body')
91
+ return;
92
+ const anchor = hint !== null && hint !== void 0 ? hint : defaultAnchor(role);
98
93
  for (const t of n.tabs) {
99
- const role = effectiveRole(t.role);
100
- if (role === 'body')
101
- continue;
102
- const anchor = hint !== null && hint !== void 0 ? hint : defaultAnchor(role);
103
94
  buckets[anchor].push({
104
95
  slotId: t.slotId, viewId: t.viewId, label: t.label, icon: t.icon, role,
105
96
  });