sh3-core 0.13.3 → 0.13.4

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.
@@ -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);
@@ -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.
@@ -1,5 +1,5 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
2
- import { floatManager, __resetFloatManagerForTest, bindFloatStore } from './float';
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { floatManager, __resetFloatManagerForTest, bindFloatStore, getFloatParentHost, } from './float';
3
3
  import { layoutStore } from '../layout/store.svelte';
4
4
  describe('floatManager', () => {
5
5
  beforeEach(() => {
@@ -80,6 +80,49 @@ describe('floatManager', () => {
80
80
  expect(f.content.type).toBe('tabs');
81
81
  });
82
82
  });
83
+ describe('floatManager — anchor-aware parent host', () => {
84
+ beforeEach(() => {
85
+ __resetFloatManagerForTest();
86
+ });
87
+ afterEach(() => {
88
+ document.body.innerHTML = '';
89
+ });
90
+ function makeOverlayHost(kind) {
91
+ const host = document.createElement('div');
92
+ host.dataset.shellOverlayHost = kind;
93
+ const anchor = document.createElement('button');
94
+ host.appendChild(anchor);
95
+ document.body.appendChild(host);
96
+ return { host, anchor };
97
+ }
98
+ it('getFloatParentHost is undefined when no anchor was passed', () => {
99
+ const id = floatManager.open('test:view', { dismissable: true });
100
+ expect(getFloatParentHost(id)).toBeUndefined();
101
+ });
102
+ it('getFloatParentHost is undefined when the anchor lives outside any overlay host', () => {
103
+ const anchor = document.createElement('button');
104
+ document.body.appendChild(anchor);
105
+ const id = floatManager.open('test:view', { dismissable: true, anchor });
106
+ expect(getFloatParentHost(id)).toBeUndefined();
107
+ });
108
+ it('getFloatParentHost returns the enclosing host for a dismissable+anchored float', () => {
109
+ const { host, anchor } = makeOverlayHost('modal');
110
+ const id = floatManager.open('test:view', { dismissable: true, anchor });
111
+ expect(getFloatParentHost(id)).toBe(host);
112
+ });
113
+ it('getFloatParentHost is undefined for non-dismissable floats even with an anchor', () => {
114
+ const { anchor } = makeOverlayHost('modal');
115
+ const id = floatManager.open('test:view', { anchor });
116
+ expect(getFloatParentHost(id)).toBeUndefined();
117
+ });
118
+ it('getFloatParentHost is cleared when the float is closed', () => {
119
+ const { anchor } = makeOverlayHost('modal');
120
+ const id = floatManager.open('test:view', { dismissable: true, anchor });
121
+ expect(getFloatParentHost(id)).toBeDefined();
122
+ floatManager.close(id);
123
+ expect(getFloatParentHost(id)).toBeUndefined();
124
+ });
125
+ });
83
126
  // ---------------------------------------------------------------------------
84
127
  // DOM tests — floatManager + FloatLayer.svelte in happy-dom
85
128
  // ---------------------------------------------------------------------------
@@ -311,3 +354,55 @@ describe('floats — F.6 multi-picker interaction', () => {
311
354
  expect(floatManager.list().some((f) => f.id === id)).toBe(false);
312
355
  });
313
356
  });
