sh3-core 0.19.1 → 0.19.5

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 (84) hide show
  1. package/dist/Sh3.svelte +3 -1
  2. package/dist/actions/menuBarModel.js +8 -0
  3. package/dist/actions/menuBarModel.test.js +61 -0
  4. package/dist/api.d.ts +4 -0
  5. package/dist/api.js +3 -0
  6. package/dist/app/admin/ApiKeysView.svelte +6 -5
  7. package/dist/app/store/PermissionConfirmModal.svelte +23 -0
  8. package/dist/app/store/PermissionConfirmModal.svelte.d.ts +1 -0
  9. package/dist/app/store/StoreView.svelte +6 -1
  10. package/dist/chrome/CompactChrome.svelte +34 -1
  11. package/dist/chrome/CompactChrome.svelte.test.js +11 -6
  12. package/dist/chrome/FloatsSheet.svelte +236 -0
  13. package/dist/chrome/FloatsSheet.svelte.d.ts +7 -0
  14. package/dist/chrome/FloatsSheet.svelte.test.d.ts +1 -0
  15. package/dist/chrome/FloatsSheet.svelte.test.js +155 -0
  16. package/dist/env/client.d.ts +5 -4
  17. package/dist/env/client.js +11 -17
  18. package/dist/env/serverUrl.d.ts +2 -0
  19. package/dist/env/serverUrl.js +8 -0
  20. package/dist/gestures/index.d.ts +17 -0
  21. package/dist/gestures/index.js +27 -0
  22. package/dist/keys/client.js +6 -7
  23. package/dist/keys/revocation-bus.svelte.js +11 -1
  24. package/dist/layout/compact/CarouselTabs.svelte +150 -14
  25. package/dist/layout/compact/CarouselTabs.svelte.test.js +222 -2
  26. package/dist/layout/compact/CompactRenderer.svelte +9 -3
  27. package/dist/layout/compact/CompactRenderer.svelte.test.js +5 -3
  28. package/dist/layout/compact/derive.js +7 -16
  29. package/dist/layout/compact/derive.test.js +30 -9
  30. package/dist/layout/compact/rootStore.svelte.d.ts +20 -0
  31. package/dist/layout/compact/rootStore.svelte.js +59 -0
  32. package/dist/layout/compact/rootStore.svelte.test.d.ts +1 -0
  33. package/dist/layout/compact/rootStore.svelte.test.js +54 -0
  34. package/dist/layout/drag.svelte.js +16 -3
  35. package/dist/layout/floats.d.ts +27 -0
  36. package/dist/layout/floats.js +20 -0
  37. package/dist/layout/floats.test.js +34 -1
  38. package/dist/layout/inspection.d.ts +20 -9
  39. package/dist/layout/inspection.js +91 -13
  40. package/dist/layout/inspection.svelte.test.d.ts +1 -0
  41. package/dist/layout/inspection.svelte.test.js +163 -0
  42. package/dist/layout/store.schemaVersion.test.js +2 -2
  43. package/dist/layout/types.d.ts +11 -8
  44. package/dist/layout/types.js +1 -1
  45. package/dist/layout/types.test.js +2 -2
  46. package/dist/overlays/FloatFrame.svelte +93 -22
  47. package/dist/overlays/FloatLayer.svelte +12 -1
  48. package/dist/overlays/float.d.ts +7 -0
  49. package/dist/overlays/float.js +76 -6
  50. package/dist/overlays/float.test.js +170 -0
  51. package/dist/primitives/ResizableSplitter.svelte +42 -8
  52. package/dist/primitives/widgets/DocumentFilePicker.d.ts +25 -0
  53. package/dist/primitives/widgets/DocumentFilePicker.js +74 -0
  54. package/dist/primitives/widgets/DocumentFilePicker.svelte +144 -0
  55. package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +18 -0
  56. package/dist/primitives/widgets/DocumentOpener.svelte +36 -0
  57. package/dist/primitives/widgets/DocumentOpener.svelte.d.ts +17 -0
  58. package/dist/primitives/widgets/DocumentSaver.svelte +36 -0
  59. package/dist/primitives/widgets/DocumentSaver.svelte.d.ts +17 -0
  60. package/dist/primitives/widgets/_DocumentBrowser.svelte +337 -0
  61. package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +11 -0
  62. package/dist/registry/checkFetch.d.ts +6 -0
  63. package/dist/registry/checkFetch.js +23 -0
  64. package/dist/sh3/views/KeysAndPeers.svelte +4 -3
  65. package/dist/shards/activate-runtime.test.js +99 -1
  66. package/dist/shards/activate.svelte.js +12 -3
  67. package/dist/shards/registry.d.ts +8 -1
  68. package/dist/shards/registry.js +13 -2
  69. package/dist/shards/registry.test.js +25 -4
  70. package/dist/shards/types.d.ts +14 -1
  71. package/dist/shell-shard/ScrollbackView.svelte +145 -67
  72. package/dist/shell-shard/ScrollbackView.svelte.test.d.ts +1 -0
  73. package/dist/shell-shard/ScrollbackView.svelte.test.js +182 -0
  74. package/dist/shell-shard/dispatch-gating.test.js +38 -2
  75. package/dist/shell-shard/dispatch.js +9 -1
  76. package/dist/shell-shard/registry-resolve.test.js +50 -0
  77. package/dist/shell-shard/registry.d.ts +2 -1
  78. package/dist/shell-shard/registry.js +12 -2
  79. package/dist/shell-shard/verbs/help.js +5 -4
  80. package/dist/shell-shard/verbs/help.svelte.test.js +5 -2
  81. package/dist/verbs/types.d.ts +10 -5
  82. package/dist/version.d.ts +1 -1
  83. package/dist/version.js +1 -1
  84. package/package.json +1 -1
