sh3-core 0.19.3 → 0.19.6

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 (43) hide show
  1. package/dist/api.d.ts +5 -0
  2. package/dist/api.js +3 -0
  3. package/dist/chrome/CompactChrome.svelte +34 -1
  4. package/dist/chrome/CompactChrome.svelte.test.js +4 -2
  5. package/dist/chrome/FloatsSheet.svelte +236 -0
  6. package/dist/chrome/FloatsSheet.svelte.d.ts +7 -0
  7. package/dist/chrome/FloatsSheet.svelte.test.d.ts +1 -0
  8. package/dist/chrome/FloatsSheet.svelte.test.js +155 -0
  9. package/dist/documents/picker-api.d.ts +31 -0
  10. package/dist/documents/picker-api.js +1 -0
  11. package/dist/documents/picker-api.test.d.ts +1 -0
  12. package/dist/documents/picker-api.test.js +132 -0
  13. package/dist/documents/picker-primitive.d.ts +7 -0
  14. package/dist/documents/picker-primitive.js +58 -0
  15. package/dist/layout/compact/CompactRenderer.svelte +8 -2
  16. package/dist/layout/compact/rootStore.svelte.d.ts +20 -0
  17. package/dist/layout/compact/rootStore.svelte.js +59 -0
  18. package/dist/layout/compact/rootStore.svelte.test.d.ts +1 -0
  19. package/dist/layout/compact/rootStore.svelte.test.js +54 -0
  20. package/dist/layout/floats.d.ts +27 -0
  21. package/dist/layout/floats.js +20 -0
  22. package/dist/layout/floats.test.js +34 -1
  23. package/dist/layout/inspection.js +25 -2
  24. package/dist/layout/inspection.svelte.test.js +49 -0
  25. package/dist/overlays/FloatLayer.svelte +12 -1
  26. package/dist/overlays/float.d.ts +7 -0
  27. package/dist/overlays/float.js +76 -6
  28. package/dist/overlays/float.test.js +170 -0
  29. package/dist/primitives/widgets/DocumentFilePicker.d.ts +25 -0
  30. package/dist/primitives/widgets/DocumentFilePicker.js +74 -0
  31. package/dist/primitives/widgets/DocumentFilePicker.svelte +144 -0
  32. package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +18 -0
  33. package/dist/primitives/widgets/DocumentOpener.svelte +36 -0
  34. package/dist/primitives/widgets/DocumentOpener.svelte.d.ts +17 -0
  35. package/dist/primitives/widgets/DocumentSaver.svelte +36 -0
  36. package/dist/primitives/widgets/DocumentSaver.svelte.d.ts +17 -0
  37. package/dist/primitives/widgets/_DocumentBrowser.svelte +339 -0
  38. package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +12 -0
  39. package/dist/shards/activate.svelte.js +23 -14
  40. package/dist/shards/types.d.ts +8 -0
  41. package/dist/version.d.ts +1 -1
  42. package/dist/version.js +1 -1
  43. package/package.json +1 -1
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { sh3 } from '../sh3Runtime.svelte';
3
+ import { createDocumentPicker } from './picker-primitive';
4
+ vi.mock('../sh3Runtime.svelte', () => ({
5
+ sh3: {
6
+ popup: {
7
+ show: vi.fn(),
8
+ },
9
+ },
10
+ }));
11
+ const mockShow = sh3.popup.show;
12
+ function mockPopup() {
13
+ let capturedCommit = null;
14
+ let capturedCancel = null;
15
+ const handle = {
16
+ close: vi.fn(),
17
+ };
18
+ mockShow.mockImplementation((_Content, _opts, props) => {
19
+ capturedCommit = props.onCommit;
20
+ capturedCancel = props.onCancel;
21
+ return handle;
22
+ });
23
+ return {
24
+ commit: (v) => {
25
+ capturedCommit === null || capturedCommit === void 0 ? void 0 : capturedCommit(v);
26
+ },
27
+ cancel: () => {
28
+ capturedCancel === null || capturedCancel === void 0 ? void 0 : capturedCancel();
29
+ },
30
+ dismiss: () => {
31
+ handle.close();
32
+ },
33
+ handle,
34
+ };
35
+ }
36
+ beforeEach(() => {
37
+ vi.clearAllMocks();
38
+ });
39
+ describe('createDocumentPicker', () => {
40
+ const sampleDoc = { shardId: 'my-shard', path: 'readme.md' };
41
+ describe('open()', () => {
42
+ it('resolves with OpenerValue when user commits', async () => {
43
+ const listFn = async () => [{ shardId: 'my-shard', path: 'readme.md', size: 100, lastModified: 0 }];
44
+ const picker = createDocumentPicker(listFn);
45
+ const popup = mockPopup();
46
+ const promise = picker.open();
47
+ await vi.waitFor(() => expect(mockShow).toHaveBeenCalledOnce());
48
+ popup.commit(sampleDoc);
49
+ const result = await promise;
50
+ expect(result).toEqual({ shardId: 'my-shard', path: 'readme.md' });
51
+ });
52
+ it('resolves with null when user cancels', async () => {
53
+ const listFn = async () => [];
54
+ const picker = createDocumentPicker(listFn);
55
+ const popup = mockPopup();
56
+ const promise = picker.open();
57
+ await vi.waitFor(() => expect(mockShow).toHaveBeenCalledOnce());
58
+ popup.cancel();
59
+ const result = await promise;
60
+ expect(result).toBeNull();
61
+ });
62
+ it('resolves with null when popup is dismissed externally', async () => {
63
+ const listFn = async () => [];
64
+ const picker = createDocumentPicker(listFn);
65
+ const popup = mockPopup();
66
+ const promise = picker.open();
67
+ await vi.waitFor(() => expect(mockShow).toHaveBeenCalledOnce());
68
+ popup.dismiss();
69
+ const result = await promise;
70
+ expect(result).toBeNull();
71
+ });
72
+ it('rejects when listFn fails', async () => {
73
+ const listFn = async () => { throw new Error('network error'); };
74
+ const picker = createDocumentPicker(listFn);
75
+ const promise = picker.open();
76
+ await expect(promise).rejects.toThrow('network error');
77
+ expect(mockShow).not.toHaveBeenCalled();
78
+ });
79
+ it('uses anchor element position when provided', async () => {
80
+ const listFn = async () => [];
81
+ const picker = createDocumentPicker(listFn);
82
+ mockPopup();
83
+ const el = document.createElement('div');
84
+ picker.open({ anchor: el });
85
+ await vi.waitFor(() => expect(mockShow).toHaveBeenCalledOnce());
86
+ const call = mockShow.mock.calls[0];
87
+ expect(call[1].anchor).toEqual({ x: 0, y: 0 });
88
+ });
89
+ });
90
+ describe('save()', () => {
91
+ it('resolves with SaverValue string when user commits a filename', async () => {
92
+ const listFn = async () => [];
93
+ const picker = createDocumentPicker(listFn);
94
+ const popup = mockPopup();
95
+ const promise = picker.save();
96
+ await vi.waitFor(() => expect(mockShow).toHaveBeenCalledOnce());
97
+ popup.commit('my-shard/report.txt');
98
+ const result = await promise;
99
+ expect(result).toBe('my-shard/report.txt');
100
+ });
101
+ it('resolves with null when user cancels', async () => {
102
+ const listFn = async () => [];
103
+ const picker = createDocumentPicker(listFn);
104
+ const popup = mockPopup();
105
+ const promise = picker.save();
106
+ await vi.waitFor(() => expect(mockShow).toHaveBeenCalledOnce());
107
+ popup.cancel();
108
+ const result = await promise;
109
+ expect(result).toBeNull();
110
+ });
111
+ it('passes suggestedName as prop', async () => {
112
+ const listFn = async () => [];
113
+ const picker = createDocumentPicker(listFn);
114
+ mockPopup();
115
+ picker.save({ suggestedName: 'draft.txt' });
116
+ await vi.waitFor(() => expect(mockShow).toHaveBeenCalledOnce());
117
+ const call = mockShow.mock.calls[0];
118
+ expect(call[2].suggestedName).toBe('draft.txt');
119
+ });
120
+ });
121
+ it('defaults anchor to viewport center when not provided', async () => {
122
+ const listFn = async () => [];
123
+ const picker = createDocumentPicker(listFn);
124
+ mockPopup();
125
+ const w = window.innerWidth;
126
+ const h = window.innerHeight;
127
+ picker.open();
128
+ await vi.waitFor(() => expect(mockShow).toHaveBeenCalledOnce());
129
+ const call = mockShow.mock.calls[0];
130
+ expect(call[1].anchor).toEqual({ x: w / 2, y: h / 2 });
131
+ });
132
+ });
@@ -0,0 +1,7 @@
1
+ import type { DocumentPickerApi, DocListFn } from './picker-api';
2
+ /**
3
+ * Create a document picker API bound to a document listing function.
4
+ * The listFn is derived from the shard's document zone + browse permission
5
+ * and baked in at construction time so callers don't pass their own scope.
6
+ */
7
+ export declare function createDocumentPicker(listFn: DocListFn): DocumentPickerApi;
@@ -0,0 +1,58 @@
1
+ import { sh3 } from '../sh3Runtime.svelte';
2
+ import DocumentBrowser from '../primitives/widgets/_DocumentBrowser.svelte';
3
+ /**
4
+ * Create a document picker API bound to a document listing function.
5
+ * The listFn is derived from the shard's document zone + browse permission
6
+ * and baked in at construction time so callers don't pass their own scope.
7
+ */
8
+ export function createDocumentPicker(listFn) {
9
+ function anchorOrDefault(anchor) {
10
+ if (anchor) {
11
+ const rect = anchor.getBoundingClientRect();
12
+ return { x: rect.left + rect.width / 2, y: rect.top };
13
+ }
14
+ return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
15
+ }
16
+ async function open(opts) {
17
+ const docs = await listFn();
18
+ return new Promise((resolve) => {
19
+ const handle = sh3.popup.show(DocumentBrowser, { anchor: anchorOrDefault(opts === null || opts === void 0 ? void 0 : opts.anchor) }, {
20
+ mode: 'open',
21
+ docs,
22
+ onCommit: (value) => {
23
+ resolve(value);
24
+ },
25
+ onCancel: () => {
26
+ resolve(null);
27
+ },
28
+ });
29
+ const origClose = handle.close;
30
+ handle.close = () => {
31
+ origClose();
32
+ resolve(null);
33
+ };
34
+ });
35
+ }
36
+ async function save(opts) {
37
+ const docs = await listFn();
38
+ return new Promise((resolve) => {
39
+ const handle = sh3.popup.show(DocumentBrowser, { anchor: anchorOrDefault(opts === null || opts === void 0 ? void 0 : opts.anchor) }, {
40
+ mode: 'save',
41
+ docs,
42
+ suggestedName: opts === null || opts === void 0 ? void 0 : opts.suggestedName,
43
+ onCommit: (value) => {
44
+ resolve(value);
45
+ },
46
+ onCancel: () => {
47
+ resolve(null);
48
+ },
49
+ });
50
+ const origClose = handle.close;
51
+ handle.close = () => {
52
+ origClose();
53
+ resolve(null);
54
+ };
55
+ });
56
+ }
57
+ return { open, save };
58
+ }
@@ -15,14 +15,20 @@
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
- const rendering = $derived(derive(layoutStore.root));
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>
@@ -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
+ });
@@ -23,6 +23,33 @@ export declare function cascadePosition(existing: FloatEntry[], bounds: {
23
23
  };
24
24
  /** Stable, process-unique float id. Not cryptographic — just unique within a session. */
25
25
  export declare function generateFloatId(): string;
26
+ /**
27
+ * Pull a float's rect into the supplied viewport bounds. Used at bind
28
+ * time so a float persisted from a larger viewport doesn't render past
29
+ * the overlay root — Firefox in particular grows the parent's painted
30
+ * area to fit an off-screen abspos child, which visibly bleeds the
31
+ * docked grid (footer ends up below the viewport).
32
+ *
33
+ * Size is shrunk to fit but never below `minSize`; if `minSize` itself
34
+ * exceeds bounds, position is pinned to (0,0) and size stays at min.
35
+ * Position is then clamped so the frame fits within bounds.
36
+ */
37
+ export declare function clampFloatToViewport(rect: {
38
+ position: {
39
+ x: number;
40
+ y: number;
41
+ };
42
+ size: Size;
43
+ }, minSize: Size, bounds: {
44
+ w: number;
45
+ h: number;
46
+ }): {
47
+ position: {
48
+ x: number;
49
+ y: number;
50
+ };
51
+ size: Size;
52
+ };
26
53
  /**
27
54
  * True if a LayoutNode subtree contains no leaf slot with a bound viewId.
28
55
  * Used by the drag-commit auto-close invariant: when the last bound leaf
@@ -55,6 +55,26 @@ export function generateFloatId() {
55
55
  floatIdCounter += 1;
56
56
  return `float-${Date.now().toString(36)}-${floatIdCounter.toString(36)}`;
57
57
  }
58
+ /**
59
+ * Pull a float's rect into the supplied viewport bounds. Used at bind
60
+ * time so a float persisted from a larger viewport doesn't render past
61
+ * the overlay root — Firefox in particular grows the parent's painted
62
+ * area to fit an off-screen abspos child, which visibly bleeds the
63
+ * docked grid (footer ends up below the viewport).
64
+ *
65
+ * Size is shrunk to fit but never below `minSize`; if `minSize` itself
66
+ * exceeds bounds, position is pinned to (0,0) and size stays at min.
67
+ * Position is then clamped so the frame fits within bounds.
68
+ */
69
+ export function clampFloatToViewport(rect, minSize, bounds) {
70
+ const w = Math.max(minSize.w, Math.min(rect.size.w, bounds.w));
71
+ const h = Math.max(minSize.h, Math.min(rect.size.h, bounds.h));
72
+ const maxX = Math.max(0, bounds.w - w);
73
+ const maxY = Math.max(0, bounds.h - h);
74
+ const x = Math.max(0, Math.min(rect.position.x, maxX));
75
+ const y = Math.max(0, Math.min(rect.position.y, maxY));
76
+ return { position: { x, y }, size: { w, h } };
77
+ }
58
78
  /**
59
79
  * True if a LayoutNode subtree contains no leaf slot with a bound viewId.
60
80
  * Used by the drag-commit auto-close invariant: when the last bound leaf
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { computeMinSize, cascadePosition, isEmptyContent } from './floats';
2
+ import { computeMinSize, cascadePosition, isEmptyContent, clampFloatToViewport } from './floats';
3
3
  const slot = (slotId, viewId = 'v') => ({
4
4
  type: 'slot',
5
5
  slotId,
@@ -72,6 +72,39 @@ describe('cascadePosition', () => {
72
72
  expect(cascadePosition(existing, bounds)).toEqual({ x: 48, y: 48 });
73
73
  });
74
74
  });
75
+ describe('clampFloatToViewport', () => {
76
+ const min = { w: 120, h: 80 };
77
+ const bounds = { w: 1024, h: 768 };
78
+ it('returns the rect unchanged when fully inside bounds', () => {
79
+ const out = clampFloatToViewport({ position: { x: 100, y: 200 }, size: { w: 600, h: 400 } }, min, bounds);
80
+ expect(out).toEqual({ position: { x: 100, y: 200 }, size: { w: 600, h: 400 } });
81
+ });
82
+ it('pulls a float that extends past the right edge back inside', () => {
83
+ const out = clampFloatToViewport({ position: { x: 900, y: 50 }, size: { w: 600, h: 400 } }, min, bounds);
84
+ expect(out.position.x).toBe(bounds.w - 600);
85
+ expect(out.position.y).toBe(50);
86
+ expect(out.size).toEqual({ w: 600, h: 400 });
87
+ });
88
+ it('pulls a float that extends past the bottom edge back inside', () => {
89
+ const out = clampFloatToViewport({ position: { x: 50, y: 600 }, size: { w: 600, h: 400 } }, min, bounds);
90
+ expect(out.position.y).toBe(bounds.h - 400);
91
+ });
92
+ it('clamps negative position back to (0,0)', () => {
93
+ const out = clampFloatToViewport({ position: { x: -200, y: -50 }, size: { w: 600, h: 400 } }, min, bounds);
94
+ expect(out.position).toEqual({ x: 0, y: 0 });
95
+ });
96
+ it('shrinks size larger than bounds down to bounds (above min)', () => {
97
+ const out = clampFloatToViewport({ position: { x: 0, y: 0 }, size: { w: 4000, h: 3000 } }, min, bounds);
98
+ expect(out.size).toEqual({ w: bounds.w, h: bounds.h });
99
+ expect(out.position).toEqual({ x: 0, y: 0 });
100
+ });
101
+ it('never shrinks size below the supplied min, even when bounds < min', () => {
102
+ const tiny = { w: 80, h: 60 };
103
+ const out = clampFloatToViewport({ position: { x: 999, y: 999 }, size: { w: 600, h: 400 } }, min, tiny);
104
+ expect(out.size).toEqual(min);
105
+ expect(out.position).toEqual({ x: 0, y: 0 });
106
+ });
107
+ });
75
108
  describe('isEmptyContent', () => {
76
109
  it('true for a slot with null viewId', () => {
77
110
  expect(isEmptyContent({ type: 'slot', slotId: 's', viewId: null })).toBe(true);
@@ -16,6 +16,24 @@ import { activeLayout, getActiveRoot } from './store.svelte';
16
16
  import { nodeAtPath, findTabBySlotId, removeTabBySlotId, cleanupTree, splitNodeAtPath, locateSlotIn, } from './ops';
17
17
  import { getSlotHandle } from './slotHostPool.svelte';
18
18
  import { floatManager } from '../overlays/float';
19
+ import { viewportStore } from '../viewport/store.svelte';
20
+ import { compactRootStore } from './compact/rootStore.svelte';
21
+ /**
22
+ * In compact mode the user sees one root at a time. When focus lands on
23
+ * a slot in a float, swap the compact body root to that float before
24
+ * returning. When focus lands in the docked tree, snap back to docked.
25
+ * Desktop is unaffected.
26
+ */
27
+ function maybeSwapForCompact(located) {
28
+ if (viewportStore.current.class !== 'compact')
29
+ return;
30
+ if (located.kind === 'float') {
31
+ compactRootStore.setRoot({ kind: 'float', floatId: located.floatId });
32
+ }
33
+ else {
34
+ compactRootStore.reset();
35
+ }
36
+ }
19
37
  /**
20
38
  * Read-only snapshot of the currently-rendered layout tree. The return
21
39
  * value is the live object — callers MUST NOT mutate it directly;
@@ -48,8 +66,10 @@ export function spliceIntoActiveLayout(entry) {
48
66
  */
49
67
  export function focusTab(slotId) {
50
68
  const tree = activeLayout();
51
- if (focusTabWhere(tree.docked, (entry) => entry.slotId === slotId))
69
+ if (focusTabWhere(tree.docked, (entry) => entry.slotId === slotId)) {
70
+ maybeSwapForCompact({ kind: 'docked' });
52
71
  return true;
72
+ }
53
73
  return focusTabInFloats(tree, (entry) => entry.slotId === slotId);
54
74
  }
55
75
  /**
@@ -58,8 +78,10 @@ export function focusTab(slotId) {
58
78
  */
59
79
  export function focusView(viewId) {
60
80
  const tree = activeLayout();
61
- if (focusTabWhere(tree.docked, (entry) => entry.viewId === viewId))
81
+ if (focusTabWhere(tree.docked, (entry) => entry.viewId === viewId)) {
82
+ maybeSwapForCompact({ kind: 'docked' });
62
83
  return true;
84
+ }
63
85
  return focusTabInFloats(tree, (entry) => entry.viewId === viewId);
64
86
  }
65
87
  /** Walk the tree looking for a tab entry that satisfies `pred`, activate it. */
@@ -85,6 +107,7 @@ function focusTabInFloats(tree, pred) {
85
107
  for (const floatEntry of tree.floats) {
86
108
  if (focusTabWhere(floatEntry.content, pred)) {
87
109
  floatManager.focus(floatEntry.id);
110
+ maybeSwapForCompact({ kind: 'float', floatId: floatEntry.id });
88
111
  return true;
89
112
  }
90
113
  }
@@ -112,3 +112,52 @@ describe('dockIntoActiveLayout — body-role preference', () => {
112
112
  expect(right.tabs.map((t) => t.viewId)).toEqual(['view:r']);
113
113
  });
114
114
  });