357
+ // ---------------------------------------------------------------------------
358
+ // F.7 — anchor portals dismissable float into the enclosing overlay host
359
+ // ---------------------------------------------------------------------------
360
+ describe('floats — F.7 anchor portals to enclosing overlay host', () => {
361
+ beforeEach(() => {
362
+ resetFramework();
363
+ bindManagerToStore();
364
+ });
365
+ it('reparents the FloatFrame into the anchor’s enclosing overlay host', async () => {
366
+ const { container } = renderWithShell(FloatLayer, {});
367
+ const fakeModalHost = document.createElement('div');
368
+ fakeModalHost.className = 'fake-modal-host';
369
+ fakeModalHost.dataset.shellOverlayHost = 'modal';
370
+ const anchor = document.createElement('button');
371
+ fakeModalHost.appendChild(anchor);
372
+ document.body.appendChild(fakeModalHost);
373
+ floatManager.open('test:view', {
374
+ dismissable: true,
375
+ anchor,
376
+ title: 'Picker',
377
+ });
378
+ await tick();
379
+ const frame = document.querySelector('[role="dialog"][aria-label="Picker"]');
380
+ expect(frame).toBeTruthy();
381
+ expect(fakeModalHost.contains(frame)).toBe(true);
382
+ expect(container.contains(frame)).toBe(false);
383
+ });
384
+ it('renders inside FloatLayer when no anchor is provided', async () => {
385
+ const { container } = renderWithShell(FloatLayer, {});
386
+ floatManager.open('test:view', { dismissable: true, title: 'NoAnchor' });
387
+ await tick();
388
+ const frame = container.querySelector('[role="dialog"][aria-label="NoAnchor"]');
389
+ expect(frame).toBeTruthy();
390
+ });
391
+ });
392
+ // ---------------------------------------------------------------------------
393
+ // F.8 — overlay host marker on FloatFrame
394
+ // ---------------------------------------------------------------------------
395
+ describe('floats — F.8 overlay host marker', () => {
396
+ beforeEach(() => {
397
+ resetFramework();
398
+ bindManagerToStore();
399
+ });
400
+ it('marks each FloatFrame with data-shell-overlay-host="float"', async () => {
401
+ const { container } = renderWithShell(FloatLayer, {});
402
+ floatManager.open('test:view', { title: 'Marked' });
403
+ await tick();
404
+ const frame = container.querySelector('[role="dialog"][aria-label="Marked"]');
405
+ expect(frame).toBeTruthy();
406
+ expect(frame.dataset.shellOverlayHost).toBe('float');
407
+ });
408
+ });
@@ -109,6 +109,7 @@ function openModal(Content, props, options) {
109
109
  const root = getLayerRoot('modal');
110
110
  const host = document.createElement('div');
111
111
  host.className = 'sh3-modal-host';
112
+ host.dataset.shellOverlayHost = 'modal';
112
113
  host.style.position = 'absolute';
113
114
  host.style.inset = '0';
114
115
  host.style.pointerEvents = 'auto';
@@ -88,3 +88,20 @@ describe('modal — back-cascade integration', () => {
88
88
  expect(layerRoot.querySelectorAll('.sh3-modal-host').length).toBe(0);
89
89
  });
90
90
  });
