sh3-core 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/dist/Sh3.svelte +107 -39
  2. package/dist/__screenshots__/handheld.browser.test.ts/handheld-viewport-flip-e2e-viewport-override-flips-chrome-and-body-branches-1.png +0 -0
  3. package/dist/actions/CommandPalette.svelte +1 -2
  4. package/dist/actions/listActionsFromEntries.test.js +29 -0
  5. package/dist/actions/listActive.js +2 -0
  6. package/dist/actions/listeners.js +16 -1
  7. package/dist/actions/programmatic-dispatch.svelte.test.js +9 -2
  8. package/dist/actions/types.d.ts +8 -0
  9. package/dist/api.d.ts +8 -1
  10. package/dist/app/store/storeShard.svelte.js +1 -21
  11. package/dist/app/store/version.d.ts +11 -0
  12. package/dist/app/store/version.js +39 -0
  13. package/dist/app/store/version.test.d.ts +1 -0
  14. package/dist/app/store/version.test.js +44 -0
  15. package/dist/apps/lifecycle.d.ts +6 -0
  16. package/dist/apps/lifecycle.js +5 -2
  17. package/dist/apps/lifecycle.test.js +30 -0
  18. package/dist/apps/types.d.ts +12 -0
  19. package/dist/assets/iconIds.generated.d.ts +1 -1
  20. package/dist/assets/iconIds.generated.js +5 -0
  21. package/dist/assets/icons.svg +31 -0
  22. package/dist/auth/auth.svelte.js +18 -8
  23. package/dist/auth/types.d.ts +6 -0
  24. package/dist/chrome/CompactChrome.svelte +130 -0
  25. package/dist/chrome/CompactChrome.svelte.d.ts +3 -0
  26. package/dist/chrome/CompactChrome.svelte.test.d.ts +1 -0
  27. package/dist/chrome/CompactChrome.svelte.test.js +174 -0
  28. package/dist/chrome/MenuSheet.svelte +224 -0
  29. package/dist/chrome/MenuSheet.svelte.d.ts +7 -0
  30. package/dist/chrome/MenuSheet.svelte.test.d.ts +1 -0
  31. package/dist/chrome/MenuSheet.svelte.test.js +46 -0
  32. package/dist/createShell.d.ts +9 -0
  33. package/dist/createShell.js +20 -7
  34. package/dist/createShell.remoteAuth.test.d.ts +1 -0
  35. package/dist/createShell.remoteAuth.test.js +71 -0
  36. package/dist/documents/http-backend.js +12 -11
  37. package/dist/env/client.js +11 -5
  38. package/dist/files/types.d.ts +106 -0
  39. package/dist/files/types.js +1 -0
  40. package/dist/gestures/gestureRegistry.d.ts +6 -0
  41. package/dist/gestures/gestureRegistry.js +190 -0
  42. package/dist/gestures/gestureRegistry.test.d.ts +1 -0
  43. package/dist/gestures/gestureRegistry.test.js +119 -0
  44. package/dist/gestures/index.d.ts +6 -0
  45. package/dist/gestures/index.js +12 -0
  46. package/dist/gestures/pointerClaim.d.ts +7 -0
  47. package/dist/gestures/pointerClaim.js +36 -0
  48. package/dist/gestures/pointerClaim.test.d.ts +1 -0
  49. package/dist/gestures/pointerClaim.test.js +64 -0
  50. package/dist/gestures/types.d.ts +83 -0
  51. package/dist/gestures/types.js +1 -0
  52. package/dist/handheld.browser.test.d.ts +1 -0
  53. package/dist/handheld.browser.test.js +90 -0
  54. package/dist/host-entry.d.ts +1 -0
  55. package/dist/host-entry.js +1 -0
  56. package/dist/layout/LayoutRenderer.browser.test.js +15 -3
  57. package/dist/layout/LayoutRenderer.svelte +27 -3
  58. package/dist/layout/LayoutRenderer.svelte.d.ts +4 -1
  59. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-3-splitter-drag-updates-split-sizes-when-the-splitter-handle-is-dragged-1.png +0 -0
  60. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-5-splitter-collapse-toggle-toggles-collapsed-i--on-double-click-1.png +0 -0
  61. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-6-fixed-slots-hides-the-collapse-widget-on-a-fixed-pane-but-keeps-it-on-panes-with-a-non-fixed-neighbor-1.png +0 -0
  62. package/dist/layout/compact/CarouselTabs.svelte +361 -0
  63. package/dist/layout/compact/CarouselTabs.svelte.d.ts +10 -0
  64. package/dist/layout/compact/CarouselTabs.svelte.test.d.ts +1 -0
  65. package/dist/layout/compact/CarouselTabs.svelte.test.js +300 -0
  66. package/dist/layout/compact/CompactRenderer.svelte +53 -0
  67. package/dist/layout/compact/CompactRenderer.svelte.d.ts +3 -0
  68. package/dist/layout/compact/CompactRenderer.svelte.test.d.ts +1 -0
  69. package/dist/layout/compact/CompactRenderer.svelte.test.js +125 -0
  70. package/dist/layout/compact/derive.d.ts +3 -0
  71. package/dist/layout/compact/derive.js +157 -0
  72. package/dist/layout/compact/derive.test.d.ts +1 -0
  73. package/dist/layout/compact/derive.test.js +197 -0
  74. package/dist/layout/compact/drawerStore.svelte.d.ts +21 -0
  75. package/dist/layout/compact/drawerStore.svelte.js +75 -0
  76. package/dist/layout/compact/drawerStore.svelte.test.d.ts +1 -0
  77. package/dist/layout/compact/drawerStore.svelte.test.js +43 -0
  78. package/dist/layout/compact/enrichCarousels.d.ts +8 -0
  79. package/dist/layout/compact/enrichCarousels.js +44 -0
  80. package/dist/layout/compact/enrichCarousels.test.d.ts +1 -0
  81. package/dist/layout/compact/enrichCarousels.test.js +88 -0
  82. package/dist/layout/compact/resolveRole.d.ts +6 -0
  83. package/dist/layout/compact/resolveRole.js +13 -0
  84. package/dist/layout/compact/resolveRole.test.d.ts +1 -0
  85. package/dist/layout/compact/resolveRole.test.js +18 -0
  86. package/dist/layout/compact/types.d.ts +30 -0
  87. package/dist/layout/compact/types.js +15 -0
  88. package/dist/layout/drag.svelte.js +13 -0
  89. package/dist/layout/presets.compactVariant.test.d.ts +1 -0
  90. package/dist/layout/presets.compactVariant.test.js +27 -0
  91. package/dist/layout/presets.d.ts +12 -0
  92. package/dist/layout/presets.js +16 -0
  93. package/dist/layout/store.drawers.svelte.test.d.ts +1 -0
  94. package/dist/layout/store.drawers.svelte.test.js +49 -0
  95. package/dist/layout/store.schemaVersion.test.d.ts +1 -0
  96. package/dist/layout/store.schemaVersion.test.js +35 -0
  97. package/dist/layout/store.svelte.js +52 -2
  98. package/dist/layout/types.d.ts +51 -1
  99. package/dist/layout/types.js +1 -1
  100. package/dist/layout/types.test.d.ts +1 -0
  101. package/dist/layout/types.test.js +26 -0
  102. package/dist/overlays/DrawerSurface.svelte +141 -0
  103. package/dist/overlays/DrawerSurface.svelte.d.ts +12 -0
  104. package/dist/overlays/DrawerSurface.svelte.test.d.ts +1 -0
  105. package/dist/overlays/DrawerSurface.svelte.test.js +67 -0
  106. package/dist/overlays/ModalFrame.svelte +3 -1
  107. package/dist/overlays/ModalFrame.svelte.d.ts +1 -0
  108. package/dist/overlays/OverlayRoots.svelte +12 -9
  109. package/dist/overlays/floatDismiss.js +5 -0
  110. package/dist/overlays/focusTrap.d.ts +11 -1
  111. package/dist/overlays/focusTrap.js +11 -9
  112. package/dist/overlays/modal.js +1 -0
  113. package/dist/overlays/popup.js +4 -0
  114. package/dist/overlays/types.d.ts +10 -1
  115. package/dist/primitives/Button.svelte +18 -0
  116. package/dist/primitives/Button.svelte.d.ts +6 -0
  117. package/dist/primitives/ResizableSplitter.svelte +71 -11
  118. package/dist/primitives/ResizableSplitter.svelte.d.ts +8 -0
  119. package/dist/primitives/ResizableSplitter.svelte.test.d.ts +1 -0
  120. package/dist/primitives/ResizableSplitter.svelte.test.js +74 -0
  121. package/dist/server-shard/types.d.ts +2 -1
  122. package/dist/sh3Api/headless.js +9 -1
  123. package/dist/sh3Api/headless.svelte.test.js +45 -1
  124. package/dist/sh3Runtime.svelte.d.ts +36 -0
  125. package/dist/sh3Runtime.svelte.js +33 -0
  126. package/dist/shards/activate.svelte.js +10 -0
  127. package/dist/shards/ctx-fetch.test.d.ts +1 -0
  128. package/dist/shards/ctx-fetch.test.js +66 -0
  129. package/dist/shards/types.d.ts +22 -1
  130. package/dist/tokens.css +3 -2
  131. package/dist/transport/apiFetch.d.ts +1 -0
  132. package/dist/transport/apiFetch.js +65 -0
  133. package/dist/transport/apiFetch.test.d.ts +1 -0
  134. package/dist/transport/apiFetch.test.js +37 -0
  135. package/dist/transport/authToken.d.ts +2 -0
  136. package/dist/transport/authToken.js +53 -0
  137. package/dist/transport/authToken.test.d.ts +1 -0
  138. package/dist/transport/authToken.test.js +33 -0
  139. package/dist/verbs/types.d.ts +5 -2
  140. package/dist/version.d.ts +1 -1
  141. package/dist/version.js +1 -1
  142. package/dist/viewport/classify.d.ts +8 -0
  143. package/dist/viewport/classify.js +20 -0
  144. package/dist/viewport/classify.test.d.ts +1 -0
  145. package/dist/viewport/classify.test.js +32 -0
  146. package/dist/viewport/store.browser.test.d.ts +1 -0
  147. package/dist/viewport/store.browser.test.js +33 -0
  148. package/dist/viewport/store.svelte.d.ts +9 -0
  149. package/dist/viewport/store.svelte.js +71 -0
  150. package/dist/viewport/store.svelte.test.d.ts +1 -0
  151. package/dist/viewport/store.svelte.test.js +54 -0
  152. package/dist/viewport/types.d.ts +9 -0
  153. package/dist/viewport/types.js +6 -0
  154. package/package.json +1 -1