@@ -0,0 +1,155 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { tick } from 'svelte';
3
+ import { renderWithShell } from '../__test__/render';
4
+ import { resetFramework } from '../__test__/reset';
5
+ import { __resetLayoutStoreForTest, layoutStore, } from '../layout/store.svelte';
6
+ import { compactRootStore, __resetCompactRootStoreForTest, } from '../layout/compact/rootStore.svelte';
7
+ import FloatsSheet from './FloatsSheet.svelte';
8
+ function makeFloat(id, title) {
9
+ return {
10
+ id,
11
+ content: {
12
+ type: 'tabs',
13
+ tabs: [{ slotId: `s-${id}`, viewId: 'v', label: title }],
14
+ activeTab: 0,
15
+ },
16
+ position: { x: 0, y: 0 },
17
+ size: { w: 200, h: 200 },
18
+ title,
19
+ };
20
+ }
21
+ describe('FloatsSheet', () => {
22
+ beforeEach(() => {
23
+ resetFramework();
24
+ __resetCompactRootStoreForTest();
25
+ __resetLayoutStoreForTest();
26
+ });
27
+ afterEach(() => {
28
+ document.body.innerHTML = '';
29
+ });
30
+ it('renders the active-layout row even when no floats exist', async () => {
31
+ const { container } = renderWithShell(FloatsSheet, {
32
+ open: true,
33
+ onClose: () => { },
34
+ });
35
+ await tick();
36
+ const rows = container.querySelectorAll('[data-sh3-floats-row]');
37
+ expect(rows.length).toBe(1);
38
+ expect(rows[0].getAttribute('data-sh3-floats-row')).toBe('docked');
39
+ });
40
+ it('lists one row per non-dismissable float, excluding pickers', async () => {
41
+ layoutStore.tree.floats.push(makeFloat('f-1', 'Notes'));
42
+ layoutStore.tree.floats.push(makeFloat('f-2', 'Editor'));
43
+ layoutStore.tree.floats.push(Object.assign(Object.assign({}, makeFloat('f-3', 'Picker')), { dismissable: true }));
44
+ const { container } = renderWithShell(FloatsSheet, {
45
+ open: true,
46
+ onClose: () => { },
47
+ });
48
+ await tick();
49
+ const rows = container.querySelectorAll('[data-sh3-floats-row]');
50
+ expect(rows.length).toBe(3); // docked + 2 non-dismissable
51
+ const ids = Array.from(rows).map((r) => r.getAttribute('data-sh3-floats-row'));
52
+ expect(ids).toEqual(['docked', 'f-1', 'f-2']);
53
+ });
54
+ it('tapping a float row sets compactRootStore + calls onClose', async () => {
55
+ layoutStore.tree.floats.push(makeFloat('f-9', 'Notes'));
56
+ let closed = false;
57
+ const { container } = renderWithShell(FloatsSheet, {
58
+ open: true,
59
+ onClose: () => { closed = true; },
60
+ });
61
+ await tick();
62
+ const row = container.querySelector('[data-sh3-floats-row="f-9"]');
63
+ row.click();
64
+ expect(compactRootStore.current).toEqual({ kind: 'float', floatId: 'f-9' });
65
+ expect(closed).toBe(true);
66
+ });
67
+ it('tapping the docked row resets compactRootStore + calls onClose', async () => {
68
+ layoutStore.tree.floats.push(makeFloat('f-10', 'Notes'));
69
+ compactRootStore.setRoot({ kind: 'float', floatId: 'f-10' });
70
+ let closed = false;
71
+ const { container } = renderWithShell(FloatsSheet, {
72
+ open: true,
73
+ onClose: () => { closed = true; },
74
+ });
75
+ await tick();
76
+ const row = container.querySelector('[data-sh3-floats-row="docked"]');
77
+ row.click();
78
+ expect(compactRootStore.current).toEqual({ kind: 'docked' });
79
+ expect(closed).toBe(true);
80
+ });
81
+ it('marks the current row with data-current="true"', async () => {
82
+ layoutStore.tree.floats.push(makeFloat('f-11', 'Notes'));
83
+ compactRootStore.setRoot({ kind: 'float', floatId: 'f-11' });
84
+ const { container } = renderWithShell(FloatsSheet, {
85
+ open: true,
86
+ onClose: () => { },
87
+ });
88
+ await tick();
89
+ const cur = container.querySelector('[data-current="true"]');
90
+ expect(cur === null || cur === void 0 ? void 0 : cur.getAttribute('data-sh3-floats-row')).toBe('f-11');
91
+ });
92
+ });
93
+ import { floatManager, bindFloatStore, __resetFloatManagerForTest, } from '../overlays/float';
94
+ function fakePointer(type, x, y, id = 1) {
95
+ const ev = new Event(type, { bubbles: true, cancelable: true });
96
+ Object.assign(ev, { pointerId: id, clientX: x, clientY: y, pointerType: 'touch', button: 0 });
97
+ return ev;
98
+ }
99
+ describe('FloatsSheet — swipe to close', () => {
100
+ beforeEach(() => {
101
+ resetFramework();
102
+ __resetCompactRootStoreForTest();
103
+ __resetLayoutStoreForTest();
104
+ __resetFloatManagerForTest();
105
+ bindFloatStore(layoutStore.floats, () => ({ w: 360, h: 740 }));
106
+ });
107
+ afterEach(() => {
108
+ document.body.innerHTML = '';
109
+ });
110
+ it('swiping a float row past 40% width closes that float', async () => {
111
+ const id = floatManager.open('test:view', { title: 'Notes' });
112
+ expect(layoutStore.floats.find((f) => f.id === id)).toBeTruthy();
113
+ const { container } = renderWithShell(FloatsSheet, {
114
+ open: true,
115
+ onClose: () => { },
116
+ });
117
+ await tick();
118
+ const row = container.querySelector(`[data-sh3-floats-row="${id}"]`);
119
+ Object.defineProperty(row, 'clientWidth', { configurable: true, value: 360 });
120
+ row.dispatchEvent(fakePointer('pointerdown', 10, 10, 1));
121
+ document.dispatchEvent(fakePointer('pointermove', 200, 10, 1));
122
+ document.dispatchEvent(fakePointer('pointerup', 200, 10, 1));
123
+ await tick();
124
+ expect(layoutStore.floats.find((f) => f.id === id)).toBeUndefined();
125
+ });
126
+ it('does not let the docked row be swiped (no throw, no state change)', async () => {
127
+ const { container } = renderWithShell(FloatsSheet, {
128
+ open: true,
129
+ onClose: () => { },
130
+ });
131
+ await tick();
132
+ const row = container.querySelector('[data-sh3-floats-row="docked"]');
133
+ Object.defineProperty(row, 'clientWidth', { configurable: true, value: 360 });
134
+ row.dispatchEvent(fakePointer('pointerdown', 10, 10, 2));
135
+ document.dispatchEvent(fakePointer('pointermove', 300, 10, 2));
136
+ document.dispatchEvent(fakePointer('pointerup', 300, 10, 2));
137
+ await tick();
138
+ expect(compactRootStore.current).toEqual({ kind: 'docked' });
139
+ });
140
+ it('swiping less than 40% width does not close', async () => {
141
+ const id = floatManager.open('test:view', { title: 'Notes' });
142
+ const { container } = renderWithShell(FloatsSheet, {
143
+ open: true,
144
+ onClose: () => { },
145
+ });
146
+ await tick();
147
+ const row = container.querySelector(`[data-sh3-floats-row="${id}"]`);
148
+ Object.defineProperty(row, 'clientWidth', { configurable: true, value: 360 });
149
+ row.dispatchEvent(fakePointer('pointerdown', 10, 10, 3));
150
+ document.dispatchEvent(fakePointer('pointermove', 50, 10, 3));
151
+ document.dispatchEvent(fakePointer('pointerup', 50, 10, 3));
152
+ await tick();
153
+ expect(layoutStore.floats.find((f) => f.id === id)).toBeTruthy();
154
+ });
155
+ });
@@ -2,10 +2,7 @@
2
2
  * Env state client — fetches and updates per-shard environment state