91
+ describe('modal — overlay host marker', () => {
92
+ let layerRoot;
93
+ beforeEach(() => {
94
+ layerRoot = makeLayerRoot();
95
+ });
96
+ afterEach(() => {
97
+ modalManager.closeAll();
98
+ teardownLayerRoot(layerRoot);
99
+ });
100
+ it('marks the modal host with data-shell-overlay-host="modal"', async () => {
101
+ modalManager.open(DummyFrame, {});
102
+ await tick();
103
+ const host = layerRoot.querySelector('.sh3-modal-host');
104
+ expect(host).not.toBeNull();
105
+ expect(host.dataset.shellOverlayHost).toBe('modal');
106
+ });
107
+ });
@@ -0,0 +1 @@
1
+ export declare function findEnclosingOverlayHost(anchor: HTMLElement): HTMLElement | null;
@@ -0,0 +1,15 @@
1
+ /*
2
+ * Walks up from `anchor` looking for an element marked as an overlay host
3
+ * via `data-shell-overlay-host`. Modal hosts, popup hosts, and float frames
4
+ * tag themselves so anchored overlays (popups, dismissable picker floats)
5
+ * can mount inside their opener's stacking context instead of at a global
6
+ * layer root — which is what the layer-z-index invariant gives us when a
7
+ * popover is logically "inside" a modal.
8
+ *
9
+ * Returns null when the anchor lives in the docked tree; callers fall back
10
+ * to their configured layer root in that case. The marker is read via
11
+ * `Element.closest`, so a marker on the anchor itself counts.
12
+ */
13
+ export function findEnclosingOverlayHost(anchor) {
14
+ return anchor.closest('[data-shell-overlay-host]');
15
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { findEnclosingOverlayHost } from './parentHost';
3
+ afterEach(() => {
4
+ document.body.innerHTML = '';
5
+ });
6
+ describe('findEnclosingOverlayHost', () => {
7
+ it('returns the nearest ancestor with data-shell-overlay-host', () => {
8
+ const host = document.createElement('div');
9
+ host.dataset.shellOverlayHost = 'modal';
10
+ const inner = document.createElement('div');
11
+ const anchor = document.createElement('button');
12
+ inner.appendChild(anchor);
13
+ host.appendChild(inner);
14
+ document.body.appendChild(host);
15
+ expect(findEnclosingOverlayHost(anchor)).toBe(host);
16
+ });
17
+ it('returns the anchor itself when it carries the marker', () => {
18
+ const anchor = document.createElement('div');
19
+ anchor.dataset.shellOverlayHost = 'float';
20
+ document.body.appendChild(anchor);
21
+ expect(findEnclosingOverlayHost(anchor)).toBe(anchor);
22
+ });
23
+ it('returns null when no ancestor carries the marker', () => {
24
+ const anchor = document.createElement('button');
25
+ document.body.appendChild(anchor);
26
+ expect(findEnclosingOverlayHost(anchor)).toBeNull();
27
+ });
28
+ it('returns the innermost host when overlay hosts are nested', () => {
29
+ const outer = document.createElement('div');
30
+ outer.dataset.shellOverlayHost = 'modal';
31
+ const inner = document.createElement('div');
32
+ inner.dataset.shellOverlayHost = 'float';
33
+ const anchor = document.createElement('button');
34
+ inner.appendChild(anchor);
35
+ outer.appendChild(inner);
36
+ document.body.appendChild(outer);
37
+ expect(findEnclosingOverlayHost(anchor)).toBe(inner);
38
+ });
39
+ });
@@ -83,6 +83,7 @@ function showPopup(Content, options, props) {
83
83
  const root = getLayerRoot('popup');
84
84
  const host = document.createElement('div');
85
85
  host.className = 'sh3-popup-host';
86
+ host.dataset.shellOverlayHost = 'popup';
86
87
  host.style.position = 'absolute';
87
88
  host.style.inset = '0';
88
89
  host.style.pointerEvents = 'none'; // only the frame captures pointer events
@@ -126,3 +126,22 @@ describe('popup — back-cascade integration', () => {
126
126
  expect(layerRoot.querySelector('.sh3-popup-host')).toBeNull();
127
127
  });
128
128
  });
