sh3-core 0.13.3 → 0.14.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 (66) hide show
  1. package/dist/api.d.ts +3 -0
  2. package/dist/api.js +3 -0
  3. package/dist/app/store/StoreView.svelte +15 -4
  4. package/dist/app/store/permissionConfirm.js +1 -2
  5. package/dist/app/store/storeApp.js +0 -1
  6. package/dist/app/store/storeShard.svelte.js +9 -18
  7. package/dist/app/store/storeTypes.d.ts +21 -0
  8. package/dist/app/store/storeTypes.js +33 -0
  9. package/dist/app/store/storeTypes.test.d.ts +1 -0
  10. package/dist/app/store/storeTypes.test.js +41 -0
  11. package/dist/app/store/updatePackage.test.js +1 -1
  12. package/dist/app/store/verbs.test.js +20 -17
  13. package/dist/host.js +2 -0
  14. package/dist/migrations/mode-id-rename.d.ts +9 -0
  15. package/dist/migrations/mode-id-rename.js +39 -0
  16. package/dist/migrations/mode-id-rename.test.d.ts +1 -0
  17. package/dist/migrations/mode-id-rename.test.js +52 -0
  18. package/dist/overlays/FloatFrame.svelte +18 -1
  19. package/dist/overlays/float.d.ts +12 -0
  20. package/dist/overlays/float.js +16 -0
  21. package/dist/overlays/float.test.js +97 -2
  22. package/dist/overlays/modal.js +1 -0
  23. package/dist/overlays/modal.test.js +17 -0
  24. package/dist/overlays/parentHost.d.ts +1 -0
  25. package/dist/overlays/parentHost.js +15 -0
  26. package/dist/overlays/parentHost.test.d.ts +1 -0
  27. package/dist/overlays/parentHost.test.js +39 -0
  28. package/dist/overlays/popup.js +1 -0
  29. package/dist/overlays/popup.test.js +19 -0
  30. package/dist/shell-shard/Terminal.svelte +85 -8
  31. package/dist/shell-shard/Terminal.svelte.d.ts +2 -0
  32. package/dist/shell-shard/contract.d.ts +65 -0
  33. package/dist/shell-shard/contract.js +11 -0
  34. package/dist/shell-shard/dispatch-custom.test.d.ts +1 -0
  35. package/dist/shell-shard/dispatch-custom.test.js +104 -0
  36. package/dist/shell-shard/dispatch.d.ts +14 -1
  37. package/dist/shell-shard/dispatch.js +58 -5
  38. package/dist/shell-shard/modes/builtin.d.ts +2 -2
  39. package/dist/shell-shard/modes/builtin.js +8 -8
  40. package/dist/shell-shard/modes/prefs.js +1 -1
  41. package/dist/shell-shard/modes/prefs.test.js +13 -13
  42. package/dist/shell-shard/modes/registry.test.js +13 -13
  43. package/dist/shell-shard/output.d.ts +3 -0
  44. package/dist/shell-shard/output.js +75 -0
  45. package/dist/shell-shard/output.test.d.ts +1 -0
  46. package/dist/shell-shard/output.test.js +54 -0
  47. package/dist/shell-shard/registerShellMode.d.ts +13 -0
  48. package/dist/shell-shard/registerShellMode.js +14 -0
  49. package/dist/shell-shard/registerShellMode.test.d.ts +1 -0
  50. package/dist/shell-shard/registerShellMode.test.js +19 -0
  51. package/dist/shell-shard/shellShard.svelte.js +8 -1
  52. package/dist/shell-shard/terminal-dispatch.test.js +9 -9
  53. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +11 -51
  54. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte.d.ts +2 -4
  55. package/dist/shell-shard/toolbar/slots.test.js +6 -6
  56. package/dist/shell-shard/verbs/index.js +2 -0
  57. package/dist/shell-shard/verbs/mode.d.ts +2 -0
  58. package/dist/shell-shard/verbs/mode.js +28 -0
  59. package/dist/shell-shard/verbs/mode.test.d.ts +1 -0
  60. package/dist/shell-shard/verbs/mode.test.js +43 -0
  61. package/dist/verbs/types.d.ts +11 -0
  62. package/dist/version.d.ts +1 -1
  63. package/dist/version.js +1 -1
  64. package/package.json +1 -1
  65. package/dist/app/store/InstalledView.svelte +0 -255
  66. package/dist/app/store/InstalledView.svelte.d.ts +0 -3