3
3
  * from the server.
4
4
  */
5
- /** Configure the server URL for env state operations. */
6
- export declare function __setEnvServerUrl(url: string): void;
7
- /** Return the configured server URL. */
8
- export declare function getEnvServerUrl(): string;
5
+ export { getEnvServerUrl, __setEnvServerUrl } from './serverUrl';
9
6
  /**
10
7
  * Fetch env state for a shard from the server.
11
8
  * Returns an empty object if the server has no stored state.
@@ -24,6 +21,10 @@ export interface ServerInstallResult {
24
21
  missing?: Array<{
25
22
  id: string;
26
23
  }>;
24
+ warnings?: Array<{
25
+ level: 'warn';
26
+ message: string;
27
+ }>;
27
28
  }
28
29
  /**
29
30
  * Install a package on the server via multipart upload.
@@ -4,22 +4,14 @@
4
4
  */
5
5
  import { getAuthHeader, isAdmin } from '../auth/index';
6
6
  import { apiFetch } from '../transport/apiFetch';
7
- /** Server base URL, set once during configuration. */
8
- let serverUrl = '';
9
- /** Configure the server URL for env state operations. */
10
- export function __setEnvServerUrl(url) {
11
- serverUrl = url;
12
- }
13
- /** Return the configured server URL. */
14
- export function getEnvServerUrl() {
15
- return serverUrl;
16
- }
7
+ import { getEnvServerUrl } from './serverUrl';
8
+ export { getEnvServerUrl, __setEnvServerUrl } from './serverUrl';
17
9
  /**
18
10
  * Fetch env state for a shard from the server.
19
11
  * Returns an empty object if the server has no stored state.
20
12
  */
