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,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
+ });
@@ -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'}
@@ -1,4 +1,4 @@
1
- export type OverlayLayer = 'floating' | 'drag-preview' | 'popup' | 'modal' | 'toast' | 'command';
1
+ export type OverlayLayer = 'drawers' | 'floating' | 'drag-preview' | 'popup' | 'modal' | 'toast' | 'command';
2
2
  /** A handle returned by every overlay opener. Calling close() is idempotent. */
3
3
  export interface OverlayHandle {
4
4
  close(): void;
@@ -300,7 +300,15 @@ export function makeSh3Api(opts) {
300
300
  },
301
301
  listActions(actionOpts) {
302
302
  const all = listActionsFromEntries(listActionEntriesFromRegistry(), getLiveDispatcherState());
303
- return (actionOpts === null || actionOpts === void 0 ? void 0 : actionOpts.activeOnly) ? all.filter((a) => a.active) : all;
303
+ let out = all;
304
+ if ((actionOpts === null || actionOpts === void 0 ? void 0 : actionOpts.submenuOf) !== undefined) {
305
+ const parent = actionOpts.submenuOf;
306
+ out = out.filter((a) => a.submenuOf === parent);
307
+ }
308
+ if (actionOpts === null || actionOpts === void 0 ? void 0 : actionOpts.activeOnly) {
309
+ out = out.filter((a) => a.active);
310
+ }
311
+ return out;
304
312
  },
305
313
  runAction(id, runOpts) {
306
314
  return dispatchActionProgrammatic(id, runOpts);
@@ -1,5 +1,8 @@
1
- import { describe, it, expect } from 'vitest';
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { makeSh3Api } from './headless';
3
+ import { registerAction, __resetActionsRegistryForTest, } from '../actions/registry';
4
+ import { __resetContributionsForTest } from '../contributions/registry';
5
+ import { __resetDispatcherStateForTest } from '../actions/state.svelte';
3
6
  function makeMockZoneManager() {
4
7
  const data = {
5
8
  ephemeral: {},
@@ -57,3 +60,44 @@ describe('sh3Api readZone', () => {
57
60
  expect(api.readZone('not-a-shard', 'workspace')).toBe(null);
58
61
  });
59
62
  });
63
+ describe('sh3Api listActions submenu filter', () => {
64
+ beforeEach(() => {
65
+ __resetContributionsForTest();
66
+ __resetActionsRegistryForTest();
67
+ __resetDispatcherStateForTest();
68
+ });
69
+ it('returns only children of the named parent when { submenuOf } is set', () => {
70
+ registerAction({ id: 'theme.set', label: 'Theme', scope: 'home', submenu: true }, 'shard.x');
71
+ registerAction({
72
+ id: 'theme.set:dark', label: 'Dark', scope: 'home',
73
+ submenuOf: 'theme.set', run: () => { },
74
+ }, 'shard.x');
75
+ registerAction({
76
+ id: 'theme.set:light', label: 'Light', scope: 'home',
77
+ submenuOf: 'theme.set', run: () => { },
78
+ }, 'shard.x');
79
+ registerAction({ id: 'unrelated', label: 'U', scope: 'home', run: () => { } }, 'shard.x');
80
+ const api = makeSh3Api({ callerKind: 'verb' });
81
+ const ids = api.listActions({ submenuOf: 'theme.set' }).map((d) => d.id);
82
+ expect(ids.sort()).toEqual(['theme.set:dark', 'theme.set:light']);
83
+ });
84
+ it('returns [] when no children match the parent id', () => {
85
+ registerAction({ id: 'home.go', label: 'Go', scope: 'home', run: () => { } }, 'shard.x');
86
+ const api = makeSh3Api({ callerKind: 'verb' });
87
+ expect(api.listActions({ submenuOf: 'nope' })).toEqual([]);
88
+ });
89
+ it('combines with { activeOnly } — both predicates must hold', () => {
90
+ registerAction({ id: 'p', label: 'P', scope: 'home', submenu: true }, 'shard.x');
91
+ // active child (home is active by default in the test state)
92
+ registerAction({ id: 'p:a', label: 'A', scope: 'home',
93
+ submenuOf: 'p', run: () => { } }, 'shard.x');
94
+ // inactive child (app scope, no active app)
95
+ registerAction({ id: 'p:b', label: 'B', scope: 'app',
96
+ submenuOf: 'p', run: () => { } }, 'shard.x');
97
+ const api = makeSh3Api({ callerKind: 'verb' });
98
+ const ids = api
99
+ .listActions({ submenuOf: 'p', activeOnly: true })
100
+ .map((d) => d.id);
101
+ expect(ids).toEqual(['p:a']);
102
+ });
103
+ });
@@ -10,6 +10,8 @@ import type { ColorApi } from './color/api';
10
10
  import { type OpenContextMenuOpts, type OpenPaletteOpts } from './actions/listeners';
11
11
  import type { ActiveActionDescriptor } from './actions/types';
12
12
  import { type DispatchToTerminalResult } from './shell-shard/dispatch-to-terminal';
13
+ import type { ViewportInfo, ViewportClass } from './viewport/types';
14
+ import type { DrawerAnchor, DrawerStateMap } from './layout/compact/types';
13
15
  /**
14
16
  * The process-wide sh3 singleton exposed to shards and the sh3's own
15
17
  * internal code. Provides state zone creation and overlay managers.
@@ -39,6 +41,17 @@ export interface Sh3 {
39
41
  color: ColorApi;
40
42
  /** Actions facade — rebind keys, query bindings, open menus/palette. */
41
43
  actions: Sh3ActionsApi;
44
+ /**
45
+ * Reactive viewport classification. Subscribers fire on class change
46
+ * (desktop ↔ compact). Use `override(cls)` to pin a class for
47
+ * playgrounds and debug; pass null to restore auto-derivation.
48
+ */
49
+ readonly viewport: Sh3Viewport;
50
+ /**
51
+ * Compact-mode drawer surface controls. Inert on desktop — mutating
52
+ * methods throw rather than silently no-op so misuse is caught early.
53
+ */
54
+ readonly drawers: Sh3Drawers;
42
55
  /**
43
56
  * Dispatch `line` through a Terminal view's normal submit path. Used by
44
57
  * views outside a verb context (floating pickers, dialogs) to drive a
@@ -51,6 +64,29 @@ export interface Sh3 {
51
64
  */
52
65
  dispatchToTerminal(line: string): DispatchToTerminalResult;
53
66
  }
67
+ /**
68
+ * Compact-mode drawer surface controls. Mutating methods throw on desktop
69
+ * so misuse surfaces as a loud error instead of a silent no-op.
70
+ */
71
+ export interface Sh3Drawers {
72
+ readonly state: DrawerStateMap;
73
+ open(anchor: DrawerAnchor): void;
74
+ close(anchor: DrawerAnchor): void;
75
+ toggle(anchor: DrawerAnchor): void;
76
+ activate(anchor: DrawerAnchor, slotId: string): void;
77
+ }
78
+ /**
79
+ * Reactive viewport classification surface. See viewport/store.svelte.ts.
80
+ */
81
+ export interface Sh3Viewport {
82
+ /** Reactive — read directly inside an effect, or use `subscribe()`. */
83
+ readonly current: ViewportInfo;
84
+ subscribe(cb: (i: ViewportInfo) => void): () => void;
85
+ /** Pin the class. Pass null to restore auto. Debug/playground only. */
86
+ override(cls: ViewportClass | null): void;
87
+ /** Currently-pinned override (null = auto). */
88
+ readonly pinned: ViewportClass | null;
89
+ }
54
90
  /**
55
91
  * API for managing action bindings and triggering menus/palette
56
92
  * programmatically (e.g. from a future settings UI shard).
@@ -28,6 +28,8 @@ import { setUserBindings, getLiveDispatcherState, onActiveChange as onActiveChan
28
28
  import { listActions, onActionsChange } from './actions/registry';
29
29
  import { listActiveFromEntries } from './actions/listActive';
30
30
  import { makeDispatchToTerminal } from './shell-shard/dispatch-to-terminal';
31
+ import { viewportStore } from './viewport/store.svelte';
32
+ import { drawerStore } from './layout/compact/drawerStore.svelte';
31
33
  const sh3Actions = {
32
34
  async rebind(appId, actionId, shortcut) {
33
35
  await saveUserBinding(appId, actionId, shortcut);
@@ -77,4 +79,35 @@ export const sh3 = {
77
79
  color: colorApi,
78
80
  actions: sh3Actions,
79
81
  dispatchToTerminal: makeDispatchToTerminal({ headless: false }),
82
+ viewport: {
83
+ get current() { return viewportStore.current; },
84
+ subscribe: (cb) => viewportStore.subscribe(cb),
85
+ override: (cls) => viewportStore.override(cls),
86
+ get pinned() { return viewportStore.pinned; },
87
+ },
88
+ drawers: {
89
+ get state() { return drawerStore.state; },
90
+ open: (anchor) => {
91
+ assertCompact('open');
92
+ drawerStore.open(anchor);
93
+ },
94
+ close: (anchor) => {
95
+ assertCompact('close');
96
+ drawerStore.close(anchor);
97
+ },
98
+ toggle: (anchor) => {
99
+ assertCompact('toggle');
100
+ drawerStore.toggle(anchor);
101
+ },
102
+ activate: (anchor, slotId) => {
103
+ assertCompact('activate');
104
+ drawerStore.activate(anchor, slotId);
105
+ },
106
+ },
80
107
  };
108
+ function assertCompact(method) {
109
+ const cls = viewportStore.current.class;
110
+ if (cls !== 'compact') {
111
+ throw new Error(`Sh3.drawers.${method}: viewport class is "${cls}"; drawers exist only on compact`);
112
+ }
113
+ }
@@ -8,7 +8,7 @@ import type { Sh3Api } from '../verbs/types';
8
8
  import type { ShardContextKeys } from '../keys/types';
9
9
  import type { ContributionsApi } from '../contributions/types';
10
10
  import type { ActionsApi } from '../actions/types';
11
- import type { TreeRootRef } from '../layout/types';
11
+ import type { TreeRootRef, SlotRole } from '../layout/types';
12
12
  export { PERMISSION_KEYS_MINT, type ShardContextKeys, type ApiKeyPublic, type MintOpts, ScopeEscalationError, ConsentDeniedError } from '../keys/types';
13
13
  /**
14
14
  * The object returned by `ViewFactory.mount`. The framework calls
@@ -38,6 +38,14 @@ export interface ViewHandle {
38
38
  closable?: boolean | {
39
39
  canClose(): Promise<boolean>;
40
40
  };
41
+ /**
42
+ * View-level slot-role default. The compact renderer reads this when
43
+ * the containing slot's `role` is unset; slot-level always wins.
44
+ *
45
+ * Lets a view declare "I'm a sidebar by nature" without forcing the
46
+ * app author to know. See `layout/compact/resolveRole.ts`.
47
+ */
48
+ defaultRole?: SlotRole;
41
49
  }