115
+ // ---------------------------------------------------------------------------
116
+ // Compact body-root swap on focusView / focusTab
117
+ // ---------------------------------------------------------------------------
118
+ import { compactRootStore, __resetCompactRootStoreForTest, } from './compact/rootStore.svelte';
119
+ import { viewportStore } from '../viewport/store.svelte';
120
+ import { focusView } from './inspection';
121
+ import { floatManager, __resetFloatManagerForTest, bindFloatStore, } from '../overlays/float';
122
+ describe('focusView — compact body-root swap', () => {
123
+ beforeEach(() => {
124
+ __resetLayoutStoreForTest();
125
+ __resetCompactRootStoreForTest();
126
+ __resetFloatManagerForTest();
127
+ viewportStore.override(null);
128
+ bindFloatStore(layoutStore.floats, () => ({ w: 1024, h: 768 }));
129
+ });
130
+ it('compact: focusView in a float swaps body root before activating', () => {
131
+ viewportStore.override('compact');
132
+ const id = floatManager.open('view:in-float', { title: 'In Float' });
133
+ // open() in compact already auto-switches; reset so we can see the swap.
134
+ compactRootStore.reset();
135
+ expect(compactRootStore.current).toEqual({ kind: 'docked' });
136
+ const ok = focusView('view:in-float');
137
+ expect(ok).toBe(true);
138
+ expect(compactRootStore.current).toEqual({ kind: 'float', floatId: id });
139
+ viewportStore.override(null);
140
+ });
141
+ it('compact: focusView for a docked tab resets body root', () => {
142
+ viewportStore.override('compact');
143
+ layoutStore.tree.docked = {
144
+ type: 'tabs',
145
+ tabs: [{ slotId: 's-d', viewId: 'view:docked', label: 'Docked' }],
146
+ activeTab: 0,
147
+ };
148
+ floatManager.open('view:other');
149
+ expect(compactRootStore.current.kind).toBe('float');
150
+ const ok = focusView('view:docked');
151
+ expect(ok).toBe(true);
152
+ expect(compactRootStore.current).toEqual({ kind: 'docked' });
153
+ viewportStore.override(null);
154
+ });
155
+ it('desktop: focusView does not touch the compact body root', () => {
156
+ viewportStore.override('desktop');
157
+ floatManager.open('view:any');
158
+ const before = compactRootStore.current;
159
+ focusView('view:any');
160
+ expect(compactRootStore.current).toEqual(before);
161
+ viewportStore.override(null);
162
+ });
163
+ });
@@ -7,14 +7,25 @@
7
7
  -->
