sh3-core 0.17.0 → 0.17.2

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 (77) hide show
  1. package/dist/Sh3.svelte +48 -35
  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/listActionsFromEntries.test.js +29 -0
  4. package/dist/actions/listActive.js +2 -0
  5. package/dist/actions/listeners.js +4 -0
  6. package/dist/actions/programmatic-dispatch.svelte.test.js +9 -2
  7. package/dist/actions/types.d.ts +8 -0
  8. package/dist/api.d.ts +4 -1
  9. package/dist/chrome/CompactChrome.svelte +96 -0
  10. package/dist/chrome/CompactChrome.svelte.d.ts +3 -0
  11. package/dist/chrome/CompactChrome.svelte.test.d.ts +1 -0
  12. package/dist/chrome/CompactChrome.svelte.test.js +67 -0
  13. package/dist/chrome/MenuSheet.svelte +224 -0
  14. package/dist/chrome/MenuSheet.svelte.d.ts +7 -0
  15. package/dist/chrome/MenuSheet.svelte.test.d.ts +1 -0
  16. package/dist/chrome/MenuSheet.svelte.test.js +46 -0
  17. package/dist/handheld.browser.test.d.ts +1 -0
  18. package/dist/handheld.browser.test.js +90 -0
  19. package/dist/layout/LayoutRenderer.svelte +12 -1
  20. package/dist/layout/LayoutRenderer.svelte.d.ts +2 -1
  21. package/dist/layout/compact/CompactRenderer.svelte +53 -0
  22. package/dist/layout/compact/CompactRenderer.svelte.d.ts +3 -0
  23. package/dist/layout/compact/CompactRenderer.svelte.test.d.ts +1 -0
  24. package/dist/layout/compact/CompactRenderer.svelte.test.js +76 -0
  25. package/dist/layout/compact/derive.d.ts +3 -0
  26. package/dist/layout/compact/derive.js +155 -0
  27. package/dist/layout/compact/derive.test.d.ts +1 -0
  28. package/dist/layout/compact/derive.test.js +160 -0
  29. package/dist/layout/compact/drawerStore.svelte.d.ts +21 -0
  30. package/dist/layout/compact/drawerStore.svelte.js +75 -0
  31. package/dist/layout/compact/drawerStore.svelte.test.d.ts +1 -0
  32. package/dist/layout/compact/drawerStore.svelte.test.js +43 -0
  33. package/dist/layout/compact/resolveRole.d.ts +6 -0
  34. package/dist/layout/compact/resolveRole.js +13 -0
  35. package/dist/layout/compact/resolveRole.test.d.ts +1 -0
  36. package/dist/layout/compact/resolveRole.test.js +18 -0
  37. package/dist/layout/compact/types.d.ts +27 -0
  38. package/dist/layout/compact/types.js +15 -0
  39. package/dist/layout/presets.compactVariant.test.d.ts +1 -0
  40. package/dist/layout/presets.compactVariant.test.js +27 -0
  41. package/dist/layout/presets.d.ts +12 -0
  42. package/dist/layout/presets.js +16 -0
  43. package/dist/layout/store.drawers.svelte.test.d.ts +1 -0
  44. package/dist/layout/store.drawers.svelte.test.js +49 -0
  45. package/dist/layout/store.schemaVersion.test.d.ts +1 -0
  46. package/dist/layout/store.schemaVersion.test.js +35 -0
  47. package/dist/layout/store.svelte.js +52 -2
  48. package/dist/layout/types.d.ts +43 -1
  49. package/dist/layout/types.js +1 -1
  50. package/dist/overlays/DrawerSurface.svelte +141 -0
  51. package/dist/overlays/DrawerSurface.svelte.d.ts +12 -0
  52. package/dist/overlays/DrawerSurface.svelte.test.d.ts +1 -0
  53. package/dist/overlays/DrawerSurface.svelte.test.js +67 -0
  54. package/dist/overlays/OverlayRoots.svelte +12 -9
  55. package/dist/overlays/types.d.ts +1 -1
  56. package/dist/sh3Api/headless.js +9 -1
  57. package/dist/sh3Api/headless.svelte.test.js +45 -1
  58. package/dist/sh3Runtime.svelte.d.ts +36 -0
  59. package/dist/sh3Runtime.svelte.js +33 -0
  60. package/dist/shards/types.d.ts +9 -1
  61. package/dist/tokens.css +3 -2
  62. package/dist/verbs/types.d.ts +5 -2
  63. package/dist/version.d.ts +1 -1
  64. package/dist/version.js +1 -1
  65. package/dist/viewport/classify.d.ts +8 -0
  66. package/dist/viewport/classify.js +20 -0
  67. package/dist/viewport/classify.test.d.ts +1 -0
  68. package/dist/viewport/classify.test.js +32 -0
  69. package/dist/viewport/store.browser.test.d.ts +1 -0
  70. package/dist/viewport/store.browser.test.js +33 -0
  71. package/dist/viewport/store.svelte.d.ts +9 -0
  72. package/dist/viewport/store.svelte.js +71 -0
  73. package/dist/viewport/store.svelte.test.d.ts +1 -0
  74. package/dist/viewport/store.svelte.test.js +54 -0
  75. package/dist/viewport/types.d.ts +9 -0
  76. package/dist/viewport/types.js +6 -0
  77. package/package.json +1 -1