42
50
  /**
43
51
  * Context passed to `ViewFactory.mount` so the view knows which layout
package/dist/tokens.css CHANGED
@@ -79,8 +79,9 @@
79
79
  * source of truth for the layer stack. No component outside the overlay
80
80
  * layer managers is permitted to write a z-index.
81
81
  */
82
- --sh3-z-layer-0: 0; /* docked layout (content area) */
83
- --sh3-z-layer-1: 100; /* floating panels (deferred) */
82
+ --sh3-z-layer-0: 0; /* docked layout (content area) */
83
+ --sh3-z-layer-drawers: 50; /* compact-mode drawer surfaces */
84
+ --sh3-z-layer-1: 100; /* floating panels (deferred) */
84
85
  --sh3-z-layer-2: 200; /* drag preview */
85
86
  --sh3-z-layer-3: 300; /* popups, context menus */
86
87
  --sh3-z-layer-4: 400; /* modals */
@@ -128,11 +128,14 @@ export interface Sh3Api {
128
128
  scrollback: ScrollbackEntry[];
129
129
  }>;
130
130
  /**
131
- * Read-only snapshot of every action registered across every shard. Pass
132
- * { activeOnly: true } to filter to currently-dispatchable actions.
131
+ * Read-only snapshot of every action registered across every shard.
132
+ * - `activeOnly`: filter to currently-dispatchable actions.
133
+ * - `submenuOf`: restrict to children of the named parent action id
134
+ * (mirrors the palette sub-drill filter).
133
135
  */