21
13
  export async function fetchEnvState(shardId) {
22
- const res = await apiFetch(`${serverUrl}/api/env-state/${encodeURIComponent(shardId)}`, {
14
+ const res = await apiFetch(`${getEnvServerUrl()}/api/env-state/${encodeURIComponent(shardId)}`, {
23
15
  credentials: 'omit',
24
16
  });
25
17
  if (!res.ok) {
@@ -41,7 +33,7 @@ export async function putEnvState(shardId, state) {
41
33
  const headers = { 'Content-Type': 'application/json' };
42
34
  if (auth)
43
35
  headers['Authorization'] = auth;
44
- const res = await apiFetch(`${serverUrl}/api/env-state/${encodeURIComponent(shardId)}`, {
36
+ const res = await apiFetch(`${getEnvServerUrl()}/api/env-state/${encodeURIComponent(shardId)}`, {
45
37
  method: 'PUT',
46
38
  headers,
47
39
  body: JSON.stringify(state),
@@ -65,6 +57,7 @@ export async function putEnvState(shardId, state) {
65
57
  * back server-side.
66
58
  */
67
59
  export async function serverInstallPackage(manifest, clientBundle, serverBundle) {
60
+ var _a;
68
61
  if (!isAdmin())
69
62
  throw new Error('Cannot install: not elevated to admin');
70
63
  const auth = getAuthHeader();
@@ -77,7 +70,7 @@ export async function serverInstallPackage(manifest, clientBundle, serverBundle)
77
70
  const headers = {};
78
71
  if (auth)
79
72
  headers['Authorization'] = auth;
80
- const res = await apiFetch(`${serverUrl}/api/packages/install`, {
73
+ const res = await apiFetch(`${getEnvServerUrl()}/api/packages/install`, {
81
74
  method: 'POST',
82
75
  headers,
83
76
  body: form,
@@ -88,7 +81,7 @@ export async function serverInstallPackage(manifest, clientBundle, serverBundle)
88
81
  try {
89
82
  body = await res.json();
90
83
  }
91
- catch ( /* non-JSON */_a) { /* non-JSON */ }
84
+ catch ( /* non-JSON */_b) { /* non-JSON */ }
92
85
  return {
93
86
  ok: false,
94
87
  error: typeof body.error === 'string' ? body.error : `HTTP ${res.status}`,
@@ -96,7 +89,8 @@ export async function serverInstallPackage(manifest, clientBundle, serverBundle)
96
89
  missing: Array.isArray(body.missing) ? body.missing : undefined,
97
90
  };
98
91
  }
99
- return { ok: true };
92
+ const body = await res.json();
93
+ return { ok: true, warnings: (_a = body.warnings) !== null && _a !== void 0 ? _a : [] };
100
94
  }
101
95
  /**
102
96
  * Uninstall a package from the server.
@@ -109,7 +103,7 @@ export async function serverUninstallPackage(id) {
109
103
  const headers = { 'Content-Type': 'application/json' };
110
104
  if (auth)
111
105
  headers['Authorization'] = auth;
112
- const res = await apiFetch(`${serverUrl}/api/packages/uninstall`, {
106
+ const res = await apiFetch(`${getEnvServerUrl()}/api/packages/uninstall`, {
113
107
  method: 'POST',
114
108
  headers,
115
109
  body: JSON.stringify({ id }),
@@ -124,7 +118,7 @@ export async function serverUninstallPackage(id) {
124
118
  * Fetch the list of packages installed on the server.
125
119
  */
126
120
  export async function fetchServerPackages() {
127
- const res = await apiFetch(`${serverUrl}/api/packages`, { credentials: 'omit' });
121
+ const res = await apiFetch(`${getEnvServerUrl()}/api/packages`, { credentials: 'omit' });
128
122
  if (!res.ok)
129
123
  return [];
130
124
  return await res.json();
@@ -0,0 +1,2 @@
1
+ export declare function __setEnvServerUrl(url: string): void;
2
+ export declare function getEnvServerUrl(): string;
@@ -0,0 +1,8 @@
1
+ /** Server base URL, set once during shell initialization. */
2
+ let serverUrl = '';
3
+ export function __setEnvServerUrl(url) {
4
+ serverUrl = url;
5
+ }
6
+ export function getEnvServerUrl() {
7
+ return serverUrl;
8
+ }
@@ -4,3 +4,20 @@ export type { GestureRegistry } from './gestureRegistry';
4
4
  export type { GestureHandle, GestureOptions, GestureType, Axis, ClaimPriority, ClaimEntry, PanEvent, ScrollEvent, ButtonEvent, } from './types';
5
5
  /** Internal utility — used by framework gesture sites. Not re-exported via api.ts. */
6
6
  export declare function ancestorCount(el: Element): number;
7
+ /**
8
+ * Width of the reserved gutter at each side edge, in CSS pixels.
9
+ * Pointer-downs that land within this strip of either side of a swipe-aware
10
+ * surface (carousel, future side drawers) initiate the gesture unconditionally
11
+ * — content-specific bailouts (editable target, native horizontal scroll) are
12
+ * suppressed. Outside the gutter, those bailouts still apply so taps on
13
+ * inputs and horizontally-scrollable regions behave normally.
14
+ */
15
+ export declare const EDGE_PX = 24;
16
+ /**
17
+ * Always-on diagnostic for premature gesture-end paths (claim stolen, foreign
18
+ * pointercancel, foreign pointerup, etc.). The log only fires on anomalies, so
19
+ * a healthy drag is silent in production. Include `pointerType` so the user
20
+ * can tell touch from mouse from pen at a glance — the touch-only auto-release
21
+ * bug class doesn't reproduce with a mouse.
22
+ */
23
+ export declare function logGesture(label: string, ev: PointerEvent | null, extra?: Record<string, unknown>): void;
@@ -10,3 +10,30 @@ export function ancestorCount(el) {
10
10
  }
11
11
  return n;
12
12
  }
13
+ /**
14
+ * Width of the reserved gutter at each side edge, in CSS pixels.
15
+ * Pointer-downs that land within this strip of either side of a swipe-aware
16
+ * surface (carousel, future side drawers) initiate the gesture unconditionally
17
+ * — content-specific bailouts (editable target, native horizontal scroll) are
18
+ * suppressed. Outside the gutter, those bailouts still apply so taps on
19
+ * inputs and horizontally-scrollable regions behave normally.
20
+ */
21
+ export const EDGE_PX = 24;
22
+ /**
23
+ * Always-on diagnostic for premature gesture-end paths (claim stolen, foreign
24
+ * pointercancel, foreign pointerup, etc.). The log only fires on anomalies, so
25
+ * a healthy drag is silent in production. Include `pointerType` so the user
26
+ * can tell touch from mouse from pen at a glance — the touch-only auto-release
27
+ * bug class doesn't reproduce with a mouse.
28
+ */
29
+ export function logGesture(label, ev, extra) {
30
+ var _a, _b;
31
+ if (typeof console === 'undefined')
32
+ return;
33
+ const tgt = ev === null || ev === void 0 ? void 0 : ev.target;
34
+ const tag = (_a = tgt === null || tgt === void 0 ? void 0 : tgt.tagName) !== null && _a !== void 0 ? _a : '-';
35
+ const raw = (_b = tgt === null || tgt === void 0 ? void 0 : tgt.className) !== null && _b !== void 0 ? _b : '';
36
+ const cls = typeof raw === 'string' ? raw : '';
37
+ // eslint-disable-next-line no-console
38
+ console.log('[sh3:gesture]', label, Object.assign({ pointerId: ev === null || ev === void 0 ? void 0 : ev.pointerId, pointerType: ev === null || ev === void 0 ? void 0 : ev.pointerType, type: ev === null || ev === void 0 ? void 0 : ev.type, target: cls ? `${tag}.${cls}` : tag }, extra));
39
+ }
@@ -9,6 +9,8 @@
9
9
  import { ConsentDeniedError, ScopeEscalationError } from './types';
10
10
  import { requestConsent } from './consent.svelte';
11
11
  import { emit } from './revocation-bus.svelte';
12
+ import { apiFetch } from '../transport/apiFetch';
13
+ import { getEnvServerUrl } from '../env/serverUrl';
12
14
  export function createShardKeysApi(params) {
13
15
  const { shardId, shardPermissions } = params;
14
16
  const assertScopesSubset = (scopes) => {
@@ -24,18 +26,16 @@ export function createShardKeysApi(params) {
24
26
  const approved = await requestConsent(shardId, opts);
25
27
  if (!approved)
26
28
  throw new ConsentDeniedError();
27
- const ticketRes = await fetch('/api/keys/consent', {
29
+ const ticketRes = await apiFetch(`${getEnvServerUrl()}/api/keys/consent`, {
28
30
  method: 'POST',
29
- credentials: 'include',
30
31
  headers: { 'content-type': 'application/json' },
31
32
  body: JSON.stringify(Object.assign({ shardId }, opts)),
32
33
  });
33
34
  if (!ticketRes.ok)
34
35
  throw new Error(`Consent ticket failed: ${ticketRes.status}`);
35
36
  const { ticket } = await ticketRes.json();
36
- const mintRes = await fetch('/api/keys', {
37
+ const mintRes = await apiFetch(`${getEnvServerUrl()}/api/keys`, {
37
38
  method: 'POST',
38
- credentials: 'include',
39
39
  headers: { 'content-type': 'application/json' },
40
40
  body: JSON.stringify({ ticket }),
41
41
  });
@@ -44,16 +44,15 @@ export function createShardKeysApi(params) {
44
44
  return mintRes.json();
45
45
  },
46
46
  async list() {
47
- const res = await fetch('/api/keys', { credentials: 'include' });
47
+ const res = await apiFetch(`${getEnvServerUrl()}/api/keys`);
48
48
  if (!res.ok)
49
49
  throw new Error(`List failed: ${res.status}`);
50
50
  const all = (await res.json());
51
51
  return all.filter((k) => k.mintedByShardId === shardId);
52
52
  },
53
53
  async revoke(id) {
54
- const res = await fetch(`/api/keys/${encodeURIComponent(id)}`, {
54
+ const res = await apiFetch(`${getEnvServerUrl()}/api/keys/${encodeURIComponent(id)}`, {
55
55
  method: 'DELETE',
56
- credentials: 'include',
57
56
  });
58
57
  if (!res.ok && res.status !== 404)
59
58
  throw new Error(`Revoke failed: ${res.status}`);
@@ -6,6 +6,8 @@
6
6
  * The bus is populated by a server-sent events stream on /api/keys/events
7
7
  * (wired by the sh3 runtime at boot) and/or by local revoke() calls.
8
8
  */
9
+ import { getEnvServerUrl } from '../env/serverUrl';
10
+ import { getAuthToken } from '../transport/authToken';
9
11
  const handlersByShard = new Map();
10
12
  /**
11
13
  * Recently-emitted (shardId → Set<keyId>) with per-entry TTL timers.
@@ -78,7 +80,15 @@ export function emit(shardId, keyId) {
78
80
  export function startServerSideStream() {
79
81
  if (typeof EventSource === 'undefined')
80
82
  return () => { };
81
- const es = new EventSource('/api/keys/events', { withCredentials: true });
83
+ // EventSource cannot send custom headers, so cross-origin auth (Tauri remote)
84
+ // is handled by passing the session token as a query param. Same-origin
85
+ // builds fall back to cookies via withCredentials.
86
+ const base = getEnvServerUrl();
87
+ const token = getAuthToken();
88
+ const url = token
89
+ ? `${base}/api/keys/events?token=${encodeURIComponent(token)}`
90
+ : `${base}/api/keys/events`;
91
+ const es = new EventSource(url, token ? {} : { withCredentials: true });
82
92
  es.onmessage = (msg) => {
83
93
  try {
84
94
  const ev = JSON.parse(msg.data);
@@ -19,7 +19,7 @@
19
19
  import SlotContainer from '../SlotContainer.svelte';
20
20
  import SlotDropZone from '../SlotDropZone.svelte';
21
21
  import { claim, revoke, isOwner } from '../../gestures/pointerClaim';
22
- import { ancestorCount } from '../../gestures';
22
+ import { ancestorCount, EDGE_PX, logGesture } from '../../gestures';
23
23
 
24
24
  let {
25
25
  node,
@@ -62,6 +62,27 @@
62
62
  let dragging = $state(false);
63
63
  let claimed = $state(false);
64
64
  let activePointerId: number | null = null;
65
+ /**
66
+ * Set to true when the pointer-down landed inside the left/right edge
67
+ * gutter. Gutter-initiated drags get the "invincible" treatment: once
68
+ * the axis-lock threshold is crossed we explicitly call
69
+ * `setPointerCapture` on the carousel container so the descendant the
70
+ * touch originally landed on (textarea, scroll region, preview canvas
71
+ * — see the earlier `cancel-our-id` log targets) no longer receives the
72
+ * pointer stream and therefore cannot fire `pointercancel` to abort us.
73
+ *
74
+ * Mid-track drags don't get capture — the documented contract is that
75
+ * a descendant claiming the gesture there wins.
76
+ */
77
+ let startedInGutter = false;
78
+ /**
79
+ * Soft window (`performance.now()` deadline) during which a single
80
+ * `pointercancel` is treated as the synthetic Android transfer-cancel
81
+ * that fires when `setPointerCapture` moves implicit capture from a
82
+ * descendant up to the container. Without this filter, the carousel's
83
+ * own capture call would immediately abort itself.
84
+ */
85
+ let ignoreCancelUntil = 0;
65
86
 
66
87
  type PointerSnapshot = { id: number; x: number; y: number; t: number };
67
88
  let downSnap: PointerSnapshot | null = null;
@@ -72,6 +93,16 @@
72
93
  const COMMIT_FRACTION = 0.4;
73
94
  const COMMIT_VELOCITY = 500;
74
95
  const RUBBER_BAND_MAX_FRACTION = 0.3;
96
+ /** Window (ms) during which the post-transfer pointercancel is swallowed. */
97
+ const TRANSFER_CANCEL_WINDOW_MS = 100;
98
+
99
+ function dbg(label: string, ev: PointerEvent | null): void {
100
+ logGesture(`carousel:${label}`, ev, {
101
+ claimed,
102
+ dragging,
103
+ downId: downSnap?.id,
104
+ });
105
+ }
75
106
  /**
76
107
  * If the pointer takes longer than this to cross the axis-lock threshold,
77
108
  * treat the gesture as text-selection intent (slow drag over text) and
@@ -80,16 +111,45 @@
80
111
  */
81
112
  const SLOW_DRAG_TIMEOUT_MS = 250;
82
113
 
114
+ /**
115
+ * True only when an ancestor has scrollable horizontal overflow that
116
+ * *actually* overflows. `overflow-x: auto` on a wrapper that doesn't
117
+ * exceed its clientWidth used to disqualify every swipe inside it — a
118
+ * common false positive for body views that set `overflow: auto` to get
119
+ * vertical scrolling. We now also require `scrollWidth > clientWidth`
120
+ * so only genuinely scrollable regions block the carousel.
121
+ */
83
122
  function hasNativeHorizontalScroll(target: EventTarget | null): boolean {
84
123
  let el = target as HTMLElement | null;
85
124
  while (el && el !== containerEl) {
86
125
  const ox = getComputedStyle(el).overflowX;
87
- if (ox === 'auto' || ox === 'scroll') return true;
126
+ if ((ox === 'auto' || ox === 'scroll') && el.scrollWidth > el.clientWidth) {
127
+ return true;
128
+ }
88
129
  el = el.parentElement;
89
130
  }
90
131
  return false;
91
132
  }
92
133
 
134
+ /**
135
+ * True when the pointer-down x sits in the left or right EDGE_PX strip of
136
+ * the carousel container. Edge-gutter pointer-downs claim the swipe
137
+ * regardless of editable-target or native-horizontal-scroll content
138
+ * underneath — the gutter is documented as an unconditional swipe
139
+ * initiation zone. Without this, any input or `overflow: auto` region
140
+ * touching the edge silently kills edge swipes.
141
+ */
142
+ function isInEdgeGutter(clientX: number): boolean {
143
+ if (!containerEl || containerWidth === 0) return false;
144
+ // rect.left for the offset, containerWidth (reactive, ResizeObserver-fed)
145
+ // for the width — getBoundingClientRect().width is 0 under happy-dom and
146
+ // similar non-layout DOMs, while containerWidth is seeded from the
147
+ // ResizeObserver path used everywhere else in this component.
148
+ const rect = containerEl.getBoundingClientRect();
149
+ const local = clientX - rect.left;
150
+ return local < EDGE_PX || local > containerWidth - EDGE_PX;
151
+ }
152
+
93
153
  function isEditableTarget(target: EventTarget | null): boolean {
94
154
  const el = target as HTMLElement | null;
95
155
  if (!el || typeof el.tagName !== 'string') return false;
@@ -118,16 +178,28 @@
118
178
  }
119
179
 
120
180
  function onPointerDown(ev: PointerEvent) {
121
- if (isEditableTarget(ev.target)) return;
122
- if (hasNativeHorizontalScroll(ev.target)) return;
181
+ // Structural bails — apply regardless of where the touch lands. Without
182
+ // these the carousel can't even set up a meaningful gesture.
123
183
  if (tabCount < 2) return;
124
184
  if (containerWidth === 0) return;
125
185
  // Multi-touch (pinch-zoom etc.) is not a swipe — bail.
126
186
  if (ev.isPrimary === false) return;
187
+ // Edge gutter override: pointer-downs in the left/right EDGE_PX strip
188
+ // claim the swipe unconditionally. This is the published invariant for
189
+ // edge initiation; bailing here would re-introduce the bug where any
190
+ // editable target or overflow-auto wrapper sitting near the screen edge
191
+ // silently kills the gesture.
192
+ const inGutter = isInEdgeGutter(ev.clientX);
193
+ if (!inGutter) {
194
+ if (isEditableTarget(ev.target)) return;
195
+ if (hasNativeHorizontalScroll(ev.target)) return;
196
+ }
127
197
  const depth = containerEl ? ancestorCount(containerEl) : 0;
128
198
  const claimGranted = claim(ev.pointerId, { ownerId: 'sh3:carousel', axis: 'x', priority: 'normal', depth });
129
199
  if (!claimGranted) return;
130
200
  activePointerId = ev.pointerId;
201
+ startedInGutter = inGutter;
202
+ ignoreCancelUntil = 0;
131
203
  downSnap = { id: ev.pointerId, x: ev.clientX, y: ev.clientY, t: performance.now() };
132
204
  lastSnap = { ...downSnap };
133
205
  dragging = false;
@@ -140,7 +212,11 @@
140
212
 
141
213
  function onPointerMove(ev: PointerEvent) {
142
214
  if (!downSnap || ev.pointerId !== downSnap.id) return;
143
- if (!isOwner(ev.pointerId, 'sh3:carousel')) { endGesture(); return; }
215
+ if (!isOwner(ev.pointerId, 'sh3:carousel')) {
216
+ dbg('claim-stolen', ev);
217
+ endGesture();
218
+ return;
219
+ }
144
220
  const dx = ev.clientX - downSnap.x;
145
221
  const dy = ev.clientY - downSnap.y;
146
222
  if (!claimed) {
@@ -159,12 +235,29 @@
159
235
  if (!horizDominates) return;
160
236
  claimed = true;
161
237
  dragging = true;
162
- // Don't call setPointerCapture: transferring capture from a
163
- // descendant that had implicit capture (a button the finger
164
- // touched) makes Android fire pointercancel on the original
165
- // target, which our document-level cancel listener would then
166
- // treat as an abort. The pointercancel-aborts-no-commit logic
167
- // is enough on its own to fix the auto-commit bug.
238
+ // Gutter-initiated drags get the "invincible" guarantee: transfer
239
+ // implicit pointer capture from the descendant the touch landed on
240
+ // (a TEXTAREA, scroll region, etc.) up to the carousel container so
241
+ // those descendants can no longer fire pointercancel mid-pan. The
242
+ // transfer itself fires one synthetic pointercancel on Android (see
243
+ // CarouselTabs commit 638d75a for the original incident); the
244
+ // ignoreCancelUntil window below swallows just that one cancel —
245
+ // every other cancel still aborts as before.
246
+ //
247
+ // Mid-track drags intentionally skip this: the contract there is
248
+ // that a descendant claiming the gesture wins, matching the
249
+ // "carousel aborts if it's caught by an inner element" behavior
250
+ // the rest of the bail logic already encodes.
251
+ if (startedInGutter && containerEl) {
252
+ try {
253
+ containerEl.setPointerCapture(ev.pointerId);
254
+ ignoreCancelUntil = performance.now() + TRANSFER_CANCEL_WINDOW_MS;
255
+ } catch {
256
+ // setPointerCapture can throw if the pointer is no longer
257
+ // active (race with platform-level cancel). Safe to swallow —
258
+ // the gesture will run on implicit capture as before.
259
+ }
260
+ }
168
261
  // Clear any selection that began during the pre-claim window so
169
262
  // it doesn't visually streak across the slide while dragging.
170
263
  if (typeof window !== 'undefined') {
@@ -177,8 +270,14 @@
177
270
  }
178
271
 
179
272
  function onPointerUp(ev: PointerEvent) {
180
- if (!downSnap || ev.pointerId !== downSnap.id) {
181
- endGesture();
273
+ if (!downSnap) return;
274
+ // Filter by pointer id. A pointerup for a different pointer (e.g. a
275
+ // second finger lifting, a stylus releasing while a finger drag is
276
+ // active, or a phantom multi-pointer) must NOT tear down our gesture —
277
+ // earlier behavior here was to call endGesture() on any unrelated
278
+ // pointerup, which dropped legitimate drags on multi-touch devices.
279
+ if (ev.pointerId !== downSnap.id) {
280
+ dbg('pointerup-other-id', ev);
182
281
  return;
183
282
  }
184
283
  const dx = ev.clientX - downSnap.x;
@@ -198,12 +297,47 @@
198
297
  * mid-drag would be read as a release at the current dx and trip
199
298
  * the commit threshold, "auto-completing" the swipe with the
200
299
  * finger still down.
300
+ *
301
+ * MUST filter by pointer id. The previous unconditional implementation
302
+ * meant any pointercancel — palm contact, ghost touches, a stylus
303
+ * cancellation while a finger drag was active — terminated the carousel
304
+ * gesture. That's the most likely cause of "drag releases mid-pan on
305
+ * some screens" reports.
201
306
  */
202
- function onPointerCancel(_ev: PointerEvent) {
307
+ function onPointerCancel(ev: PointerEvent) {
308
+ if (!downSnap) return;
309
+ if (ev.pointerId !== downSnap.id) {
310
+ dbg('cancel-other-id', ev);
311
+ return;
312
+ }
313
+ // Single-shot swallow for the synthetic Android transfer-cancel that
314
+ // fires right after we call `setPointerCapture` on a gutter swipe.
315
+ // Clear the deadline once consumed so a real cancel later in the same
316
+ // gesture still aborts normally.
317
+ if (performance.now() < ignoreCancelUntil) {
318
+ dbg('cancel-ignored-transfer', ev);
319
+ ignoreCancelUntil = 0;
320
+ return;
321
+ }
322
+ dbg('cancel-our-id', ev);
203
323
  endGesture();
204
324
  }
205
325
 
206
326
  function endGesture() {
327
+ // Release explicit pointer capture if we acquired it for a gutter
328
+ // swipe. Browsers auto-release on pointerup/cancel, but doing it
329
+ // defensively here keeps the container's `hasPointerCapture` state
330
+ // clean if endGesture is called from a path that didn't naturally
331
+ // release (claim-stolen, unmount, etc.).
332
+ if (startedInGutter && containerEl && activePointerId !== null) {
333
+ try {
334
+ if (containerEl.hasPointerCapture?.(activePointerId)) {
335
+ containerEl.releasePointerCapture(activePointerId);
336
+ }
337
+ } catch {
338
+ // Defensive — happy-dom / older browsers may not implement it.
339
+ }
340
+ }
207
341
  if (activePointerId !== null) {
208
342
  revoke(activePointerId, 'sh3:carousel');
209
343
  activePointerId = null;
@@ -216,6 +350,8 @@
216
350
  dragging = false;
217
351
  claimed = false;
218
352
  dragDelta = 0;
353
+ startedInGutter = false;
354
+ ignoreCancelUntil = 0;
219
355
  }
220
356
 
221
357
  function commitOrSnap(dx: number, velocity: number) {