package/dist/api.d.ts CHANGED
@@ -50,6 +50,9 @@ export type { Verb, VerbContext, ShellApi } from './verbs/types';
50
50
  export type { Scrollback } from './shell-shard/scrollback.svelte';
51
51
  export type { SessionClient } from './shell-shard/session-client.svelte';
52
52
  export { listVerbs } from './shards/registry';
53
+ export { registerShellMode } from './shell-shard/registerShellMode';
54
+ export type { ShellModeDescriptor, ShellModeOutput, ShellModeDispatchHandler, ShellModeDispatchInput, ShellModeRunsOn, RichEntryHandle, StreamHandle, } from './shell-shard/contract';
55
+ export { SHELL_MODE_CONTRIBUTION_POINT } from './shell-shard/contract';
53
56
  export { VERSION } from './version';
54
57
  export declare const FRAMEWORK_SHARD_IDS: readonly string[];
55
58
  export { setTokenOverrides, clearTokenOverrides, getTokenOverrides, } from './theme';
package/dist/api.js CHANGED
@@ -56,6 +56,9 @@ export const capabilities = {
56
56
  hotInstall: typeof Blob !== 'undefined' && typeof URL.createObjectURL === 'function',
57
57
  };
58
58
  export { listVerbs } from './shards/registry';
59
+ // Shell mode contributions (external shards extend the shell with new modes).
60
+ export { registerShellMode } from './shell-shard/registerShellMode';
61
+ export { SHELL_MODE_CONTRIBUTION_POINT } from './shell-shard/contract';
59
62
  // Package version.
60
63
  export { VERSION } from './version';
61
64
  // Framework shard IDs — shards that are always present (built-in to sh3-core).
@@ -17,9 +17,15 @@
17
17
  import type { InstalledPackage } from '../../registry/types';
18
18
  import { FRAMEWORK_SHARD_IDS } from '../../api';
19
19
  import PermissionConfirmModal from './PermissionConfirmModal.svelte';
20
+ import {
21
+ displayPackageType,
22
+ displayPackageTypeLabel,
23
+ packageMatchesTypeFilter,
24
+ type PackageTypeFilter,
25
+ } from './storeTypes';
20
26
 
21
27
  let search = $state('');
22
- let typeFilter = $state<'all' | 'shard' | 'app'>('all');
28
+ let typeFilter = $state<PackageTypeFilter>('all');
23
29
  let installingIds = $state<Set<string>>(new Set());
24
30
  let updatingIds = $state<Set<string>>(new Set());
25
31
  let installError = $state<string | null>(null);