@@ -1,2 +1,14 @@
1
1
  import type { LayoutNode, LayoutTree, LayoutPreset, CanonicalPreset } from './types';
2
+ import type { ViewportClass } from '../viewport/types';
3
+ /**
4
+ * Pick the active LayoutTree for a preset given the current viewport
5
+ * class. Compact viewport uses `variants.compact` if authored, else
6
+ * falls back to `variants.default`. Desktop always uses `variants.default`.
7
+ *
8
+ * Per spec §4 (Override path): when the compact variant is taken as-is,
9
+ * any role-tagged sidebar/inspector slots in *it* still get extracted
10
+ * into drawers by the derive() transform. So an explicit override
11
+ * doesn't have to author drawer chrome — only the docked structure.
12
+ */
13
+ export declare function resolveActiveTree(preset: CanonicalPreset, cls: ViewportClass): LayoutTree;
2
14
  export declare function normalizeInitialLayout(input: LayoutNode | LayoutTree | LayoutPreset[]): CanonicalPreset[];
@@ -35,6 +35,22 @@ function canonicalizePreset(p) {
35
35
  }
36
36
  return { name: p.name, variants };
37
37
  }
38
+ /**
39
+ * Pick the active LayoutTree for a preset given the current viewport
40
+ * class. Compact viewport uses `variants.compact` if authored, else
41
+ * falls back to `variants.default`. Desktop always uses `variants.default`.
42
+ *
43
+ * Per spec §4 (Override path): when the compact variant is taken as-is,
44
+ * any role-tagged sidebar/inspector slots in *it* still get extracted
45
+ * into drawers by the derive() transform. So an explicit override
46
+ * doesn't have to author drawer chrome — only the docked structure.
47
+ */
48
+ export function resolveActiveTree(preset, cls) {
49
+ if (cls === 'compact' && preset.variants.compact) {
50
+ return preset.variants.compact;
51
+ }
52
+ return preset.variants.default;
53
+ }
38
54
  export function normalizeInitialLayout(input) {
39
55
  if (Array.isArray(input)) {
40
56
  return input.map(canonicalizePreset);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ /*
2
+ * Drawer persistence round-trip — open the right drawer, simulate a
3
+ * remount (call attachApp twice on the same blob), assert the open
4
+ * state survived.
5
+ *
6
+ * The test uses the layoutStore's app-attach machinery directly rather
7
+ * than going through createShell, since the drawerStore <→ blob binding
8
+ * is the unit under test.
9
+ */
10
+ import { describe, it, expect, beforeEach } from 'vitest';
11
+ import { flushSync } from 'svelte';
12
+ import { attachApp, detachApp, __resetLayoutStoreForTest } from './store.svelte';
13
+ import { drawerStore } from './compact/drawerStore.svelte';
14
+ function fakeApp() {
15
+ // Minimal App shape — only fields attachApp reads.
16
+ return {
17
+ manifest: { id: 'test-app', layoutVersion: 5 },
18
+ initialLayout: {
19
+ type: 'split', direction: 'horizontal', sizes: [0.3, 0.7],
20
+ children: [
21
+ { type: 'slot', slotId: 'sb', viewId: 'v:sb', role: 'sidebar' },
22
+ { type: 'slot', slotId: 'body', viewId: 'v:body', role: 'body' },
23
+ ],
24
+ },
25
+ };
26
+ }
27
+ describe('drawer persistence round-trip', () => {
28
+ beforeEach(() => {
29
+ __resetLayoutStoreForTest();
30
+ drawerStore.__reset();
31
+ });
32
+ it('drawer state survives detach + re-attach', async () => {
33
+ const app = fakeApp();
34
+ attachApp(app);
35
+ // Run the proxy's initial $effect so the "first run skip" is consumed
36
+ // before our mutations. Subsequent mutations will trigger real flushes.
37
+ flushSync();
38
+ drawerStore.open('right');
39
+ drawerStore.activate('right', 'sb');
40
+ flushSync();
41
+ await new Promise((r) => queueMicrotask(r));
42
+ detachApp();
43
+ drawerStore.__reset();
44
+ expect(drawerStore.state.right.open).toBe(false);
45
+ attachApp(app);
46
+ expect(drawerStore.state.right.open).toBe(true);
47
+ expect(drawerStore.state.right.activeSlotId).toBe('sb');
48
+ });
49
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ /*
2
+ * Schema-version regression — a stored AppLayoutBlob written under
3
+ * LAYOUT_SCHEMA_VERSION 4 (no role hints, no drawers) must load cleanly
4
+ * under v5. The bump is purely additive; if this test breaks, the
5
+ * additive promise is broken.
6
+ */
7
+ import { describe, it, expect } from 'vitest';
8
+ import { LAYOUT_SCHEMA_VERSION } from './types';
9
+ describe('layout schema v4 → v5 backward compatibility', () => {
10
+ it('a v4 blob loads with all roles undefined and drawers absent', () => {
11
+ const tree = {
12
+ docked: {
13
+ type: 'split',
14
+ direction: 'horizontal',
15
+ sizes: [0.3, 0.7],
16
+ children: [
17
+ { type: 'slot', slotId: 'a', viewId: 'view:a' },
18
+ { type: 'slot', slotId: 'b', viewId: 'view:b' },
19
+ ],
20
+ },
21
+ floats: [],
22
+ };
23
+ const v4Blob = {
24
+ layoutVersion: 4,
25
+ activePreset: 'default',
26
+ presets: { default: { default: tree } },
27
+ };
28
+ const slotA = v4Blob.presets.default.default.docked.children[0];
29
+ expect(slotA.role).toBeUndefined();
30
+ expect(v4Blob.drawers).toBeUndefined();
31
+ });
32
+ it('LAYOUT_SCHEMA_VERSION is 6', () => {
33
+ expect(LAYOUT_SCHEMA_VERSION).toBe(6);
34
+ });
35
+ });
@@ -31,10 +31,12 @@
31
31
  */
32
32
  import { createStateZones, peekZone, clearZone } from '../state/zones.svelte';
33
33
  import { acquireSlotHost, releaseSlotHost } from './slotHostPool.svelte';
34
- import { normalizeInitialLayout } from './presets';
34
+ import { normalizeInitialLayout, resolveActiveTree } from './presets';
35
+ import { viewportStore } from '../viewport/store.svelte';
35
36
  import { collectTreeSlotRefs } from './tree-walk';
36
37
  import { bindPresetBlob, unbindPresetBlob } from '../overlays/presets';
37
38
  import { getRegisteredApp } from '../apps/registry.svelte';
39
+ import { drawerStore } from './compact/drawerStore.svelte';
38
40
  // ---------- orphan cleanup of pre-phase-8 sh3 layout key ----------------
39
41
  // Legacy pre-phase-8 orphan cleanup. The literal '__shell__' here is
40
42
  // intentional — it clears data written under the old reserved id before
@@ -140,6 +142,44 @@ export function attachApp(app) {
140
142
  // so shards can read/switch presets from their activate() hook.
141
143
  appEntry = { appId: app.manifest.id, proxy, heldSlotIds: [] };
142
144
  bindPresetBlob(proxy);
145
+ bindDrawerStoreToBlob(proxy);
146
+ }
147
+ /**
148
+ * Sync drawerStore <→ AppLayoutBlob.drawers.
149
+ *
150
+ * On attach (or preset/viewport change): read the persisted snapshot for
151
+ * (activePreset, 'compact') and hydrate drawerStore. Bind a
152
+ * write-through callback so subsequent mutations persist.
153
+ *
154
+ * v1 only persists the 'compact' viewport class; future viewport classes
155
+ * (e.g. 'phone') would extend this map without schema work.
156
+ */
157
+ function bindDrawerStoreToBlob(blob) {
158
+ const presetName = blob.activePreset;
159
+ const ensure = () => {
160
+ if (!blob.drawers)
161
+ blob.drawers = {};
162
+ if (!blob.drawers[presetName])
163
+ blob.drawers[presetName] = {};
164
+ if (!blob.drawers[presetName].compact) {
165
+ blob.drawers[presetName].compact = {
166
+ left: { open: false, activeSlotId: null },
167
+ right: { open: false, activeSlotId: null },
168
+ top: { open: false, activeSlotId: null },
169
+ };
170
+ }
171
+ return blob.drawers;
172
+ };
173
+ const persisted = ensure();
174
+ drawerStore.__hydrate(persisted[presetName].compact);
175
+ drawerStore.__setWriteThrough((next) => {
176
+ const drawers = ensure();
177
+ drawers[presetName].compact = {
178
+ left: Object.assign({}, next.left),
179
+ right: Object.assign({}, next.right),
180
+ top: Object.assign({}, next.top),
181
+ };
182
+ });
143
183
  }
144
184
  /**
145
185
  * Second-phase attach: take refcount holds on every slot in the active
@@ -231,6 +271,12 @@ export function detachApp() {
231
271
  if (!appEntry)
232
272
  return;
233
273
  unbindPresetBlob();
274
+ drawerStore.__setWriteThrough(null);
275
+ drawerStore.__hydrate({
276
+ left: { open: false, activeSlotId: null },
277
+ right: { open: false, activeSlotId: null },
278
+ top: { open: false, activeSlotId: null },
279
+ });
234
280
  for (const slotId of appEntry.heldSlotIds) {
235
281
  releaseSlotHost(slotId);
236
282
  }
@@ -271,7 +317,11 @@ const activeTree = $derived.by(() => {
271
317
  if (!preset) {
272
318
  throw new Error(`AppLayoutBlob active preset "${presetName}" not found in presets map`);
273
319
  }
274
- return preset.default;
320
+ // Per ADR-024: when the viewport is compact and the preset declares
321
+ // a `compact` variant, that variant wins. Otherwise the default
322
+ // variant is used and the framework derives drawer chrome from
323
+ // role-tagged slots in it.
324
+ return resolveActiveTree({ name: presetName, variants: preset }, viewportStore.current.class);
275
325
  }
276
326
  return HOME_TREE;
277
327
  });
@@ -1,5 +1,15 @@
1
1
  /** Axis along which a split node divides its children. */
2
2
  export type SplitDirection = 'horizontal' | 'vertical';
3
+ /**
4
+ * Slot role hint. Inert on desktop; the framework reads it on small
5
+ * viewports to derive a compact rendering (sidebars/inspectors lift into
6
+ * drawer surfaces, body slots fill the page).
7
+ *
8
+ * Default `'body'`. Authored on a slot or tab entry; if unset, falls back
9
+ * to the view's `defaultRole` (registered via the shard contract). See
10
+ * `layout/compact/resolveRole.ts`.
11
+ */
12
+ export type SlotRole = 'body' | 'sidebar' | 'inspector';
3
13
  /** How a child of a split node is sized. */
4
14
  export type SizeMode = 'fr' | 'px';
5
15
  /**
@@ -46,6 +56,11 @@ export interface TabEntry {
46
56
  label: string;
47
57
  /** Optional icon hint (not yet rendered in phase 8). */
48
58
  icon?: string;
59
+ /**
60
+ * Slot-role hint for compact rendering. Default `'body'` via
61
+ * `resolveRole(slot, viewDefault)`. Inert on desktop.
62
+ */
63
+ role?: SlotRole;
49
64
  /**
50
65
  * Caller-supplied instance data, threaded to `MountContext.meta`.
51
66
  * Ephemeral — not serialized with the layout tree.
@@ -74,6 +89,14 @@ export interface TabsNode {
74
89
  * Runtime-only — not serialized with the layout tree.
75
90
  */
76
91
  emptyRenderer?: (container: HTMLElement) => void;
92
+ /**
93
+ * When the framework renders this group as a compact-mode carousel,
94
+ * controls end-of-track behavior:
95
+ * - false (default): edge-resist (rubber-band, snap back).
96
+ * - true: warpback (swipe past last → first; past first → last).
97
+ * Inert when not rendered as a carousel.
98
+ */
99
+ wrap?: boolean;
77
100
  }
78
101
  /**
79
102
  * A leaf layout node that holds a single mounted view. `slotId` is the stable
@@ -86,6 +109,11 @@ export interface SlotNode {
86
109
  slotId: string;
87
110
  /** View id to mount into this slot, or null for an empty slot. */
88
111
  viewId: string | null;
112
+ /**
113
+ * Slot-role hint for compact rendering. Default `'body'` via
114
+ * `resolveRole(slot, viewDefault)`. Inert on desktop.
115
+ */
116
+ role?: SlotRole;
89
117
  /**
90
118
  * Caller-supplied instance data, threaded to `MountContext.meta`.
91
119
  * Ephemeral — not serialized with the layout tree. Mirrors
@@ -187,7 +215,7 @@ export type TreeRootRef = {
187
215
  * the default tree takes over — phase 7 deliberately does not ship a
188
216
  * migration framework, only the hook for one.
189
217
  */
190
- export declare const LAYOUT_SCHEMA_VERSION = 4;
218
+ export declare const LAYOUT_SCHEMA_VERSION = 6;
191
219
  /**
192
220
  * The wire shape of a persisted layout in the workspace state zone.
193
221
  * One blob per sh3 (or per program, once per-program layouts exist);
@@ -208,6 +236,14 @@ export interface PersistedLayout {
208
236
  * The `attachApp` read path wraps that shape into the new form rather
209
237
  * than discarding it; see `layout/store.svelte.ts`.
210
238
  */
239
+ /**
240
+ * Persisted per-anchor drawer state — open flag and active slot id.
241
+ * Lives on `AppLayoutBlob.drawers[presetName][viewportClass][anchor]`.
242
+ */
243
+ export interface DrawerStateBlob {
244
+ open: boolean;
245
+ activeSlotId: string | null;
246
+ }
211
247
  export interface AppLayoutBlob {
212
248
  layoutVersion: number;
213
249
  /** Name of the currently-active preset. Must be a key of `presets`. */
@@ -222,4 +258,18 @@ export interface AppLayoutBlob {
222
258
  [variantName: string]: LayoutTree;
223
259
  };
224
260
  };
261
+ /**
262
+ * Drawer state keyed by `(presetName, viewportClass)`. Optional so
263
+ * legacy blobs without this field still load. v1 only writes the
264
+ * `'compact'` viewport-class key. See ADR-024.
265
+ */
266
+ drawers?: {
267
+ [presetName: string]: {
268
+ [viewportClass: string]: {
269
+ left: DrawerStateBlob;
270
+ right: DrawerStateBlob;
271
+ top: DrawerStateBlob;
272
+ };
273
+ };
274
+ };
225
275
  }
@@ -22,4 +22,4 @@
22
22
  * the default tree takes over — phase 7 deliberately does not ship a
23
23
  * migration framework, only the hook for one.
24
24
  */
25
- export const LAYOUT_SCHEMA_VERSION = 4;
25
+ export const LAYOUT_SCHEMA_VERSION = 6;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { LAYOUT_SCHEMA_VERSION } from './types';
3
+ describe('TabsNode.wrap', () => {
4
+ it('accepts the optional wrap field', () => {
5
+ const tabs = {
6
+ type: 'tabs',
7
+ tabs: [{ slotId: 's1', viewId: null, label: 'A' }],
8
+ activeTab: 0,
9
+ wrap: true,
10
+ };
11
+ expect(tabs.wrap).toBe(true);
12
+ });
13
+ it('defaults to undefined (consumers treat as false)', () => {
14
+ const tabs = {
15
+ type: 'tabs',
16
+ tabs: [{ slotId: 's1', viewId: null, label: 'A' }],
17
+ activeTab: 0,
18
+ };
19
+ expect(tabs.wrap).toBeUndefined();
20
+ });
21
+ });
22
+ describe('LAYOUT_SCHEMA_VERSION', () => {
23
+ it('is 6 (bumped for TabsNode.wrap)', () => {
24
+ expect(LAYOUT_SCHEMA_VERSION).toBe(6);
25
+ });
26
+ });
@@ -0,0 +1,141 @@
1
+ <script lang="ts">
2
+ /*
3
+ * One drawer frame anchored to an edge. Renders nothing when closed.
4
+ * Open state slides the panel in over a backdrop. Tapping the backdrop
5
+ * fires onClose; tapping the close button does the same.
6
+ *
7
+ * Multi-slot drawers render a tab strip in the header. Single-slot
8
+ * drawers show the slot label only.
9
+ *
10
+ * Slot rendering goes through the standard SlotContainer path so the
11
+ * pooled host (and the mounted view) survives mount/unmount via the
12
+ * slot host pool — same mechanism as tab-drag re-parents.
13
+ */
14
+ import type { DrawerAnchor, DrawerSpec } from '../layout/compact/types';
15
+ import type { SlotNode } from '../layout/types';
16
+ import SlotContainer from '../layout/SlotContainer.svelte';
17
+
18
+ let {
19
+ anchor,
20
+ spec,
21
+ open,
22
+ activeSlotId,
23
+ onClose,
24
+ onActivate,
25
+ }: {
26
+ anchor: DrawerAnchor;
27
+ spec: DrawerSpec;
28
+ open: boolean;
29
+ activeSlotId: string | null;
30
+ onClose: () => void;
31
+ onActivate: (slotId: string) => void;
32
+ } = $props();
33
+
34
+ const activeSlot = $derived(
35
+ spec.slots.find((s) => s.slotId === activeSlotId) ?? spec.slots[0],
36
+ );
37
+
38
+ const slotNode: SlotNode = $derived({
39
+ type: 'slot',
40
+ slotId: activeSlot.slotId,
41
+ viewId: activeSlot.viewId,
42
+ role: activeSlot.role,
43
+ });
44
+ </script>
45
+
46
+ {#if open}
47
+ <div
48
+ class="drawer-backdrop"
49
+ onclick={onClose}
50
+ onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}
51
+ role="presentation"
52
+ ></div>
53
+ <div
54
+ class="drawer drawer-{anchor}"
55
+ data-sh3-region="drawer"
56
+ data-sh3-anchor={anchor}
57
+ >
58
+ <header>
59
+ <span class="title">{activeSlot.label}</span>
60
+ <button
61
+ class="close"
62
+ onclick={onClose}
63
+ aria-label="Close drawer"
64
+ >×</button>
65
+ </header>
66
+ {#if spec.slots.length > 1}
67
+ <div class="tab-strip" role="tablist">
68
+ {#each spec.slots as s (s.slotId)}
69
+ <button
70
+ role="tab"
71
+ aria-selected={s.slotId === activeSlot.slotId}
72
+ class:active={s.slotId === activeSlot.slotId}
73
+ onclick={() => onActivate(s.slotId)}
74
+ >
75
+ {s.label}
76
+ </button>
77
+ {/each}
78
+ </div>
79
+ {/if}
80
+ <div class="body">
81
+ <SlotContainer node={slotNode} label={activeSlot.label} />
82
+ </div>
83
+ </div>
84
+ {/if}
85
+
86
+ <style>
87
+ .drawer-backdrop {
88
+ position: absolute;
89
+ inset: 0;
90
+ background: var(--sh3-overlay-backdrop, rgba(0, 0, 0, 0.35));
91
+ pointer-events: auto;
92
+ }
93
+ .drawer {
94
+ position: absolute;
95
+ background: var(--sh3-bg);
96
+ color: var(--sh3-fg);
97
+ box-shadow: var(--sh3-shadow-md, 0 0 16px rgba(0, 0, 0, 0.2));
98
+ display: flex;
99
+ flex-direction: column;
100
+ border: 1px solid var(--sh3-border);
101
+ pointer-events: auto;
102
+ }
103
+ .drawer-left { top: 0; bottom: 0; left: 0; width: min(360px, 80vw); }
104
+ .drawer-right { top: 0; bottom: 0; right: 0; width: min(360px, 80vw); }
105
+ .drawer-top { left: 0; right: 0; top: 0; height: min(50vh, 360px); }
106
+ header {
107
+ display: flex;
108
+ align-items: center;
109
+ padding: var(--sh3-pad-sm) var(--sh3-pad-md);
110
+ gap: var(--sh3-pad-md);
111
+ border-bottom: 1px solid var(--sh3-border);
112
+ background: var(--sh3-bg-elevated);
113
+ }
114
+ .title { font-weight: 600; }
115
+ .close {
116
+ margin-left: auto;
117
+ background: none;
118
+ border: none;
119
+ font-size: var(--sh3-font-lg);
120
+ cursor: pointer;
121
+ color: var(--sh3-fg-muted);
122
+ padding: 0 var(--sh3-pad-sm);
123
+ }
124
+ .close:hover { color: var(--sh3-fg); }
125
+ .tab-strip {
126
+ display: flex;
127
+ gap: 2px;
128
+ padding: var(--sh3-pad-xs) var(--sh3-pad-sm) 0;
129
+ background: var(--sh3-bg-sunken);
130
+ }
131
+ .tab-strip button {
132
+ padding: var(--sh3-pad-xs) var(--sh3-pad-sm);
133
+ border: 1px solid var(--sh3-border);
134
+ background: var(--sh3-bg-elevated);
135
+ border-bottom: none;
136
+ cursor: pointer;
137
+ color: var(--sh3-fg);
138
+ }
139
+ .tab-strip button.active { background: var(--sh3-bg); }
140
+ .body { flex: 1; min-height: 0; overflow: auto; }
141
+ </style>
@@ -0,0 +1,12 @@
1
+ import type { DrawerAnchor, DrawerSpec } from '../layout/compact/types';
2
+ type $$ComponentProps = {
3
+ anchor: DrawerAnchor;
4
+ spec: DrawerSpec;
5
+ open: boolean;
6
+ activeSlotId: string | null;
7
+ onClose: () => void;
8
+ onActivate: (slotId: string) => void;
9
+ };
10
+ declare const DrawerSurface: import("svelte").Component<$$ComponentProps, {}, "">;
11
+ type DrawerSurface = ReturnType<typeof DrawerSurface>;
12
+ export default DrawerSurface;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,67 @@
1
+ /*
2
+ * DOM smoke test for DrawerSurface — when open with a single-slot spec,
3
+ * the drawer renders the slot's label in its header. When closed, it
4
+ * renders nothing.
5
+ *
6
+ * Re-parent contract testing (slot DOM container actually moves, doesn't
7
+ * remount) is handled by the browser-mode handheld-flip test.
8
+ */
9
+ import { describe, it, expect, afterEach } from 'vitest';
10
+ import { mount, unmount, flushSync } from 'svelte';
11
+ import DrawerSurface from './DrawerSurface.svelte';
12
+ const DrawerSurfaceAny = DrawerSurface;
13
+ const spec = {
14
+ slots: [{ slotId: 'sb', viewId: 'v:sb', label: 'Files', role: 'sidebar' }],
15
+ };
16
+ const multiSpec = {
17
+ slots: [
18
+ { slotId: 'sb', viewId: 'v:sb', label: 'Files', role: 'sidebar' },
19
+ { slotId: 'pin', viewId: 'v:pin', label: 'Pinned', role: 'sidebar' },
20
+ ],
21
+ };
22
+ let mounted = null;
23
+ let host = null;
24
+ function renderHost(props) {
25
+ host = document.createElement('div');
26
+ host.style.position = 'relative';
27
+ document.body.appendChild(host);
28
+ mounted = mount(DrawerSurfaceAny, { target: host, props });
29
+ flushSync();
30
+ return host;
31
+ }
32
+ afterEach(() => {
33
+ if (mounted) {
34
+ unmount(mounted);
35
+ mounted = null;
36
+ }
37
+ if (host) {
38
+ host.remove();
39
+ host = null;
40
+ }
41
+ });
42
+ describe('DrawerSurface (dom)', () => {
43
+ it('renders nothing when closed', () => {
44
+ const el = renderHost({
45
+ anchor: 'left', spec, open: false, activeSlotId: null,
46
+ onClose: () => { }, onActivate: () => { },
47
+ });
48
+ expect(el.querySelector('[data-sh3-region="drawer"]')).toBeNull();
49
+ });
50
+ it('renders header with slot label when open', () => {
51
+ const el = renderHost({
52
+ anchor: 'left', spec, open: true, activeSlotId: 'sb',
53
+ onClose: () => { }, onActivate: () => { },
54
+ });
55
+ const header = el.querySelector('[data-sh3-region="drawer"] header');
56
+ expect(header === null || header === void 0 ? void 0 : header.textContent).toContain('Files');
57
+ });
58
+ it('multi-slot drawer renders a tab strip with one button per slot', () => {
59
+ const el = renderHost({
60
+ anchor: 'left', spec: multiSpec, open: true, activeSlotId: 'pin',
61
+ onClose: () => { }, onActivate: () => { },
62
+ });
63
+ const tabs = el.querySelectorAll('[data-sh3-region="drawer"] .tab-strip button');
64
+ expect(tabs.length).toBe(2);
65
+ expect(tabs[1].classList.contains('active')).toBe(true);
66
+ });
67
+ });
@@ -35,19 +35,21 @@
35
35
  close,
36
36
  boxStyle,
37
37
  onBackdropClick,
38
+ initialFocus = true,
38
39
  }: {
39
40
  Content: Component<Record<string, unknown>>;
40
41
  contentProps: Record<string, unknown>;
41
42
  close: () => void;
42
43
  boxStyle?: string;
43
44
  onBackdropClick?: () => void;
45
+ initialFocus?: boolean;
44
46
  } = $props();
45
47
 
46
48
  let box: HTMLDivElement;
47
49
 
48
50
  $effect(() => {
49
51
  if (!box) return;
50
- return createFocusTrap(box);
52
+ return createFocusTrap(box, { initialFocus });
51
53
  });
52
54
 
53
55
  function handleFrameClick(ev: MouseEvent): void {
@@ -5,6 +5,7 @@ type $$ComponentProps = {
5
5
  close: () => void;
6
6
  boxStyle?: string;
7
7
  onBackdropClick?: () => void;
8
+ initialFocus?: boolean;
8
9
  };
9
10
  declare const ModalFrame: Component<$$ComponentProps, {}, "">;
10
11
  type ModalFrame = ReturnType<typeof ModalFrame>;
@@ -23,13 +23,16 @@
23
23
 
24
24
  // Layer metadata — order matches the stack in docs/design/layout.md.
25
25
  // Index 0 here is layer 1 (floating panels); layer 0 is the content area.
26
- const overlayLayers: { layer: number; name: OverlayLayer }[] = [
27
- { layer: 1, name: 'floating' },
28
- { layer: 2, name: 'drag-preview' },
29
- { layer: 3, name: 'popup' },
30
- { layer: 4, name: 'modal' },
31
- { layer: 5, name: 'toast' },
32
- { layer: 6, name: 'command' },
26
+ // The 'drawers' layer (compact-mode side panels) sits between docked (0)
27
+ // and floating (1); its z-index comes from --sh3-z-layer-drawers.
28
+ const overlayLayers: { layer: number | string; name: OverlayLayer; zToken: string }[] = [
29
+ { layer: 'drawers', name: 'drawers', zToken: '--sh3-z-layer-drawers' },
30
+ { layer: 1, name: 'floating', zToken: '--sh3-z-layer-1' },
31
+ { layer: 2, name: 'drag-preview', zToken: '--sh3-z-layer-2' },
32
+ { layer: 3, name: 'popup', zToken: '--sh3-z-layer-3' },
33
+ { layer: 4, name: 'modal', zToken: '--sh3-z-layer-4' },
34
+ { layer: 5, name: 'toast', zToken: '--sh3-z-layer-5' },
35
+ { layer: 6, name: 'command', zToken: '--sh3-z-layer-6' },
33
36
  ];
34
37
 
35
38
  const overlayRoots: Partial<Record<OverlayLayer, HTMLDivElement>> = $state({});
@@ -55,12 +58,12 @@
55
58
  </script>
56
59
 
57
60
  <div class="sh3-overlays" aria-hidden="true">
58
- {#each overlayLayers as { layer, name } (layer)}
61
+ {#each overlayLayers as { layer, name, zToken } (layer)}
59
62
  <div
60
63
  class="sh3-overlay-root"
61
64
  data-sh3-overlay={name}
62
65
  data-sh3-layer={layer}
63
- style="z-index: var(--sh3-z-layer-{layer});"
66
+ style="z-index: var({zToken});"
64
67
  bind:this={overlayRoots[name]}
65
68
  >
66
69
  {#if name === 'floating'}
@@ -12,11 +12,16 @@
12
12
  * own action handlers fire on `click`/`pointerup` and are unaffected.
13
13
  */
14
14
  import { floatManager } from './float';
15
+ import { getOwner } from '../gestures';
15
16
  const registry = new Map();
16
17
  let listenerAttached = false;
17
18
  function onDocumentPointerDown(event) {
18
19
  if (registry.size === 0)
19
20
  return;
21
+ // Skip dismiss if a gesture has claimed this pointer — a swipe or drag
22
+ // is in progress and this pointerdown is not a real outside-click.
23
+ if (getOwner(event.pointerId))
24
+ return;
20
25
  const target = event.target;
21
26
  if (!target)
22
27
  return;