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
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2
+ import { MemoryDocumentBackend } from '../documents/backends';
3
+ import { __setDocumentBackend, __setTenantId } from '../documents/config';
4
+ import { __setEnvServerUrl } from '../env/index';
5
+ import { registerShard, activateShard, __resetShardRegistryForTest, } from './activate.svelte';
6
+ import { __resetViewRegistryForTest } from './registry';
7
+ describe('ctx.fetch', () => {
8
+ let originalFetch;
9
+ beforeEach(() => {
10
+ originalFetch = globalThis.fetch;
11
+ __resetShardRegistryForTest();
12
+ __resetViewRegistryForTest();
13
+ __setDocumentBackend(new MemoryDocumentBackend());
14
+ __setTenantId('tenant-test');
15
+ __setEnvServerUrl('https://example.com');
16
+ });
17
+ afterEach(() => {
18
+ globalThis.fetch = originalFetch;
19
+ __setEnvServerUrl('');
20
+ });
21
+ it('resolves relative paths against the configured serverUrl', async () => {
22
+ const calls = [];
23
+ globalThis.fetch = vi.fn(async (input) => {
24
+ calls.push(String(input));
25
+ return new Response('ok');
26
+ });
27
+ let captured = null;
28
+ registerShard({
29
+ manifest: { id: 'test', label: 'test', version: '0.0.0', views: [] },
30
+ activate(ctx) { captured = ctx; },
31
+ });
32
+ await activateShard('test');
33
+ await captured.fetch('/api/foo');
34
+ expect(calls[0]).toBe('https://example.com/api/foo');
35
+ });
36
+ it('passes absolute URLs through unchanged', async () => {
37
+ const calls = [];
38
+ globalThis.fetch = vi.fn(async (input) => {
39
+ calls.push(String(input));
40
+ return new Response('ok');
41
+ });
42
+ let captured = null;
43
+ registerShard({
44
+ manifest: { id: 'test', label: 'test', version: '0.0.0', views: [] },
45
+ activate(ctx) { captured = ctx; },
46
+ });
47
+ await activateShard('test');
48
+ await captured.fetch('https://other.example.com/api/bar');
49
+ expect(calls[0]).toBe('https://other.example.com/api/bar');
50
+ });
51
+ it('prepends a slash to bare relative paths', async () => {
52
+ const calls = [];
53
+ globalThis.fetch = vi.fn(async (input) => {
54
+ calls.push(String(input));
55
+ return new Response('ok');
56
+ });
57
+ let captured = null;
58
+ registerShard({
59
+ manifest: { id: 'test', label: 'test', version: '0.0.0', views: [] },
60
+ activate(ctx) { captured = ctx; },
61
+ });
62
+ await activateShard('test');
63
+ await captured.fetch('api/baz');
64
+ expect(calls[0]).toBe('https://example.com/api/baz');
65
+ });
66
+ });
@@ -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
@@ -197,6 +205,19 @@ export interface ShardContext {
197
205
  registerVerb(verb: Verb): void;
198
206
  /** Obtain a file-oriented document handle scoped to this shard. */
199
207
  documents(options: DocumentHandleOptions): DocumentHandle;
208
+ /**
209
+ * Cross-origin-safe HTTP helper. Resolves relative `/api/...` paths
210
+ * against the configured serverUrl. In Tauri, routes through
211
+ * @tauri-apps/plugin-http (no browser CORS, cookies via reqwest).
212
+ * On web, behaves like `fetch` with `credentials: 'include'`.
213
+ *
214
+ * Server-shards must use this instead of global `fetch` to remain
215
+ * compatible with cross-origin clients (e.g. Tauri Android).
216
+ *
217
+ * @param path - Relative `/api/...` path or fully-qualified URL.
218
+ * @param init - Standard RequestInit.
219
+ */
220
+ fetch(path: string, init?: RequestInit): Promise<Response>;
200
221
  /**
201
222
  * Declare environment state for this shard and receive a hydrated snapshot.
202
223
  * Env state is server-authoritative, fetched once at activation, and
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 */
@@ -0,0 +1 @@
1
+ export declare function apiFetch(url: string, init?: RequestInit): Promise<Response>;
@@ -0,0 +1,65 @@
1
+ /*
2
+ * apiFetch — single source of truth for HTTP calls against the
3
+ * configured sh3-server, transparently using Tauri's plugin-http
4
+ * when running inside a Tauri webview.
5
+ *
6
+ * Browser fetch enforces CORS and respects SameSite cookie rules,
7
+ * which breaks cross-origin requests from a Tauri webview at e.g.
8
+ * `tauri://localhost` to a remote sh3-server. plugin-http (reqwest
9
+ * under the hood) is not subject to browser CORS and uses a
10
+ * per-host cookie store, so existing same-origin auth keeps working
11
+ * across origins.
12
+ *
13
+ * The plugin-http import is dynamic and behind try/catch — same
14
+ * defensive pattern as `platform/index.ts`. Vite code-splits it
15
+ * into a Tauri-only chunk that never loads in web builds.
16
+ */
17
+ import { getAuthToken } from './authToken';
18
+ let tauriFetch = null;
19
+ let tauriProbed = false;
20
+ function inTauriRuntime() {
21
+ // The plugin's fetch implementation calls into IPC via window.__TAURI__,
22
+ // so the *package being installed* is not enough — we have to be running
23
+ // inside a Tauri webview where these globals are injected.
24
+ if (typeof window === 'undefined')
25
+ return false;
26
+ return '__TAURI_INTERNALS__' in window || '__TAURI__' in window;
27
+ }
28
+ async function getTauriFetch() {
29
+ if (tauriProbed)
30
+ return tauriFetch;
31
+ tauriProbed = true;
32
+ if (!inTauriRuntime())
33
+ return null;
34
+ try {
35
+ // Static analysis is skipped so Vite/svelte-package don't try to bundle
36
+ // the optional Tauri-only dependency in pure-web builds.
37
+ const specifier = '@tauri-apps/plugin-http';
38
+ const mod = await import(/* @vite-ignore */ specifier);
39
+ tauriFetch = mod.fetch;
40
+ }
41
+ catch (_a) {
42
+ tauriFetch = null;
43
+ }
44
+ return tauriFetch;
45
+ }
46
+ export async function apiFetch(url, init) {
47
+ var _a;
48
+ // Inject Authorization: Bearer <session-token> if a session is active
49
+ // and the caller didn't already supply one. Cookies don't survive the
50
+ // cross-origin hop (SameSite=Lax + plugin-http has no cookie store),
51
+ // so the header carries the session for both transports uniformly.
52
+ const token = getAuthToken();
53
+ let finalInit = init !== null && init !== void 0 ? init : {};
54
+ if (token) {
55
+ const headers = new Headers((_a = finalInit.headers) !== null && _a !== void 0 ? _a : {});
56
+ if (!headers.has('Authorization')) {
57
+ headers.set('Authorization', `Bearer ${token}`);
58
+ finalInit = Object.assign(Object.assign({}, finalInit), { headers });
59
+ }
60
+ }
61
+ const tf = await getTauriFetch();
62
+ if (tf)
63
+ return tf(url, finalInit);
64
+ return fetch(url, Object.assign({ credentials: 'include' }, finalInit));
65
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2
+ describe('apiFetch', () => {
3
+ let originalFetch;
4
+ beforeEach(() => {
5
+ originalFetch = globalThis.fetch;
6
+ vi.resetModules();
7
+ });
8
+ afterEach(() => {
9
+ globalThis.fetch = originalFetch;
10
+ });
11
+ it('uses global fetch with credentials:include when Tauri plugin-http is unavailable', async () => {
12
+ const calls = [];
13
+ globalThis.fetch = vi.fn(async (input, init) => {
14
+ calls.push([input, init]);
15
+ return new Response('ok');
16
+ });
17
+ const { apiFetch } = await import('./apiFetch');
18
+ const res = await apiFetch('https://example.com/api/foo', { method: 'GET' });
19
+ expect(await res.text()).toBe('ok');
20
+ expect(calls).toHaveLength(1);
21
+ const [input, init] = calls[0];
22
+ expect(input).toBe('https://example.com/api/foo');
23
+ expect(init.credentials).toBe('include');
24
+ expect(init.method).toBe('GET');
25
+ });
26
+ it('lets caller-provided credentials override the default', async () => {
27
+ const calls = [];
28
+ globalThis.fetch = vi.fn(async (input, init) => {
29
+ calls.push([input, init]);
30
+ return new Response('ok');
31
+ });
32
+ const { apiFetch } = await import('./apiFetch');
33
+ await apiFetch('https://example.com/api/foo', { credentials: 'omit' });
34
+ const [, init] = calls[0];
35
+ expect(init.credentials).toBe('omit');
36
+ });
37
+ });
@@ -0,0 +1,2 @@
1
+ export declare function setAuthToken(value: string | null): void;
2
+ export declare function getAuthToken(): string | null;
@@ -0,0 +1,53 @@
1
+ /*
2
+ * Auth-token store used by `apiFetch` to attach `Authorization: Bearer
3
+ * <token>` to every request when a session exists.
4
+ *
5
+ * The browser auth flow relies on Set-Cookie + same-origin requests to
6
+ * keep the session alive. That breaks down for cross-origin Tauri
7
+ * clients (e.g. Android): the WebView's origin is `tauri://localhost`
8
+ * and the sh3-server lives on a different domain, so the session
9
+ * cookie is treated as cross-site and dropped under SameSite=Lax. On
10
+ * top of that, `@tauri-apps/plugin-http` (reqwest under the hood)
11
+ * doesn't enable a persistent cookie store by default, so even if
12
+ * SameSite weren't an issue cookies wouldn't survive between calls.
13
+ *
14
+ * Carrying the session token in a header sidesteps both problems.
15
+ * `auth.svelte.ts` populates this store after login/register/initFromBoot
16
+ * and clears it on logout. The token is mirrored to localStorage so an
17
+ * app restart doesn't dump the user back at the sign-in wall.
18
+ */
19
+ const KEY = 'sh3:authToken';
20
+ let token = null;
21
+ let restored = false;
22
+ function restore() {
23
+ if (restored)
24
+ return;
25
+ restored = true;
26
+ if (typeof localStorage === 'undefined')
27
+ return;
28
+ try {
29
+ token = localStorage.getItem(KEY);
30
+ }
31
+ catch (_a) {
32
+ token = null;
33
+ }
34
+ }
35
+ export function setAuthToken(value) {
36
+ restored = true;
37
+ token = value;
38
+ if (typeof localStorage === 'undefined')
39
+ return;
40
+ try {
41
+ if (value)
42
+ localStorage.setItem(KEY, value);
43
+ else
44
+ localStorage.removeItem(KEY);
45
+ }
46
+ catch (_a) {
47
+ // localStorage may be disabled (private browsing); in-memory copy still works.
48
+ }
49
+ }
50
+ export function getAuthToken() {
51
+ restore();
52
+ return token;
53
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it, beforeEach, vi } from 'vitest';
2
+ describe('authToken', () => {
3
+ beforeEach(() => {
4
+ vi.resetModules();
5
+ if (typeof localStorage !== 'undefined')
6
+ localStorage.clear();
7
+ });
8
+ it('returns null when nothing stored', async () => {
9
+ const { getAuthToken } = await import('./authToken');
10
+ expect(getAuthToken()).toBeNull();
11
+ });
12
+ it('round-trips set/get', async () => {
13
+ const { setAuthToken, getAuthToken } = await import('./authToken');
14
+ setAuthToken('sh3s_deadbeef');
15
+ expect(getAuthToken()).toBe('sh3s_deadbeef');
16
+ });
17
+ it('persists across module re-imports via localStorage', async () => {
18
+ const first = await import('./authToken');
19
+ first.setAuthToken('sh3s_persisted');
20
+ vi.resetModules();
21
+ const second = await import('./authToken');
22
+ expect(second.getAuthToken()).toBe('sh3s_persisted');
23
+ });
24
+ it('clear removes from storage', async () => {
25
+ const { setAuthToken, getAuthToken } = await import('./authToken');
26
+ setAuthToken('sh3s_x');
27
+ setAuthToken(null);
28
+ expect(getAuthToken()).toBeNull();
29
+ vi.resetModules();
30
+ const reloaded = await import('./authToken');
31
+ expect(reloaded.getAuthToken()).toBeNull();
32
+ });
33
+ });
@@ -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.19.0";
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.19.0';
@@ -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
+ };
@@ -0,0 +1,71 @@
1
+ /*
2
+ * viewport store — reactive ViewportInfo with subscribe + override.
3
+ *
4
+ * Subscribes to window.resize, screen.orientation.change, and matchMedia
5
+ * change events for `pointer: coarse` / `hover: none`. Re-runs classify(),
6
+ * notifies subscribers only on class change (not every resize tick).
7
+ *
8
+ * Module is loaded eagerly by createShell, but the listener-wiring branch
9
+ * is gated on `typeof window` so node-only test runs don't crash.
10
+ */
11
+ import { classify } from './classify';
12
+ let pinnedClass = $state(null);
13
+ const subscribers = new Set();
14
+ function read() {
15
+ if (typeof window === 'undefined') {
16
+ return { class: 'desktop', width: 1920, height: 1080, coarsePointer: false, noHover: false, dpr: 1 };
17
+ }
18
+ const width = window.innerWidth;
19
+ const height = window.innerHeight;
20
+ const coarsePointer = window.matchMedia('(pointer: coarse)').matches;
21
+ const noHover = window.matchMedia('(hover: none)').matches;
22
+ const dpr = window.devicePixelRatio;
23
+ const cls = pinnedClass !== null && pinnedClass !== void 0 ? pinnedClass : classify({ width, coarsePointer, noHover, dpr });
24
+ return { class: cls, width, height, coarsePointer, noHover, dpr };
25
+ }
26
+ let cached = $state(read());
27
+ function recompute() {
28
+ const next = read();
29
+ const prevClass = cached.class;
30
+ cached = next;
31
+ if (next.class !== prevClass) {
32
+ for (const cb of subscribers)
33
+ cb(next);
34
+ }
35
+ }
36
+ if (typeof window !== 'undefined') {
37
+ window.addEventListener('resize', recompute);
38
+ window.matchMedia('(pointer: coarse)').addEventListener('change', recompute);
39
+ window.matchMedia('(hover: none)').addEventListener('change', recompute);
40
+ if (typeof screen !== 'undefined' && screen.orientation) {
41
+ screen.orientation.addEventListener('change', recompute);
42
+ }
43
+ }
44
+ export const viewportStore = {
45
+ get current() {
46
+ return cached;
47
+ },
48
+ subscribe(cb) {
49
+ subscribers.add(cb);
50
+ return () => { subscribers.delete(cb); };
51
+ },
52
+ override(cls) {
53
+ if (pinnedClass === cls)
54
+ return;
55
+ pinnedClass = cls;
56
+ recompute();
57
+ // recompute only fires subscribers on class change. If override pins
58
+ // to the same class as the auto-derived one, the visible class doesn't
59
+ // change. Force-fire so consumers re-read the override state.
60
+ for (const cb of subscribers)
61
+ cb(cached);
62
+ },
63
+ get pinned() {
64
+ return pinnedClass;
65
+ },
66
+ /** Test-only reset hook. Not exported from index.ts. */
67
+ __reset() {
68
+ pinnedClass = null;
69
+ subscribers.clear();
70
+ },
71
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
1
+ /*
2
+ * DOM tests for the viewport store. happy-dom's matchMedia stub returns
3
+ * { matches: false, addEventListener, removeEventListener } so we can
4
+ * verify the wiring without driving the real browser engine.
5
+ *
6
+ * These tests intentionally use the store's __reset hook between cases
7
+ * to isolate subscriber state.
8
+ */
9
+ import { describe, it, expect, beforeEach } from 'vitest';
10
+ import { viewportStore } from './store.svelte';
11
+ describe('viewport store (dom)', () => {
12
+ beforeEach(() => {
13
+ viewportStore.__reset();
14
+ });
15
+ it('current returns a ViewportInfo with shape', () => {
16
+ const info = viewportStore.current;
17
+ expect(info.class).toMatch(/desktop|compact/);
18
+ expect(typeof info.width).toBe('number');
19
+ expect(typeof info.height).toBe('number');
20
+ });
21
+ it('override(compact) flips class to compact', () => {
22
+ viewportStore.override('compact');
23
+ expect(viewportStore.current.class).toBe('compact');
24
+ expect(viewportStore.pinned).toBe('compact');
25
+ });
26
+ it('override(null) restores pinned to null', () => {
27
+ viewportStore.override('compact');
28
+ viewportStore.override(null);
29
+ expect(viewportStore.pinned).toBeNull();
30
+ });
31
+ it('subscribers fire on class change via override', () => {
32
+ const fires = [];
33
+ const unsub = viewportStore.subscribe((i) => fires.push(i.class));
34
+ viewportStore.override('compact');
35
+ expect(fires).toContain('compact');
36
+ unsub();
37
+ });
38
+ it('subscribers fire on override even when class does not change', () => {
39
+ // Auto-class on happy-dom is desktop (default width 1024 + fine pointer).
40
+ // override('desktop') should still fire so consumers see pin state change.
41
+ const fires = [];
42
+ const unsub = viewportStore.subscribe((i) => fires.push(i.class));
43
+ viewportStore.override('desktop');
44
+ expect(fires.length).toBeGreaterThan(0);
45
+ unsub();
46
+ });
47
+ it('unsubscribe stops further notifications', () => {
48
+ const fires = [];
49
+ const unsub = viewportStore.subscribe((i) => fires.push(i.class));
50
+ unsub();
51
+ viewportStore.override('compact');
52
+ expect(fires).toEqual([]);
53
+ });
54
+ });
@@ -0,0 +1,9 @@
1
+ export type ViewportClass = 'desktop' | 'compact';
2
+ export interface ViewportInfo {
3
+ class: ViewportClass;
4
+ width: number;
5
+ height: number;
6
+ coarsePointer: boolean;
7
+ noHover: boolean;
8
+ dpr: number;
9
+ }
@@ -0,0 +1,6 @@
1
+ /*
2
+ * Viewport class types. The classifier (./classify.ts) is pure; the
3
+ * store (./store.svelte.ts) wires it to matchMedia and exposes a
4
+ * reactive ViewportInfo via Sh3.viewport.
5
+ */
6
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh3-core",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"