134
136
  listActions(opts?: {
135
137
  activeOnly?: boolean;
138
+ submenuOf?: string;
136
139
  }): ActionDescriptor[];
137
140
  /**
138
141
  * Programmatically dispatch a registered action by id. Same semantics as
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Auto-generated from package.json — do not edit manually. */
2
- export declare const VERSION = "0.17.0";
2
+ export declare const VERSION = "0.17.2";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Auto-generated from package.json — do not edit manually. */
2
- export const VERSION = '0.17.0';
2
+ export const VERSION = '0.17.2';
@@ -0,0 +1,8 @@
1
+ import type { ViewportClass } from './types';
2
+ export interface ClassifyInput {
3
+ width: number;
4
+ coarsePointer: boolean;
5
+ noHover: boolean;
6
+ dpr: number;
7
+ }
8
+ export declare function classify(i: ClassifyInput): ViewportClass;
@@ -0,0 +1,20 @@
1
+ /*
2
+ * classify — derives the viewport class from multi-signal input.
3
+ *
4
+ * Why multi-signal: a 6.7" phone reports ~393 CSS px wide *with* coarse
5
+ * pointer + high DPR; a narrow desktop window reports the same width
6
+ * *with* fine pointer + DPR 1-2. CSS pixels alone undercount physical
7
+ * compactness on high-DPI mobile, so width is one signal among three.
8
+ *
9
+ * The thresholds (720, 1100) are not load-bearing — they're tunable in
10
+ * a single place. Adjust with a corresponding test row.
11
+ */
12
+ export function classify(i) {
13
+ if (i.coarsePointer && i.noHover)
14
+ return 'compact';
15
+ if (i.width < 720)
16
+ return 'compact';
17
+ if (i.coarsePointer && i.dpr >= 2 && i.width < 1100)
18
+ return 'compact';
19
+ return 'desktop';
20
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,32 @@
1
+ /*
2
+ * Table-driven tests for the viewport classifier. Each row is a real
3
+ * device (or a deliberately-chosen edge case) with the signal tuple
4
+ * expected from a real browser/matchMedia in that device's typical
5
+ * orientation.
6
+ */
7
+ import { describe, it, expect } from 'vitest';
8
+ import { classify } from './classify';
9
+ const ROWS = [
10
+ { name: 'phone portrait', width: 393, coarsePointer: true, noHover: true, dpr: 3, expected: 'compact' },
11
+ { name: 'phone landscape', width: 852, coarsePointer: true, noHover: true, dpr: 3, expected: 'compact' },
12
+ { name: 'tablet portrait', width: 768, coarsePointer: true, noHover: true, dpr: 2, expected: 'compact' },
13
+ { name: 'tablet landscape', width: 1024, coarsePointer: true, noHover: true, dpr: 2, expected: 'compact' },
14
+ { name: 'desktop wide', width: 1920, coarsePointer: false, noHover: false, dpr: 1, expected: 'desktop' },
15
+ { name: 'desktop narrow window', width: 700, coarsePointer: false, noHover: false, dpr: 1, expected: 'compact' },
16
+ { name: 'iPad with mouse attached', width: 1024, coarsePointer: false, noHover: false, dpr: 2, expected: 'desktop' },
17
+ { name: 'small laptop', width: 1366, coarsePointer: false, noHover: false, dpr: 1, expected: 'desktop' },
18
+ { name: 'phone with stylus (hover ok)', width: 412, coarsePointer: true, noHover: false, dpr: 3, expected: 'compact' },
19
+ ];
20
+ describe('classify', () => {
21
+ for (const row of ROWS) {
22
+ it(`${row.name} → ${row.expected}`, () => {
23
+ const result = classify({
24
+ width: row.width,
25
+ coarsePointer: row.coarsePointer,
26
+ noHover: row.noHover,
27
+ dpr: row.dpr,
28
+ });
29
+ expect(result).toBe(row.expected);
30
+ });
31
+ }
32
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ /*
2
+ * Real-signal viewport classification — boots the viewport store under
3
+ * Chromium and asserts initial class + override behavior. happy-dom's
4
+ * matchMedia stub is shallow, so this is the contract pin against a
5
+ * real browser engine.
6
+ */
7
+ import { describe, it, expect, beforeEach } from 'vitest';
8
+ import { viewportStore } from './store.svelte';
9
+ beforeEach(() => {
10
+ viewportStore.__reset();
11
+ });
12
+ describe('viewport store under real browser', () => {
13
+ it('returns a viewport class consistent with the test runner window', () => {
14
+ const info = viewportStore.current;
15
+ expect(info.class).toMatch(/desktop|compact/);
16
+ expect(info.width).toBeGreaterThan(0);
17
+ expect(info.height).toBeGreaterThan(0);
18
+ });
19
+ it('override(compact) immediately flips class', () => {
20
+ const fires = [];
21
+ const unsub = viewportStore.subscribe((i) => fires.push(i.class));
22
+ viewportStore.override('compact');
23
+ expect(viewportStore.current.class).toBe('compact');
24
+ expect(fires).toContain('compact');
25
+ unsub();
26
+ });
27
+ it('override(null) restores auto-derived class', () => {
28
+ viewportStore.override('compact');
29
+ expect(viewportStore.pinned).toBe('compact');
30
+ viewportStore.override(null);
31
+ expect(viewportStore.pinned).toBeNull();
32
+ });
33
+ });
@@ -0,0 +1,9 @@
1
+ import type { ViewportClass, ViewportInfo } from './types';
2
+ export declare const viewportStore: {
3
+ readonly current: ViewportInfo;
4
+ subscribe(cb: (i: ViewportInfo) => void): () => void;
5
+ override(cls: ViewportClass | null): void;
6
+ readonly pinned: ViewportClass | null;
7
+ /** Test-only reset hook. Not exported from index.ts. */
8
+ __reset(): void;
9
+ };