@@ -70,7 +76,7 @@
70
76
  const filtered = $derived.by(() => {
71
77
  const q = search.toLowerCase().trim();
72
78
  return ctx.state.ephemeral.catalog.filter((pkg: ResolvedPackage) => {
73
- if (typeFilter !== 'all' && pkg.entry.type !== typeFilter) return false;
79
+ if (!packageMatchesTypeFilter(pkg.entry.type, typeFilter)) return false;
74
80
  if (!q) return true;
75
81
  return (
76
82
  pkg.entry.id.toLowerCase().includes(q) ||
@@ -312,6 +318,7 @@
312
318
  {@const updatable = hasUpdate(pkg.entry.id)}
313
319
  {@const updating = updatingIds.has(pkg.entry.id)}
314
320
  {@const missing = missingShards(pkg, ctx.state.ephemeral.installed)}
321
+ {@const displayType = displayPackageType(pkg.entry.type)}
315
322
  <div class="store-card">
316
323
  <div class="store-card-header">
317
324
  <div class="store-card-icon">
@@ -325,8 +332,12 @@
325
332
  </div>
326
333
  <div class="store-card-title">
327
334
  <span class="store-card-label">{pkg.entry.label}</span>
328
- <span class="store-card-badge" class:badge-shard={pkg.entry.type === 'shard'} class:badge-app={pkg.entry.type === 'app'}>
329
- {pkg.entry.type}
335
+ <span
336
+ class="store-card-badge"
337
+ class:badge-shard={displayType === 'shard'}
338
+ class:badge-app={displayType === 'app'}
339
+ >
340
+ {displayPackageTypeLabel(pkg.entry.type)}
330
341
  </span>
331
342
  <span class="store-card-version">{pkg.latest.version}</span>
332
343
  </div>
@@ -1,8 +1,7 @@
1
1
  /*
2
2
  * Shared permission-diff confirmation flow for the `update` path. Opens
3
3
  * PermissionConfirmModal via modalManager and resolves to the user's choice.
4
- * Used by InstalledView's Update button and the home-card "Check for
5
- * updates" context-menu action.
4
+ * Used by the home-card "Check for updates" context-menu action.
6
5
  */
7
6
  import { modalManager } from '../../overlays/modal';
8
7
  import PermissionConfirmModal from './PermissionConfirmModal.svelte';
@@ -21,7 +21,6 @@ export const storeApp = {
21
21
  activeTab: 0,
22
22
  tabs: [
23
23
  { slotId: 'store.browse', viewId: 'sh3-store:browse', label: 'Browse' },
24
- { slotId: 'store.installed', viewId: 'sh3-store:installed', label: 'Installed' },
25
24
  ],
26
25
  },
27
26
  };
@@ -1,10 +1,14 @@
1
1
  /*
2
- * Store shard — framework-shipped shard for browsing and managing
3
- * installed packages.
2
+ * Store shard — framework-shipped shard for browsing and installing
3
+ * packages.
4
4
  *
5
- * Contributes two views:
6
- * - `sh3-store:browse` — searchable/filterable catalog of available packages
7
- * - `sh3-store:installed` — list of installed packages with uninstall
5
+ * Contributes a single view:
6
+ * - `sh3-store:browse` — searchable/filterable catalog of available packages
7
+ *
8
+ * Uninstall and update flows for already-installed packages live on the
9
+ * shell home card's context menu (see `sh3core-shard/appActions.ts`); this
10
+ * shard exposes the underlying operations as verbs (`installVerb`,
11
+ * `uninstallVerb`, `updateVerb`, `appinfoVerb`) and via `storeContext`.
8
12
  *
9
13
  * Uses env state for registries (server-authoritative, admin-writable) and
10
14
  * an ephemeral zone for the live catalog / installed list / loading / error state.
@@ -13,7 +17,6 @@
13
17
  */
14
18
  import { mount, unmount } from 'svelte';
15
19
  import StoreView from './StoreView.svelte';
16
- import InstalledView from './InstalledView.svelte';
17
20
  import { fetchRegistries, fetchBundle, fetchServerBundle, buildPackageMeta } from '../../registry/client';
18
21
  import { installPackage, listInstalledPackages } from '../../registry/installer';
19
22
  import { uninstallPackage as installerUninstallPackage } from '../../registry/installer';
@@ -83,7 +86,6 @@ export const storeShard = {
83
86
  version: VERSION,
84
87
  views: [
85
88
  { id: 'sh3-store:browse', label: 'Store' },
86
- { id: 'sh3-store:installed', label: 'Installed' },
87
89
  ],
88
90
  },
89
91
  activate(ctx) {
@@ -284,18 +286,7 @@ export const storeShard = {
284
286
  };
285
287
  },
286
288
  };
287
- const installedFactory = {
288
- mount(container, _context) {
289
- const instance = mount(InstalledView, { target: container });
290
- return {
291
- unmount() {
292
- unmount(instance);
293
- },
294
- };
295
- },
296
- };
297
289
  ctx.registerView('sh3-store:browse', browseFactory);
298
- ctx.registerView('sh3-store:installed', installedFactory);
299
290
  // Store verbs — registered as sh3-store:install, sh3-store:uninstall, sh3-store:appinfo
300
291
  ctx.registerVerb(installVerb);
301
292
  ctx.registerVerb(uninstallVerb);
@@ -0,0 +1,21 @@
1
+ /** Internal package types as carried by the registry index. */
2
+ export type PackageType = 'shard' | 'app' | 'combo';
3
+ /** User-visible package types — the registry triple collapsed to a pair. */
4
+ export type DisplayPackageType = 'shard' | 'app';
5
+ /** Type-filter values exposed by the browse-view dropdown. */
6
+ export type PackageTypeFilter = 'all' | DisplayPackageType;
7
+ /**
8
+ * Collapse the internal triple to the user-visible pair. Combo packages
9
+ * fold into `app`.
10
+ */
11
+ export declare function displayPackageType(type: PackageType): DisplayPackageType;
12
+ /**
13
+ * Title-cased label for the type chip. Returns `"Shard"` or `"App"` —
14
+ * never `"Combo"`.
15
+ */
16
+ export declare function displayPackageTypeLabel(type: PackageType): 'Shard' | 'App';
17
+ /**
18
+ * True if a package of `type` should be visible under the chosen filter.
19
+ * Combos pass under both `"all"` and `"app"` (never under `"shard"`).
20
+ */
21
+ export declare function packageMatchesTypeFilter(type: PackageType, filter: PackageTypeFilter): boolean;
@@ -0,0 +1,33 @@
1
+ /*
2
+ * Pure helpers that translate the registry's internal package-type triple
3
+ * (`shard | app | combo`) into the two-value vocabulary the store UI shows
4
+ * the user (`Shard | App`). `combo` is an internal distinction — a package
5
+ * that ships both a shard and an app surface — and from a user's standpoint
6
+ * a combo *is* an app, so it collapses to "App" everywhere user-facing.
7
+ *
8
+ * Kept as a separate module (rather than inline in StoreView) so the mapping
9
+ * is unit-testable without mounting Svelte components.
10
+ */
11
+ /**
12
+ * Collapse the internal triple to the user-visible pair. Combo packages
13
+ * fold into `app`.
14
+ */
15
+ export function displayPackageType(type) {
16
+ return type === 'shard' ? 'shard' : 'app';
17
+ }
18
+ /**
19
+ * Title-cased label for the type chip. Returns `"Shard"` or `"App"` —
20
+ * never `"Combo"`.
21
+ */
22
+ export function displayPackageTypeLabel(type) {
23
+ return displayPackageType(type) === 'shard' ? 'Shard' : 'App';
24
+ }
25
+ /**
26
+ * True if a package of `type` should be visible under the chosen filter.
27
+ * Combos pass under both `"all"` and `"app"` (never under `"shard"`).
28
+ */
29
+ export function packageMatchesTypeFilter(type, filter) {
30
+ if (filter === 'all')
31
+ return true;
32
+ return displayPackageType(type) === filter;
33
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { displayPackageType, displayPackageTypeLabel, packageMatchesTypeFilter, } from './storeTypes';
3
+ describe('displayPackageType', () => {
4
+ it('returns "shard" for shard packages', () => {
5
+ expect(displayPackageType('shard')).toBe('shard');
6
+ });
7
+ it('returns "app" for app packages', () => {
8
+ expect(displayPackageType('app')).toBe('app');
9
+ });
10
+ it('collapses combo packages to "app"', () => {
11
+ expect(displayPackageType('combo')).toBe('app');
12
+ });
13
+ });
14
+ describe('displayPackageTypeLabel', () => {
15
+ it('returns "Shard" for shard packages', () => {
16
+ expect(displayPackageTypeLabel('shard')).toBe('Shard');
17
+ });
18
+ it('returns "App" for app packages', () => {
19
+ expect(displayPackageTypeLabel('app')).toBe('App');
20
+ });
21
+ it('returns "App" for combo packages — the user-visible vocabulary never includes "combo"', () => {
22
+ expect(displayPackageTypeLabel('combo')).toBe('App');
23
+ });
24
+ });
25
+ describe('packageMatchesTypeFilter', () => {
26
+ it('matches every type when filter is "all"', () => {
27
+ expect(packageMatchesTypeFilter('shard', 'all')).toBe(true);
28
+ expect(packageMatchesTypeFilter('app', 'all')).toBe(true);
29
+ expect(packageMatchesTypeFilter('combo', 'all')).toBe(true);
30
+ });
31
+ it('matches only shards when filter is "shard"', () => {
32
+ expect(packageMatchesTypeFilter('shard', 'shard')).toBe(true);
33
+ expect(packageMatchesTypeFilter('app', 'shard')).toBe(false);
34
+ expect(packageMatchesTypeFilter('combo', 'shard')).toBe(false);
35
+ });
36
+ it('matches both apps and combos when filter is "app"', () => {
37
+ expect(packageMatchesTypeFilter('shard', 'app')).toBe(false);
38
+ expect(packageMatchesTypeFilter('app', 'app')).toBe(true);
39
+ expect(packageMatchesTypeFilter('combo', 'app')).toBe(true);
40
+ });
41
+ });
@@ -1,7 +1,7 @@
1
1
  /*
2
2
  * updatePackage() — version-aware unit tests. We exercise the version
3
3
  * resolution branch only; the full flow (server install + permission diff)
4
- * is covered indirectly through InstalledView component tests.
4
+ * is covered indirectly through the home-card update-action tests.
5
5
  */
6
6
  import { describe, it, expect } from 'vitest';
7
7
  import { pickVersion } from './storeShard.svelte';
@@ -1,6 +1,14 @@
1
- import { describe, it, expect, vi } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ const { mockStoreContext } = vi.hoisted(() => ({
3
+ mockStoreContext: {
4
+ state: { ephemeral: { installed: [] } },
5
+ updatePackage: vi.fn(),
6
+ },
7
+ }));
8
+ vi.mock('./storeShard.svelte', () => ({
9
+ storeContext: mockStoreContext,
10
+ }));
2
11
  import { updateVerb } from './verbs';
3
- import * as shard from './storeShard.svelte';
4
12
  function mkCtx() {
5
13
  const lines = [];
6
14
  return {
@@ -11,6 +19,10 @@ function mkCtx() {
11
19
  };
12
20
  }
13
21
  describe('updateVerb', () => {
22
+ beforeEach(() => {
23
+ mockStoreContext.state = { ephemeral: { installed: [] } };
24
+ mockStoreContext.updatePackage = vi.fn();
25
+ });
14
26
  it('warns when no id is given', async () => {
15
27
  var _a;
16
28
  const { ctx, lines } = mkCtx();
@@ -19,11 +31,8 @@ describe('updateVerb', () => {
19
31
  });
20
32
  it('delegates to storeContext.updatePackage when version omitted', async () => {
21
33
  const fake = vi.fn().mockResolvedValue(undefined);
22
- // @ts-expect-error module-level singleton is not readonly at runtime
23
- shard.storeContext = {
24
- state: { ephemeral: { installed: [{ id: 'foo', version: '1.0.0' }] } },
25
- updatePackage: fake,
26
- };
34
+ mockStoreContext.state = { ephemeral: { installed: [{ id: 'foo', version: '1.0.0' }] } };
35
+ mockStoreContext.updatePackage = fake;
27
36
  const { ctx, lines } = mkCtx();
28
37
  await updateVerb.run(ctx, ['foo']);
29
38
  expect(fake).toHaveBeenCalledWith('foo', expect.any(Function), undefined);
@@ -31,22 +40,16 @@ describe('updateVerb', () => {
31
40
  });
32
41
  it('passes version through when provided', async () => {
33
42
  const fake = vi.fn().mockResolvedValue(undefined);
34
- // @ts-expect-error
35
- shard.storeContext = {
36
- state: { ephemeral: { installed: [{ id: 'foo', version: '2.0.0' }] } },
37
- updatePackage: fake,
38
- };
43
+ mockStoreContext.state = { ephemeral: { installed: [{ id: 'foo', version: '2.0.0' }] } };
44
+ mockStoreContext.updatePackage = fake;
39
45
  const { ctx } = mkCtx();
40
46
  await updateVerb.run(ctx, ['foo', '1.0.0']);
41
47
  expect(fake).toHaveBeenCalledWith('foo', expect.any(Function), '1.0.0');
42
48
  });
43
49
  it('reports failure as error scrollback line', async () => {
44
50
  const fake = vi.fn().mockRejectedValue(new Error('boom'));
45
- // @ts-expect-error
46
- shard.storeContext = {
47
- state: { ephemeral: { installed: [{ id: 'foo', version: '1.0.0' }] } },
48
- updatePackage: fake,
49
- };
51
+ mockStoreContext.state = { ephemeral: { installed: [{ id: 'foo', version: '1.0.0' }] } };
52
+ mockStoreContext.updatePackage = fake;
50
53
  const { ctx, lines } = mkCtx();
51
54
  await updateVerb.run(ctx, ['foo']);
52
55
  const last = lines.at(-1);
package/dist/host.js CHANGED
@@ -31,6 +31,7 @@ import { storeApp } from './app/store/storeApp';
31
31
  import { adminShard } from './app/admin/adminShard.svelte';
32
32
  import { adminApp } from './app/admin/adminApp';
33
33
  import { runShellRenameMigration, } from './migrations/shell-rename';
34
+ import { runModeIdRenameMigration } from './migrations/mode-id-rename';
34
35
  import { setLifecycleHandlers } from './navigation/back-stack';
35
36
  import { installWebEmitter } from './navigation/platform-web';
36
37
  import { returnToHome } from './apps/lifecycle';
@@ -60,6 +61,7 @@ export async function bootstrap(config) {
60
61
  // already in place when shards activate.
61
62
  if (typeof globalThis.localStorage !== 'undefined') {
62
63
  runShellRenameMigration(createWorkspaceZoneAdapter(), globalThis.localStorage);
64
+ runModeIdRenameMigration(globalThis.localStorage);
63
65
  // Per ADR-002 amendment, app workspace state is keyed by (scopeId, appId).
64
66
  // Rewrite legacy unkeyed entries to the personal scope namespace.
65
67
  const { migrateLegacyWorkspaceKeys } = await import('./apps/workspace-rekey');
@@ -0,0 +1,9 @@
1
+ interface MinimalStorage {
2
+ getItem(key: string): string | null;
3
+ setItem(key: string, value: string): void;
4
+ removeItem(key: string): void;
5
+ }
6
+ export declare function runModeIdRenameMigration(storage: MinimalStorage & {
7
+ _keys?: () => string[];
8
+ }): void;
9
+ export {};
@@ -0,0 +1,39 @@
1
+ /*
2
+ * One-shot migration: rewrites persisted shell-mode preferences from the
3
+ * pre-rename ids (`dev`, `user`) to the new ids (`bash`, `sh3`). Idempotent —
4
+ * gated by a localStorage flag, safe to call on every boot.
5
+ *
6
+ * Persistence shape: localStorage keys of the form `sh3.shell.lastMode.<userId>`
7
+ * (see packages/sh3-core/src/shell-shard/modes/prefs.ts).
8
+ */
9
+ const FLAG_KEY = 'sh3:migrations:mode-id-rename:done';
10
+ const KEY_PREFIX = 'sh3.shell.lastMode.';
11
+ const REWRITES = { dev: 'bash', user: 'sh3' };
12
+ function listKeys(storage) {
13
+ if (typeof storage._keys === 'function')
14
+ return storage._keys();
15
+ const ls = storage;
16
+ if (typeof ls.length === 'number' && typeof ls.key === 'function') {
17
+ const out = [];
18
+ for (let i = 0; i < ls.length; i++) {
19
+ const k = ls.key(i);
20
+ if (k !== null)
21
+ out.push(k);
22
+ }
23
+ return out;
24
+ }
25
+ return [];
26
+ }
27
+ export function runModeIdRenameMigration(storage) {
28
+ if (storage.getItem(FLAG_KEY))
29
+ return;
30
+ for (const key of listKeys(storage)) {
31
+ if (!key.startsWith(KEY_PREFIX))
32
+ continue;
33
+ const value = storage.getItem(key);
34
+ if (value && Object.prototype.hasOwnProperty.call(REWRITES, value)) {
35
+ storage.setItem(key, REWRITES[value]);
36
+ }
37
+ }
38
+ storage.setItem(FLAG_KEY, '1');
39
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { runModeIdRenameMigration } from './mode-id-rename';
3
+ function makeStorage(initial = {}) {
4
+ const map = new Map(Object.entries(initial));
5
+ return {
6
+ getItem: (k) => (map.has(k) ? map.get(k) : null),
7
+ setItem: (k, v) => { map.set(k, v); },
8
+ removeItem: (k) => { map.delete(k); },
9
+ _dump: () => Object.fromEntries(map),
10
+ _keys: () => [...map.keys()],
11
+ };
12
+ }
13
+ describe('runModeIdRenameMigration', () => {
14
+ it('rewrites dev → bash for every user-keyed pref', () => {
15
+ const s = makeStorage({
16
+ 'sh3.shell.lastMode.alice': 'dev',
17
+ 'sh3.shell.lastMode.bob': 'dev',
18
+ });
19
+ runModeIdRenameMigration(s);
20
+ expect(s._dump()).toMatchObject({
21
+ 'sh3.shell.lastMode.alice': 'bash',
22
+ 'sh3.shell.lastMode.bob': 'bash',
23
+ });
24
+ });
25
+ it('rewrites user → sh3 for every user-keyed pref', () => {
26
+ const s = makeStorage({ 'sh3.shell.lastMode.alice': 'user' });
27
+ runModeIdRenameMigration(s);
28
+ expect(s.getItem('sh3.shell.lastMode.alice')).toBe('sh3');
29
+ });
30
+ it('leaves unknown values untouched', () => {
31
+ const s = makeStorage({ 'sh3.shell.lastMode.alice': 'gemini' });
32
+ runModeIdRenameMigration(s);
33
+ expect(s.getItem('sh3.shell.lastMode.alice')).toBe('gemini');
34
+ });
35
+ it('is idempotent (gated by a done flag)', () => {
36
+ const s = makeStorage({ 'sh3.shell.lastMode.alice': 'dev' });
37
+ runModeIdRenameMigration(s);
38
+ s.setItem('sh3.shell.lastMode.alice', 'dev');
39
+ runModeIdRenameMigration(s);
40
+ expect(s.getItem('sh3.shell.lastMode.alice')).toBe('dev');
41
+ });
42
+ it('ignores unrelated keys', () => {
43
+ const s = makeStorage({
44
+ 'sh3.shell.lastMode.alice': 'dev',
45
+ 'sh3.unrelated': 'dev',
46
+ 'random': 'user',
47
+ });
48
+ runModeIdRenameMigration(s);
49
+ expect(s.getItem('sh3.unrelated')).toBe('dev');
50
+ expect(s.getItem('random')).toBe('user');
51
+ });
52
+ });
@@ -17,7 +17,7 @@
17
17
  -->
18
18
  <script lang="ts">
19
19
  import LayoutRenderer from '../layout/LayoutRenderer.svelte';
20
- import { floatManager } from './float';
20
+ import { floatManager, getFloatParentHost } from './float';
21
21
  import { registerDismissableFrame, unregisterDismissableFrame } from './floatDismiss';
22
22
  import type { FloatEntry } from '../layout/types';
23
23
 
@@ -37,6 +37,22 @@
37
37
  return () => unregisterDismissableFrame(entry.id);
38
38
  });
39
39
 
40
+ // Portal the frame into the anchor's enclosing overlay host when one was
41
+ // resolved at open() time. This puts the frame inside the opener's
42
+ // stacking context — so a picker opened from inside a modal stacks above
43
+ // that modal without writing any z-index. The Svelte component lifecycle
44
+ // is unaffected; we're only relocating the rendered DOM node.
45
+ $effect(() => {
46
+ if (!frameEl) return;
47
+ const host = getFloatParentHost(entry.id);
48
+ if (!host) return;
49
+ const original = frameEl.parentNode;
50
+ host.appendChild(frameEl);
51
+ return () => {
52
+ if (frameEl?.parentNode === host && original) original.appendChild(frameEl);
53
+ };
54
+ });
55
+
40
56
  function onHeaderPointerDown(e: PointerEvent): void {
41
57
  if (e.button !== 0) return;
42
58
  if ((e.target as HTMLElement).closest('.sh3-float-close')) return;
@@ -76,6 +92,7 @@
76
92
  <!-- svelte-ignore a11y_click_events_have_key_events -->
77
93
  <div
78
94
  class="sh3-float-frame"
95
+ data-shell-overlay-host="float"
79
96
  bind:this={frameEl}
80
97
  style:left="{entry.position.x}px"
81
98
  style:top="{entry.position.y}px"
@@ -15,6 +15,17 @@ export interface FloatOptions {
15
15
  * See docs/superpowers/specs/2026-04-21-dismissable-float-design.md.
16
16
  */
17
17
  dismissable?: boolean;
18
+ /**
19
+ * For `dismissable` floats only: anchor element used to determine the
20
+ * mount host. When the anchor is inside another overlay (modal, popup,
21
+ * float frame), the float frame is portaled into that host so it stacks
22
+ * above its opener instead of sitting at layer 1. Without an anchor —
23
+ * or for non-dismissable floats — the frame renders at the FloatLayer
24
+ * root as usual. The anchor isn't stored on FloatEntry (HTMLElement
25
+ * isn't serializable through the workspace-zone proxy); only the
26
+ * resolved parent host is, in a sidecar map keyed by float id.
27
+ */
28
+ anchor?: HTMLElement;
18
29
  }
19
30
  export interface FloatManager {
20
31
  open(viewId: string, options?: FloatOptions): string;
@@ -34,4 +45,5 @@ export declare function bindFloatStore(floats: FloatEntry[], getBounds: () => {
34
45
  export declare function unbindFloatStore(): void;
35
46
  /** Test-only reset. Clears in-memory fallback and unbinds any store. */
36
47
  export declare function __resetFloatManagerForTest(): void;
48
+ export declare function getFloatParentHost(id: string): HTMLElement | undefined;
37
49
  export declare const floatManager: FloatManager;
@@ -27,6 +27,7 @@
27
27
  * and the pre-boot state.
28
28
  */
29
29
  import { computeMinSize, cascadePosition, generateFloatId } from '../layout/floats';
30
+ import { findEnclosingOverlayHost } from './parentHost';
30
31
  // ----- storage binding ---------------------------------------------------
31
32
  let fallbackFloats = [];
32
33
  let boundFloats = null;
@@ -49,10 +50,19 @@ export function __resetFloatManagerForTest() {
49
50
  fallbackFloats = [];
50
51
  boundFloats = null;
51
52
  getTreeBounds = () => ({ w: 1600, h: 900 });
53
+ parentHosts.clear();
52
54
  }
53
55
  function activeStore() {
54
56
  return boundFloats !== null && boundFloats !== void 0 ? boundFloats : fallbackFloats;
55
57
  }
58
+ // ----- parent host sidecar ------------------------------------------------
59
+ // HTMLElement can't live on FloatEntry (workspace-zone proxy state), so the
60
+ // resolved parent host is stored here keyed by float id and consumed by
61
+ // FloatFrame to portal the rendered DOM into the opener's stacking context.
62
+ const parentHosts = new Map();
63
+ export function getFloatParentHost(id) {
64
+ return parentHosts.get(id);
65
+ }
56
66
  // ----- slot id minting ---------------------------------------------------
57
67
  let floatSlotCounter = 0;
58
68
  function mintFloatSlotId(viewId) {
@@ -107,6 +117,11 @@ function openFloat(viewId, options = {}) {
107
117
  };
108
118
  if (options.dismissable)
109
119
  entry.dismissable = true;
120
+ if (options.dismissable && options.anchor) {
121
+ const host = findEnclosingOverlayHost(options.anchor);
122
+ if (host)
123
+ parentHosts.set(id, host);
124
+ }
110
125
  store.push(entry);
111
126
  return id;
112
127
  }
@@ -116,6 +131,7 @@ function closeFloat(floatId) {
116
131
  if (idx < 0)
117
132
  return;
118
133
  store.splice(idx, 1);
134
+ parentHosts.delete(floatId);
119
135
  }
120
136
  function listFloats() {
121
137
  // Return a snapshot so callers can iterate without racing mutations.