129
+ describe('popup — overlay host marker', () => {
130
+ let layerRoot;
131
+ beforeEach(() => {
132
+ vi.stubGlobal('innerWidth', 2000);
133
+ vi.stubGlobal('innerHeight', 2000);
134
+ layerRoot = makeLayerRoot();
135
+ });
136
+ afterEach(() => {
137
+ __resetPopupManagerForTest();
138
+ teardownLayerRoot(layerRoot);
139
+ vi.unstubAllGlobals();
140
+ });
141
+ it('marks the popup host with data-shell-overlay-host="popup"', () => {
142
+ popupManager.show(DummyFrame, { anchor: { x: 100, y: 100 } }, {});
143
+ const host = layerRoot.querySelector('.sh3-popup-host');
144
+ expect(host).not.toBeNull();
145
+ expect(host.dataset.shellOverlayHost).toBe('popup');
146
+ });
147
+ });
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.13.3";
2
+ export declare const VERSION = "0.13.4";
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.13.3';
2
+ export const VERSION = '0.13.4';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh3-core",
3
- "version": "0.13.3",
3
+ "version": "0.13.4",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"
@@ -1,255 +0,0 @@
1
- <script lang="ts">
2
- /*
3
- * InstalledView — lists installed packages with uninstall capability.
4
- *
5
- * Simpler than the browse view: a flat list of installed packages with
6
- * metadata (type, version, contract version, source registry, install date)
7
- * and an uninstall button per entry.
8
- */
9
-
10
- import { storeContext } from './storeShard.svelte';
11
- import { uninstallPackage } from '../../registry/installer';
12
- import { serverUninstallPackage } from '../../env/client';
13
- import { openPermissionConfirmModal } from './permissionConfirm';
14
- import type { InstalledPackage } from '../../registry/types';
15
-
16
- const ctx = storeContext;
17
-
18
- let uninstallingIds = $state<Set<string>>(new Set());
19
- let updatingIds = $state<Set<string>>(new Set());
20
- let updateError = $state<string | null>(null);
21
-
22
- async function handleUninstall(id: string) {
23
- if (uninstallingIds.has(id)) return;
24
-
25
- uninstallingIds = new Set([...uninstallingIds, id]);
26
- try {
27
- await serverUninstallPackage(id);
28
- await uninstallPackage(id);
29
- await ctx.refreshInstalled();
30
- } catch (err) {
31
- console.warn('[sh3-store] Uninstall failed:', err instanceof Error ? err.message : err);
32
- } finally {
33
- const next = new Set(uninstallingIds);
34
- next.delete(id);
35
- uninstallingIds = next;
36
- }
37
- }
38
-
39
- async function handleUpdate(id: string) {
40
- if (updatingIds.has(id)) return;
41
- updatingIds = new Set([...updatingIds, id]);
42
- updateError = null;
43
- try {
44
- await ctx.updatePackage(id, async (added, removed) => {
45
- const pkg = ctx.state.ephemeral.installed.find(
46
- (p: InstalledPackage) => p.id === id,
47
- );
48
- const target = ctx.state.ephemeral.updatable[id];
49
- if (!pkg || !target) return true;
50
- return openPermissionConfirmModal(pkg, target.latest.version, added, removed);
51
- });
52
- } catch (err) {
53
- updateError = err instanceof Error ? err.message : String(err);
54
- } finally {
55
- const next = new Set(updatingIds);
56
- next.delete(id);
57
- updatingIds = next;
58
- }
59
- }
60
-
61
- function handleRefresh() {
62
- ctx.refreshInstalled();
63
- }
64
-
65
- function formatDate(iso: string): string {
66
- try {
67
- return new Date(iso).toLocaleDateString(undefined, {
68
- year: 'numeric',
69
- month: 'short',
70
- day: 'numeric',
71
- });
72
- } catch {
73
- return iso;
74
- }
75
- }
76
- </script>
77
-
78
- <div class="installed-view">
79
- <header class="installed-header">
80
- <h2>Installed Packages</h2>
81
- <button onclick={handleRefresh}>Refresh</button>
82
- </header>
83
-
84
- {#if updateError}
85
- <div class="installed-error">{updateError}</div>
86
- {/if}
87
-
88
- {#if ctx.state.ephemeral.installed.length === 0}
89
- <div class="installed-empty">No packages installed.</div>
90
- {:else}
91
- <ul class="installed-list">
92
- {#each ctx.state.ephemeral.installed as pkg (pkg.id)}
93
- {@const uninstalling = uninstallingIds.has(pkg.id)}
94
- <li class="installed-item">
95
- <div class="installed-item-main">
96
- <span class="installed-item-id">{pkg.id}</span>
97
- <span class="installed-item-badge" class:badge-shard={pkg.type === 'shard'} class:badge-app={pkg.type === 'app'}>
98
- {pkg.type}
99
- </span>
100
- <span class="installed-item-version">{pkg.version}</span>
101
- </div>
102
- <div class="installed-item-meta">
103
- <span>Contract: v{pkg.contractVersion}</span>
104
- <span>Source: {pkg.sourceRegistry}</span>
105
- <span>Installed: {formatDate(pkg.installedAt)}</span>
106
- </div>
107
- <div class="installed-item-actions">
108
- {#if pkg.id in ctx.state.ephemeral.updatable}
109
- {@const target = ctx.state.ephemeral.updatable[pkg.id]}
110
- {@const updating = updatingIds.has(pkg.id)}
111
- <button
112
- class="installed-update-btn"
113
- onclick={() => handleUpdate(pkg.id)}
114
- disabled={updating || uninstalling}
115
- >
116
- {updating ? 'Updating...' : `Update -> ${target.latest.version}`}
117
- </button>
118
- {/if}
119
- <button
120
- class="installed-uninstall-btn"
121
- onclick={() => handleUninstall(pkg.id)}
122
- disabled={uninstalling}
123
- >
124
- {uninstalling ? 'Removing...' : 'Uninstall'}
125
- </button>
126
- </div>
127
- </li>
128
- {/each}
129
- </ul>
130
- {/if}
131
-
132
- </div>
133
-
134
- <style>
135
- .installed-view {
136
- font-family: var(--shell-font-ui);
137
- color: var(--shell-fg, #e0e0e0);
138
- background: var(--shell-bg, #1e1e1e);
139
- padding: 16px;
140
- height: 100%;
141
- overflow-y: auto;
142
- box-sizing: border-box;
143
- }
144
- .installed-header {
145
- display: flex;
146
- align-items: center;
147
- justify-content: space-between;
148
- margin-bottom: 16px;
149
- }
150
- .installed-header h2 {
151
- margin: 0;
152
- font-size: 1.25rem;
153
- font-weight: 600;
154
- }
155
- .installed-empty {
156
- text-align: center;
157
- padding: 32px 16px;
158
- color: var(--shell-fg-muted, #888);
159
- font-size: 0.875rem;
160
- }
161
- .installed-list {
162
- list-style: none;
163
- margin: 0;
164
- padding: 0;
165
- display: flex;
166
- flex-direction: column;
167
- gap: 8px;
168
- }
169
- .installed-item {
170
- background: var(--shell-input-bg, #2a2a2a);
171
- border: 1px solid var(--shell-border, #444);
172
- border-radius: var(--shell-radius-md);
173
- padding: 12px 14px;
174
- display: flex;
175
- flex-direction: column;
176
- gap: 6px;
177
- }
178
- .installed-item-main {
179
- display: flex;
180
- align-items: center;
181
- gap: 8px;
182
- }
183
- .installed-item-id {
184
- font-weight: 600;
185
- font-size: 0.9375rem;
186
- }
187
- .installed-item-badge {
188
- font-size: 0.6875rem;
189
- padding: 1px 6px;
190
- border-radius: var(--shell-radius-sm);
191
- text-transform: uppercase;
192
- font-weight: 600;
193
- letter-spacing: 0.04em;
194
- }
195
- .badge-shard {
196
- background: color-mix(in srgb, var(--shell-accent, #007acc) 25%, transparent);
197
- color: var(--shell-accent, #007acc);
198
- }
199
- .badge-app {
200
- background: color-mix(in srgb, var(--shell-success, #4caf50) 25%, transparent);
201
- color: var(--shell-success, #4caf50);
202
- }
203
- .installed-item-version {
204
- font-size: 0.75rem;
205
- color: var(--shell-fg-muted, #888);
206
- }
207
- .installed-item-meta {
208
- display: flex;
209
- gap: 16px;
210
- flex-wrap: wrap;
211
- font-size: 0.75rem;
212
- color: var(--shell-fg-muted, #888);
213
- }
214
- .installed-item-actions {
215
- display: flex;
216
- justify-content: flex-end;
217
- gap: 8px;
218
- }
219
- .installed-uninstall-btn {
220
- padding: 4px 12px;
221
- background: transparent;
222
- color: var(--shell-error, #d32f2f);
223
- border: 1px solid var(--shell-error, #d32f2f);
224
- font-size: 0.8125rem;
225
- }
226
- .installed-uninstall-btn:hover:not(:disabled) {
227
- background: color-mix(in srgb, var(--shell-error, #d32f2f) 15%, transparent);
228
- }
229
- .installed-uninstall-btn:disabled {
230
- opacity: 0.6;
231
- cursor: not-allowed;
232
- }
233
- .installed-update-btn {
234
- padding: 4px 12px;
235
- background: var(--shell-warning, #fbbf24);
236
- color: var(--shell-fg-on-warning, #1a1b1e);
237
- font-size: 0.8125rem;
238
- }
239
- .installed-update-btn:hover:not(:disabled) {
240
- filter: brightness(1.1);
241
- }
242
- .installed-update-btn:disabled {
243
- opacity: 0.6;
244
- cursor: not-allowed;
245
- }
246
- .installed-error {
247
- padding: 8px 12px;
248
- margin-bottom: 12px;
249
- background: color-mix(in srgb, var(--shell-error, #d32f2f) 15%, transparent);
250
- color: var(--shell-error, #d32f2f);
251
- border: 1px solid var(--shell-error, #d32f2f);
252
- border-radius: var(--shell-radius);
253
- font-size: 0.8125rem;
254
- }
255
- </style>
@@ -1,3 +0,0 @@
1
- declare const InstalledView: import("svelte").Component<Record<string, never>, {}, "">;
2
- type InstalledView = ReturnType<typeof InstalledView>;
3
- export default InstalledView;