sh3-core 0.19.1 → 0.19.5
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.
- package/dist/Sh3.svelte +3 -1
- package/dist/actions/menuBarModel.js +8 -0
- package/dist/actions/menuBarModel.test.js +61 -0
- package/dist/api.d.ts +4 -0
- package/dist/api.js +3 -0
- package/dist/app/admin/ApiKeysView.svelte +6 -5
- package/dist/app/store/PermissionConfirmModal.svelte +23 -0
- package/dist/app/store/PermissionConfirmModal.svelte.d.ts +1 -0
- package/dist/app/store/StoreView.svelte +6 -1
- package/dist/chrome/CompactChrome.svelte +34 -1
- package/dist/chrome/CompactChrome.svelte.test.js +11 -6
- package/dist/chrome/FloatsSheet.svelte +236 -0
- package/dist/chrome/FloatsSheet.svelte.d.ts +7 -0
- package/dist/chrome/FloatsSheet.svelte.test.d.ts +1 -0
- package/dist/chrome/FloatsSheet.svelte.test.js +155 -0
- package/dist/env/client.d.ts +5 -4
- package/dist/env/client.js +11 -17
- package/dist/env/serverUrl.d.ts +2 -0
- package/dist/env/serverUrl.js +8 -0
- package/dist/gestures/index.d.ts +17 -0
- package/dist/gestures/index.js +27 -0
- package/dist/keys/client.js +6 -7
- package/dist/keys/revocation-bus.svelte.js +11 -1
- package/dist/layout/compact/CarouselTabs.svelte +150 -14
- package/dist/layout/compact/CarouselTabs.svelte.test.js +222 -2
- package/dist/layout/compact/CompactRenderer.svelte +9 -3
- package/dist/layout/compact/CompactRenderer.svelte.test.js +5 -3
- package/dist/layout/compact/derive.js +7 -16
- package/dist/layout/compact/derive.test.js +30 -9
- package/dist/layout/compact/rootStore.svelte.d.ts +20 -0
- package/dist/layout/compact/rootStore.svelte.js +59 -0
- package/dist/layout/compact/rootStore.svelte.test.d.ts +1 -0
- package/dist/layout/compact/rootStore.svelte.test.js +54 -0
- package/dist/layout/drag.svelte.js +16 -3
- package/dist/layout/floats.d.ts +27 -0
- package/dist/layout/floats.js +20 -0
- package/dist/layout/floats.test.js +34 -1
- package/dist/layout/inspection.d.ts +20 -9
- package/dist/layout/inspection.js +91 -13
- package/dist/layout/inspection.svelte.test.d.ts +1 -0
- package/dist/layout/inspection.svelte.test.js +163 -0
- package/dist/layout/store.schemaVersion.test.js +2 -2
- package/dist/layout/types.d.ts +11 -8
- package/dist/layout/types.js +1 -1
- package/dist/layout/types.test.js +2 -2
- package/dist/overlays/FloatFrame.svelte +93 -22
- package/dist/overlays/FloatLayer.svelte +12 -1
- package/dist/overlays/float.d.ts +7 -0
- package/dist/overlays/float.js +76 -6
- package/dist/overlays/float.test.js +170 -0
- package/dist/primitives/ResizableSplitter.svelte +42 -8
- package/dist/primitives/widgets/DocumentFilePicker.d.ts +25 -0
- package/dist/primitives/widgets/DocumentFilePicker.js +74 -0
- package/dist/primitives/widgets/DocumentFilePicker.svelte +144 -0
- package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +18 -0
- package/dist/primitives/widgets/DocumentOpener.svelte +36 -0
- package/dist/primitives/widgets/DocumentOpener.svelte.d.ts +17 -0
- package/dist/primitives/widgets/DocumentSaver.svelte +36 -0
- package/dist/primitives/widgets/DocumentSaver.svelte.d.ts +17 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte +337 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +11 -0
- package/dist/registry/checkFetch.d.ts +6 -0
- package/dist/registry/checkFetch.js +23 -0
- package/dist/sh3/views/KeysAndPeers.svelte +4 -3
- package/dist/shards/activate-runtime.test.js +99 -1
- package/dist/shards/activate.svelte.js +12 -3
- package/dist/shards/registry.d.ts +8 -1
- package/dist/shards/registry.js +13 -2
- package/dist/shards/registry.test.js +25 -4
- package/dist/shards/types.d.ts +14 -1
- package/dist/shell-shard/ScrollbackView.svelte +145 -67
- package/dist/shell-shard/ScrollbackView.svelte.test.d.ts +1 -0
- package/dist/shell-shard/ScrollbackView.svelte.test.js +182 -0
- package/dist/shell-shard/dispatch-gating.test.js +38 -2
- package/dist/shell-shard/dispatch.js +9 -1
- package/dist/shell-shard/registry-resolve.test.js +50 -0
- package/dist/shell-shard/registry.d.ts +2 -1
- package/dist/shell-shard/registry.js +12 -2
- package/dist/shell-shard/verbs/help.js +5 -4
- package/dist/shell-shard/verbs/help.svelte.test.js +5 -2
- package/dist/verbs/types.d.ts +10 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -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,18 +11,24 @@
|
|
|
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 /
|
|
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
|
*/
|
|
18
|
-
import { layoutStore } from '../store.svelte';
|
|
19
18
|
import { drawerStore } from './drawerStore.svelte';
|
|
20
19
|
import { derive } from './derive';
|
|
20
|
+
import { resolveCompactBodyRoot } from './rootStore.svelte';
|
|
21
21
|
import LayoutRenderer from '../LayoutRenderer.svelte';
|
|
22
22
|
import DrawerSurface from '../../overlays/DrawerSurface.svelte';
|
|
23
23
|
import type { DrawerAnchor } from './types';
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
// Compact body shows whatever compactRootStore currently points at —
|
|
26
|
+
// the docked tree by default, or one float when the user navigates
|
|
27
|
+
// there via the FloatsSheet. Drawer derivation runs on the same root,
|
|
28
|
+
// so a float carrying role-tagged sidebar slots gets the same drawer
|
|
29
|
+
// chrome the docked tree gets.
|
|
30
|
+
const bodyRoot = $derived(resolveCompactBodyRoot());
|
|
31
|
+
const rendering = $derived(derive(bodyRoot));
|
|
26
32
|
|
|
27
33
|
const anchors: DrawerAnchor[] = ['left', 'right', 'top'];
|
|
28
34
|
</script>
|
|
@@ -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'
|
|
86
|
-
{ slotId: 's1', viewId: null, label: 'B'
|
|
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
|
-
|
|
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 /
|
|
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(
|
|
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
|
-
|
|
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
|
});
|
|
@@ -128,26 +128,26 @@ describe('derive', () => {
|
|
|
128
128
|
describe('tabs nodes', () => {
|
|
129
129
|
it('tabs of body slots stay in body root', () => {
|
|
130
130
|
const tree = {
|
|
131
|
-
type: 'tabs', activeTab: 0,
|
|
131
|
+
type: 'tabs', activeTab: 0, role: 'body',
|
|
132
132
|
tabs: [
|
|
133
|
-
{ slotId: 't1', viewId: 'v:t1', label: 'Tab 1'
|
|
134
|
-
{ slotId: 't2', viewId: 'v:t2', label: 'Tab 2'
|
|
133
|
+
{ slotId: 't1', viewId: 'v:t1', label: 'Tab 1' },
|
|
134
|
+
{ slotId: 't2', viewId: 'v:t2', label: 'Tab 2' },
|
|
135
135
|
],
|
|
136
136
|
};
|
|
137
137
|
const result = derive(tree);
|
|
138
138
|
expect(result.bodyRoot.type).toBe('tabs');
|
|
139
139
|
});
|
|
140
|
-
it('tabs
|
|
140
|
+
it('body tabs node alongside sidebar slot stays in body root', () => {
|
|
141
141
|
var _a;
|
|
142
142
|
const tree = {
|
|
143
143
|
type: 'split', direction: 'horizontal', sizes: [0.2, 0.8],
|
|
144
144
|
children: [
|
|
145
145
|
{ type: 'slot', slotId: 'sb', viewId: 'v:sb', role: 'sidebar' },
|
|
146
146
|
{
|
|
147
|
-
type: 'tabs', activeTab: 0,
|
|
147
|
+
type: 'tabs', activeTab: 0, role: 'body',
|
|
148
148
|
tabs: [
|
|
149
|
-
{ slotId: 't1', viewId: 'v:t1', label: 'Tab 1'
|
|
150
|
-
{ slotId: 't2', viewId: 'v:t2', label: 'Tab 2'
|
|
149
|
+
{ slotId: 't1', viewId: 'v:t1', label: 'Tab 1' },
|
|
150
|
+
{ slotId: 't2', viewId: 'v:t2', label: 'Tab 2' },
|
|
151
151
|
],
|
|
152
152
|
},
|
|
153
153
|
],
|
|
@@ -156,13 +156,33 @@ describe('derive', () => {
|
|
|
156
156
|
expect(result.bodyRoot.type).toBe('tabs');
|
|
157
157
|
expect((_a = result.drawers.left) === null || _a === void 0 ? void 0 : _a.slots.map((s) => s.slotId)).toEqual(['sb']);
|
|
158
158
|
});
|
|
159
|
+
it('sidebar-tagged tabs node lifts all tabs into a drawer', () => {
|
|
160
|
+
var _a;
|
|
161
|
+
const tree = {
|
|
162
|
+
type: 'split', direction: 'horizontal', sizes: [0.3, 0.7],
|
|
163
|
+
children: [
|
|
164
|
+
{
|
|
165
|
+
type: 'tabs', activeTab: 0, role: 'sidebar',
|
|
166
|
+
tabs: [
|
|
167
|
+
{ slotId: 'sb-1', viewId: 'v:sb-1', label: 'Files' },
|
|
168
|
+
{ slotId: 'sb-2', viewId: 'v:sb-2', label: 'Search' },
|
|
169
|
+
],
|
|
170
|
+
},
|
|
171
|
+
{ type: 'slot', slotId: 'body', viewId: 'v:body', role: 'body' },
|
|
172
|
+
],
|
|
173
|
+
};
|
|
174
|
+
const result = derive(tree);
|
|
175
|
+
expect((_a = result.drawers.left) === null || _a === void 0 ? void 0 : _a.slots.map((s) => s.slotId)).toEqual(['sb-1', 'sb-2']);
|
|
176
|
+
expect(result.bodyRoot.slotId).toBe('body');
|
|
177
|
+
});
|
|
159
178
|
});
|
|
160
179
|
describe('carousels', () => {
|
|
161
180
|
it('includes a carousels Map in the output', () => {
|
|
162
181
|
var _a;
|
|
163
182
|
const tree = {
|
|
164
183
|
type: 'tabs',
|
|
165
|
-
|
|
184
|
+
role: 'body',
|
|
185
|
+
tabs: [{ slotId: 't0', viewId: null, label: 'Only' }],
|
|
166
186
|
activeTab: 0,
|
|
167
187
|
};
|
|
168
188
|
const out = derive(tree);
|
|
@@ -184,7 +204,8 @@ describe('derive', () => {
|
|
|
184
204
|
{ type: 'slot', slotId: 'sb', viewId: null, role: 'sidebar' },
|
|
185
205
|
{
|
|
186
206
|
type: 'tabs',
|
|
187
|
-
|
|
207
|
+
role: 'body',
|
|
208
|
+
tabs: [{ slotId: 't0', viewId: null, label: 'Body Tab' }],
|
|
188
209
|
activeTab: 0,
|
|
189
210
|
},
|
|
190
211
|
],
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { LayoutNode } from '../types';
|
|
2
|
+
export type CompactRoot = {
|
|
3
|
+
kind: 'docked';
|
|
4
|
+
} | {
|
|
5
|
+
kind: 'float';
|
|
6
|
+
floatId: string;
|
|
7
|
+
};
|
|
8
|
+
export declare const compactRootStore: {
|
|
9
|
+
readonly current: CompactRoot;
|
|
10
|
+
setRoot(r: CompactRoot): void;
|
|
11
|
+
reset(): void;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the LayoutNode the compact body should render. Returns
|
|
15
|
+
* `layoutStore.root` when current is docked OR when the referenced float
|
|
16
|
+
* has been removed (self-heal: also resets `current` to docked).
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveCompactBodyRoot(): LayoutNode;
|
|
19
|
+
/** Test-only reset. Not exported from src/index.ts. */
|
|
20
|
+
export declare function __resetCompactRootStoreForTest(): void;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* compactRootStore — selects which LayoutNode the compact body shows.
|
|
3
|
+
*
|
|
4
|
+
* In compact mode the user sees exactly one root at a time: either the
|
|
5
|
+
* docked tree (the "active layout"), or the content of one float. This
|
|
6
|
+
* module is the source of truth for that selection. Desktop never reads it.
|
|
7
|
+
*
|
|
8
|
+
* Reset triggers (called from existing modules):
|
|
9
|
+
* - bindFloatStore (active tree changed)
|
|
10
|
+
* - closeFloat(id) when current.floatId === id
|
|
11
|
+
* - resolveCompactBodyRoot self-heal when the referenced float is gone
|
|
12
|
+
*
|
|
13
|
+
* Set triggers:
|
|
14
|
+
* - floatManager.open in compact + non-dismissable
|
|
15
|
+
* - focusView / focusTab in compact when the target lives in a float
|
|
16
|
+
* - FloatsSheet row tap
|
|
17
|
+
*/
|
|
18
|
+
import { layoutStore } from '../store.svelte';
|
|
19
|
+
let current = $state({ kind: 'docked' });
|
|
20
|
+
export const compactRootStore = {
|
|
21
|
+
get current() {
|
|
22
|
+
return current;
|
|
23
|
+
},
|
|
24
|
+
setRoot(r) {
|
|
25
|
+
if (r.kind === 'float') {
|
|
26
|
+
const exists = layoutStore.tree.floats.some((f) => f.id === r.floatId);
|
|
27
|
+
if (!exists) {
|
|
28
|
+
throw new Error(`compactRootStore.setRoot: float id "${r.floatId}" is not in the active tree`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
current = r;
|
|
32
|
+
},
|
|
33
|
+
reset() {
|
|
34
|
+
current = { kind: 'docked' };
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the LayoutNode the compact body should render. Returns
|
|
39
|
+
* `layoutStore.root` when current is docked OR when the referenced float
|
|
40
|
+
* has been removed (self-heal: also resets `current` to docked).
|
|
41
|
+
*/
|
|
42
|
+
export function resolveCompactBodyRoot() {
|
|
43
|
+
// Snapshot locally — narrowing on a module-level $state binding is lost
|
|
44
|
+
// across function calls because the narrowed-out branch could in theory
|
|
45
|
+
// reassign before the next read.
|
|
46
|
+
const cur = current;
|
|
47
|
+
if (cur.kind === 'docked')
|
|
48
|
+
return layoutStore.root;
|
|
49
|
+
const entry = layoutStore.tree.floats.find((f) => f.id === cur.floatId);
|
|
50
|
+
if (!entry) {
|
|
51
|
+
current = { kind: 'docked' };
|
|
52
|
+
return layoutStore.root;
|
|
53
|
+
}
|
|
54
|
+
return entry.content;
|
|
55
|
+
}
|
|
56
|
+
/** Test-only reset. Not exported from src/index.ts. */
|
|
57
|
+
export function __resetCompactRootStoreForTest() {
|
|
58
|
+
current = { kind: 'docked' };
|
|
59
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { compactRootStore, resolveCompactBodyRoot, __resetCompactRootStoreForTest, } from './rootStore.svelte';
|
|
3
|
+
import { __resetLayoutStoreForTest, layoutStore, } from '../store.svelte';
|
|
4
|
+
function makeFloat(id, viewId = 'v') {
|
|
5
|
+
return {
|
|
6
|
+
id,
|
|
7
|
+
content: { type: 'slot', slotId: `slot-${id}`, viewId },
|
|
8
|
+
position: { x: 0, y: 0 },
|
|
9
|
+
size: { w: 200, h: 200 },
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
describe('compactRootStore', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
__resetLayoutStoreForTest();
|
|
15
|
+
__resetCompactRootStoreForTest();
|
|
16
|
+
});
|
|
17
|
+
it('starts at { kind: "docked" }', () => {
|
|
18
|
+
expect(compactRootStore.current).toEqual({ kind: 'docked' });
|
|
19
|
+
});
|
|
20
|
+
it('setRoot accepts a float id present in the active tree', () => {
|
|
21
|
+
const f = makeFloat('f-1');
|
|
22
|
+
layoutStore.tree.floats.push(f);
|
|
23
|
+
compactRootStore.setRoot({ kind: 'float', floatId: 'f-1' });
|
|
24
|
+
expect(compactRootStore.current).toEqual({ kind: 'float', floatId: 'f-1' });
|
|
25
|
+
});
|
|
26
|
+
it('setRoot throws when the float id is not in the active tree', () => {
|
|
27
|
+
expect(() => compactRootStore.setRoot({ kind: 'float', floatId: 'missing' })).toThrow(/missing/);
|
|
28
|
+
});
|
|
29
|
+
it('reset returns to docked', () => {
|
|
30
|
+
layoutStore.tree.floats.push(makeFloat('f-2'));
|
|
31
|
+
compactRootStore.setRoot({ kind: 'float', floatId: 'f-2' });
|
|
32
|
+
compactRootStore.reset();
|
|
33
|
+
expect(compactRootStore.current).toEqual({ kind: 'docked' });
|
|
34
|
+
});
|
|
35
|
+
it('resolveCompactBodyRoot returns docked content when current is docked', () => {
|
|
36
|
+
expect(resolveCompactBodyRoot()).toEqual(layoutStore.tree.docked);
|
|
37
|
+
});
|
|
38
|
+
it('resolveCompactBodyRoot returns the float content when current points at it', () => {
|
|
39
|
+
const f = makeFloat('f-3');
|
|
40
|
+
layoutStore.tree.floats.push(f);
|
|
41
|
+
compactRootStore.setRoot({ kind: 'float', floatId: 'f-3' });
|
|
42
|
+
// Workspace-zone reactivity proxies the pushed object, so identity
|
|
43
|
+
// (`toBe`) does not hold; the structural content is what we care about.
|
|
44
|
+
expect(resolveCompactBodyRoot()).toEqual(f.content);
|
|
45
|
+
});
|
|
46
|
+
it('resolveCompactBodyRoot self-heals on stale id (returns docked + resets)', () => {
|
|
47
|
+
const f = makeFloat('f-4');
|
|
48
|
+
layoutStore.tree.floats.push(f);
|
|
49
|
+
compactRootStore.setRoot({ kind: 'float', floatId: 'f-4' });
|
|
50
|
+
layoutStore.tree.floats.length = 0;
|
|
51
|
+
expect(resolveCompactBodyRoot()).toEqual(layoutStore.tree.docked);
|
|
52
|
+
expect(compactRootStore.current).toEqual({ kind: 'docked' });
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -44,7 +44,7 @@ import { cleanupTree, insertTabIntoTabs, moveTabWithinTabs, removeTabBySlotId, s
|
|
|
44
44
|
import { layoutStore } from './store.svelte';
|
|
45
45
|
import { isEmptyContent } from './floats';
|
|
46
46
|
import { claim, revoke } from '../gestures/pointerClaim';
|
|
47
|
-
import { ancestorCount } from '../gestures';
|
|
47
|
+
import { ancestorCount, logGesture } from '../gestures';
|
|
48
48
|
export const dragState = $state({
|
|
49
49
|
phase: 'idle',
|
|
50
50
|
source: null,
|
|
@@ -116,6 +116,8 @@ function removeGlobalListeners() {
|
|
|
116
116
|
delete document.body.dataset.dragging;
|
|
117
117
|
}
|
|
118
118
|
function onPointerMove(e) {
|
|
119
|
+
if (e.pointerId !== activeDragPointerId)
|
|
120
|
+
return;
|
|
119
121
|
dragState.pointerX = e.clientX;
|
|
120
122
|
dragState.pointerY = e.clientY;
|
|
121
123
|
if (dragState.phase === 'pending') {
|
|
@@ -126,7 +128,9 @@ function onPointerMove(e) {
|
|
|
126
128
|
}
|
|
127
129
|
}
|
|
128
130
|
}
|
|
129
|
-
function onPointerUp(
|
|
131
|
+
function onPointerUp(e) {
|
|
132
|
+
if (e.pointerId !== activeDragPointerId)
|
|
133
|
+
return;
|
|
130
134
|
const wasDragging = dragState.phase === 'dragging';
|
|
131
135
|
if (wasDragging) {
|
|
132
136
|
commit();
|
|
@@ -137,7 +141,16 @@ function onPointerUp(_e) {
|
|
|
137
141
|
}
|
|
138
142
|
teardown();
|
|
139
143
|
}
|
|
140
|
-
function onPointerCancel(
|
|
144
|
+
function onPointerCancel(e) {
|
|
145
|
+
// Filter by pointer id. Without this, any pointercancel on the window
|
|
146
|
+
// (palm contact, ghost touch, stylus cancellation while a finger drag
|
|
147
|
+
// is active) would tear down a legitimate tab drag — same touch-only
|
|
148
|
+
// auto-release class as in carousel / floatframe / splitter.
|
|
149
|
+
if (e.pointerId !== activeDragPointerId) {
|
|
150
|
+
logGesture('tabdrag:cancel-other-id', e, { activeDragPointerId });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
logGesture('tabdrag:cancel-our-id', e, { activeDragPointerId });
|
|
141
154
|
teardown();
|
|
142
155
|
}
|
|
143
156
|
function rootNode(ref) {
|