8
8
  <script lang="ts">
9
9
  import { layoutStore } from '../layout/store.svelte';
10
+ import { viewportStore } from '../viewport/store.svelte';
10
11
  import FloatFrame from './FloatFrame.svelte';
11
12
 
12
13
  const floats = $derived(layoutStore.floats);
14
+ // In compact mode, non-dismissable floats are body-or-menu only — the
15
+ // compact-floats-menu design demotes them to the FloatsSheet so the
16
+ // user sees one root at a time. Dismissable pickers (anchored
17
+ // popovers) keep floating because they're transient.
18
+ const compact = $derived(viewportStore.current.class === 'compact');
19
+ function shouldRender(entry: { dismissable?: boolean }): boolean {
20
+ return !compact || entry.dismissable === true;
21
+ }
13
22
  </script>
14
23
 
15
24
  <div class="sh3-float-layer">
16
25
  {#each floats as entry, i (entry.id)}
17
- <FloatFrame bind:entry={floats[i]} />
26
+ {#if shouldRender(entry)}
27
+ <FloatFrame bind:entry={floats[i]} />
28
+ {/if}
18
29
  {/each}
19
30
  </div>
20
31
 
@@ -74,6 +74,13 @@ export interface FloatManager {
74
74
  * Bind the manager to the active LayoutTree's `floats` array. Called
75
75
  * from Sh3.svelte during boot. `getBounds` returns the current
76
76
  * tree-allocated area for cascade-position wraparound.
77
+ *
78
+ * Persisted floats observed for the first time are pulled into the
79
+ * supplied viewport — without this, a float whose position/size came
80
+ * from a larger window renders past the overlay root, which Firefox
81
+ * paints by growing the parent and visibly shifts the docked grid.
82
+ * Ids already in `clampedIds` (re-bound on viewport-class swap, etc.)
83
+ * are left alone.
77
84
  */
78
85
  export declare function bindFloatStore(floats: FloatEntry[], getBounds: () => {
79
86
  w: number;