@@ -0,0 +1,75 @@
1
+ /*
2
+ * drawerStore — backing store for Sh3.drawers.
3
+ *
4
+ * In-memory state mirror; persistence integration (read/write
5
+ * AppLayoutBlob.drawers) lives in layout/store.svelte.ts and is wired
6
+ * via __setWriteThrough(...). When no blob is bound (home layout, satellite
7
+ * mode, tests), mutations stay in-memory only.
8
+ *
9
+ * The state object is keyed by anchor only; the per-(preset, viewport)
10
+ * dimension lives in the blob, not here. The bind helper in layoutStore
11
+ * rehydrates this store when the active preset or viewport class changes.
12
+ *
13
+ * File is .svelte.ts so the Svelte compiler processes the runes.
14
+ */
15
+ function initial() {
16
+ return {
17
+ left: { open: false, activeSlotId: null },
18
+ right: { open: false, activeSlotId: null },
19
+ top: { open: false, activeSlotId: null },
20
+ };
21
+ }
22
+ const state = $state(initial());
23
+ let writeThrough = null;
24
+ function flush() {
25
+ if (writeThrough)
26
+ writeThrough(state);
27
+ }
28
+ export const drawerStore = {
29
+ get state() {
30
+ return state;
31
+ },
32
+ open(anchor) {
33
+ state[anchor].open = true;
34
+ flush();
35
+ },
36
+ close(anchor) {
37
+ state[anchor].open = false;
38
+ flush();
39
+ },
40
+ toggle(anchor) {
41
+ state[anchor].open = !state[anchor].open;
42
+ flush();
43
+ },
44
+ activate(anchor, slotId) {
45
+ state[anchor].activeSlotId = slotId;
46
+ flush();
47
+ },
48
+ /**
49
+ * Bind a write-through callback. layoutStore calls this when the active
50
+ * preset/viewport changes; the callback persists state into the
51
+ * AppLayoutBlob.drawers field. Pass null to unbind.
52
+ */
53
+ __setWriteThrough(cb) {
54
+ writeThrough = cb;
55
+ },
56
+ /**
57
+ * Replace the current state from a persisted snapshot. Used by
58
+ * layoutStore on preset/viewport-class change.
59
+ */
60
+ __hydrate(snapshot) {
61
+ for (const anchor of ['left', 'right', 'top']) {
62
+ state[anchor].open = snapshot[anchor].open;
63
+ state[anchor].activeSlotId = snapshot[anchor].activeSlotId;
64
+ }
65
+ },
66
+ /** Test-only reset. */
67
+ __reset() {
68
+ writeThrough = null;
69
+ const fresh = initial();
70
+ for (const anchor of ['left', 'right', 'top']) {
71
+ state[anchor].open = fresh[anchor].open;
72
+ state[anchor].activeSlotId = fresh[anchor].activeSlotId;
73
+ }
74
+ },
75
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ /*
2
+ * Drawer store unit tests. These exercise the in-memory state machine
3
+ * only; persistence integration is covered in Task 11's blob
4
+ * round-trip test.
5
+ */
6
+ import { describe, it, expect, beforeEach } from 'vitest';
7
+ import { drawerStore } from './drawerStore.svelte';
8
+ describe('drawerStore', () => {
9
+ beforeEach(() => {
10
+ drawerStore.__reset();
11
+ });
12
+ it('starts with all drawers closed', () => {
13
+ expect(drawerStore.state.left).toEqual({ open: false, activeSlotId: null });
14
+ expect(drawerStore.state.right).toEqual({ open: false, activeSlotId: null });
15
+ expect(drawerStore.state.top).toEqual({ open: false, activeSlotId: null });
16
+ });
17
+ it('open(anchor) sets open true', () => {
18
+ drawerStore.open('left');
19
+ expect(drawerStore.state.left.open).toBe(true);
20
+ });
21
+ it('close(anchor) sets open false', () => {
22
+ drawerStore.open('left');
23
+ drawerStore.close('left');
24
+ expect(drawerStore.state.left.open).toBe(false);
25
+ });
26
+ it('toggle(anchor) flips open state', () => {
27
+ drawerStore.toggle('right');
28
+ expect(drawerStore.state.right.open).toBe(true);
29
+ drawerStore.toggle('right');
30
+ expect(drawerStore.state.right.open).toBe(false);
31
+ });
32
+ it('activate(anchor, slotId) sets activeSlotId', () => {
33
+ drawerStore.activate('left', 'sidebar-1');
34
+ expect(drawerStore.state.left.activeSlotId).toBe('sidebar-1');
35
+ });
36
+ it('reset returns to initial state', () => {
37
+ drawerStore.open('left');
38
+ drawerStore.activate('right', 'x');
39
+ drawerStore.__reset();
40
+ expect(drawerStore.state.left.open).toBe(false);
41
+ expect(drawerStore.state.right.activeSlotId).toBeNull();
42
+ });
43
+ });
@@ -0,0 +1,6 @@
1
+ import type { SlotRole } from '../types';
2
+ interface SlotLike {
3
+ role?: SlotRole;
4
+ }
5
+ export declare function resolveRole(slot: SlotLike, viewDefault: SlotRole | undefined): SlotRole;
6
+ export {};
@@ -0,0 +1,13 @@
1
+ /*
2
+ * Role resolution: slot-level wins, view-level fills in, default 'body'.
3
+ *
4
+ * Why this matters: authoring a layout shouldn't require knowing every
5
+ * view's natural role. The view's author knows best ('graphlive:hierarchy'
6
+ * is a sidebar by nature), so they declare `defaultRole: 'sidebar'` on
7
+ * the ViewHandle. The app retains override authority by writing `role`
8
+ * on the slot itself.
9
+ */
10
+ export function resolveRole(slot, viewDefault) {
11
+ var _a, _b;
12
+ return (_b = (_a = slot.role) !== null && _a !== void 0 ? _a : viewDefault) !== null && _b !== void 0 ? _b : 'body';
13
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,18 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { resolveRole } from './resolveRole';
3
+ describe('resolveRole', () => {
4
+ it('slot-level role wins over view default', () => {
5
+ expect(resolveRole({ role: 'body' }, 'sidebar')).toBe('body');
6
+ expect(resolveRole({ role: 'inspector' }, 'sidebar')).toBe('inspector');
7
+ });
8
+ it('view default fills in when slot role is unset', () => {
9
+ expect(resolveRole({}, 'sidebar')).toBe('sidebar');
10
+ expect(resolveRole({}, 'inspector')).toBe('inspector');
11
+ });
12
+ it('defaults to body when both unset', () => {
13
+ expect(resolveRole({}, undefined)).toBe('body');
14
+ });
15
+ it('treats undefined slot role same as missing field', () => {
16
+ expect(resolveRole({ role: undefined }, 'sidebar')).toBe('sidebar');
17
+ });
18
+ });
@@ -0,0 +1,27 @@
1
+ import type { LayoutNode, SlotNode, SlotRole } from '../types';
2
+ export type DrawerAnchor = 'left' | 'right' | 'top';
3
+ export interface DrawerSpec {
4
+ slots: Array<{
5
+ slotId: string;
6
+ viewId: string | null;
7
+ label: string;
8
+ icon?: string;
9
+ role: SlotRole;
10
+ }>;
11
+ }
12
+ export interface CompactRendering {
13
+ bodyRoot: LayoutNode;
14
+ drawers: {
15
+ left: DrawerSpec | null;
16
+ right: DrawerSpec | null;
17
+ top: DrawerSpec | null;
18
+ };
19
+ }
20
+ export interface DrawerState {
21
+ open: boolean;
22
+ activeSlotId: string | null;
23
+ }
24
+ export type DrawerStateMap = {
25
+ [anchor in DrawerAnchor]: DrawerState;
26
+ };
27
+ export declare const EMPTY_BODY: SlotNode;
@@ -0,0 +1,15 @@
1
+ /*
2
+ * Compact-rendering types. The derivation (./derive.ts) walks the
3
+ * canonical LayoutNode tree and emits a CompactRendering: a body root
4
+ * (the body-only sub-tree) plus per-anchor drawer specs.
5
+ *
6
+ * EMPTY_BODY is the placeholder used when an input tree has zero body
7
+ * slots — likely an authoring bug, but we render the chrome correctly
8
+ * rather than crashing.
9
+ */
10
+ export const EMPTY_BODY = {
11
+ type: 'slot',
12
+ slotId: '__sh3core__:compact:empty',
13
+ viewId: null,
14
+ role: 'body',
15
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { resolveActiveTree } from './presets';
3
+ describe('compact variant selection', () => {
4
+ const preset = {
5
+ name: 'main',
6
+ variants: {
7
+ default: { docked: { type: 'slot', slotId: 'a', viewId: 'v:a' }, floats: [] },
8
+ compact: { docked: { type: 'slot', slotId: 'b', viewId: 'v:b' }, floats: [] },
9
+ },
10
+ };
11
+ it('returns default variant when class is desktop', () => {
12
+ const tree = resolveActiveTree(preset, 'desktop');
13
+ expect(tree.docked.slotId).toBe('a');
14
+ });
15
+ it('returns compact variant when class is compact and present', () => {
16
+ const tree = resolveActiveTree(preset, 'compact');
17
+ expect(tree.docked.slotId).toBe('b');
18
+ });
19
+ it('falls back to default when compact variant is absent', () => {
20
+ const onlyDefault = {
21
+ name: 'main',
22
+ variants: { default: preset.variants.default },
23
+ };
24
+ const tree = resolveActiveTree(onlyDefault, 'compact');
25
+ expect(tree.docked.slotId).toBe('a');
26
+ });
27
+ });
@@ -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 5', () => {
33
+ expect(LAYOUT_SCHEMA_VERSION).toBe(5);
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.
@@ -86,6 +101,11 @@ export interface SlotNode {
86
101
  slotId: string;
87
102
  /** View id to mount into this slot, or null for an empty slot. */
88
103
  viewId: string | null;
104
+ /**
105
+ * Slot-role hint for compact rendering. Default `'body'` via
106
+ * `resolveRole(slot, viewDefault)`. Inert on desktop.
107
+ */
108
+ role?: SlotRole;
89
109
  /**
90
110
  * Caller-supplied instance data, threaded to `MountContext.meta`.
91
111
  * Ephemeral — not serialized with the layout tree. Mirrors
@@ -187,7 +207,7 @@ export type TreeRootRef = {
187
207
  * the default tree takes over — phase 7 deliberately does not ship a
188
208
  * migration framework, only the hook for one.
189
209
  */
190
- export declare const LAYOUT_SCHEMA_VERSION = 4;
210
+ export declare const LAYOUT_SCHEMA_VERSION = 5;
191
211
  /**
192
212
  * The wire shape of a persisted layout in the workspace state zone.
193
213
  * One blob per sh3 (or per program, once per-program layouts exist);
@@ -208,6 +228,14 @@ export interface PersistedLayout {
208
228
  * The `attachApp` read path wraps that shape into the new form rather
209
229
  * than discarding it; see `layout/store.svelte.ts`.
210
230
  */
231
+ /**
232
+ * Persisted per-anchor drawer state — open flag and active slot id.
233
+ * Lives on `AppLayoutBlob.drawers[presetName][viewportClass][anchor]`.
234
+ */
235
+ export interface DrawerStateBlob {
236
+ open: boolean;
237
+ activeSlotId: string | null;
238
+ }
211
239
  export interface AppLayoutBlob {
212
240
  layoutVersion: number;
213
241
  /** Name of the currently-active preset. Must be a key of `presets`. */
@@ -222,4 +250,18 @@ export interface AppLayoutBlob {
222
250
  [variantName: string]: LayoutTree;
223
251
  };
224
252
  };
253
+ /**
254
+ * Drawer state keyed by `(presetName, viewportClass)`. Optional so
255
+ * legacy blobs without this field still load. v1 only writes the
256
+ * `'compact'` viewport-class key. See ADR-024.
257
+ */
258
+ drawers?: {
259
+ [presetName: string]: {
260
+ [viewportClass: string]: {
261
+ left: DrawerStateBlob;
262
+ right: DrawerStateBlob;
263
+ top: DrawerStateBlob;
264
+ };
265
+ };
266
+ };
225
267
  }
@@ -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 = 5;