sh3-core 0.13.2 → 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.
Files changed (79) hide show
  1. package/dist/actions/MenuButton.svelte +2 -1
  2. package/dist/actions/contextMenuModel.d.ts +1 -1
  3. package/dist/actions/contextMenuModel.js +2 -1
  4. package/dist/actions/dispatcher.svelte.d.ts +1 -1
  5. package/dist/actions/dispatcher.svelte.js +2 -1
  6. package/dist/actions/listActive.d.ts +1 -1
  7. package/dist/actions/listActive.js +2 -1
  8. package/dist/actions/listeners.d.ts +1 -1
  9. package/dist/actions/listeners.js +6 -5
  10. package/dist/actions/menuBarModel.js +3 -2
  11. package/dist/actions/paletteModel.js +2 -1
  12. package/dist/actions/resolveLabel.test.d.ts +1 -0
  13. package/dist/actions/resolveLabel.test.js +14 -0
  14. package/dist/actions/types.d.ts +12 -1
  15. package/dist/actions/types.js +7 -1
  16. package/dist/app/store/AppUpdateAvailableModal.svelte +87 -0
  17. package/dist/app/store/AppUpdateAvailableModal.svelte.d.ts +11 -0
  18. package/dist/app/store/StoreView.svelte +15 -4
  19. package/dist/app/store/UninstallAppDialog.svelte +86 -0
  20. package/dist/app/store/UninstallAppDialog.svelte.d.ts +10 -0
  21. package/dist/app/store/permissionConfirm.d.ts +4 -0
  22. package/dist/app/store/permissionConfirm.js +27 -0
  23. package/dist/app/store/storeApp.js +0 -1
  24. package/dist/app/store/storeShard.svelte.d.ts +8 -1
  25. package/dist/app/store/storeShard.svelte.js +51 -27
  26. package/dist/app/store/storeTypes.d.ts +21 -0
  27. package/dist/app/store/storeTypes.js +33 -0
  28. package/dist/app/store/storeTypes.test.d.ts +1 -0
  29. package/dist/app/store/storeTypes.test.js +41 -0
  30. package/dist/app/store/updatePackage.test.d.ts +1 -0
  31. package/dist/app/store/updatePackage.test.js +34 -0
  32. package/dist/app/store/verbs.d.ts +1 -0
  33. package/dist/app/store/verbs.js +79 -5
  34. package/dist/app/store/verbs.test.d.ts +1 -0
  35. package/dist/app/store/verbs.test.js +59 -0
  36. package/dist/app-appearance/AppAppearanceModal.svelte +174 -0
  37. package/dist/app-appearance/AppAppearanceModal.svelte.d.ts +8 -0
  38. package/dist/app-appearance/appearanceShard.svelte.d.ts +2 -0
  39. package/dist/app-appearance/appearanceShard.svelte.js +61 -0
  40. package/dist/app-appearance/appearanceState.svelte.d.ts +15 -0
  41. package/dist/app-appearance/appearanceState.svelte.js +59 -0
  42. package/dist/app-appearance/appearanceState.test.d.ts +1 -0
  43. package/dist/app-appearance/appearanceState.test.js +30 -0
  44. package/dist/app-appearance/index.d.ts +3 -0
  45. package/dist/app-appearance/index.js +2 -0
  46. package/dist/app-appearance/types.d.ts +11 -0
  47. package/dist/app-appearance/types.js +1 -0
  48. package/dist/apps/types.d.ts +7 -0
  49. package/dist/assets/iconIds.generated.d.ts +2 -0
  50. package/dist/assets/iconIds.generated.js +154 -0
  51. package/dist/host.js +2 -1
  52. package/dist/overlays/FloatFrame.svelte +18 -1
  53. package/dist/overlays/float.d.ts +12 -0
  54. package/dist/overlays/float.js +16 -0
  55. package/dist/overlays/float.test.js +97 -2
  56. package/dist/overlays/modal.js +1 -0
  57. package/dist/overlays/modal.test.js +17 -0
  58. package/dist/overlays/parentHost.d.ts +1 -0
  59. package/dist/overlays/parentHost.js +15 -0
  60. package/dist/overlays/parentHost.test.d.ts +1 -0
  61. package/dist/overlays/parentHost.test.js +39 -0
  62. package/dist/overlays/popup.js +1 -0
  63. package/dist/overlays/popup.test.js +19 -0
  64. package/dist/primitives/widgets/IconPicker.svelte +115 -0
  65. package/dist/primitives/widgets/IconPicker.svelte.d.ts +9 -0
  66. package/dist/primitives/widgets/IconPicker.svelte.test.d.ts +1 -0
  67. package/dist/primitives/widgets/IconPicker.svelte.test.js +43 -0
  68. package/dist/projects-shard/ProjectManage.svelte +14 -4
  69. package/dist/sh3core-shard/ShellHome.svelte +64 -38
  70. package/dist/sh3core-shard/appActions.d.ts +13 -0
  71. package/dist/sh3core-shard/appActions.js +181 -0
  72. package/dist/sh3core-shard/appActions.test.d.ts +1 -0
  73. package/dist/sh3core-shard/appActions.test.js +25 -0
  74. package/dist/sh3core-shard/sh3coreShard.svelte.js +2 -0
  75. package/dist/version.d.ts +1 -1
  76. package/dist/version.js +1 -1
  77. package/package.json +2 -2
  78. package/dist/app/store/InstalledView.svelte +0 -301
  79. package/dist/app/store/InstalledView.svelte.d.ts +0 -3
@@ -0,0 +1,61 @@
1
+ /*
2
+ * `__app-appearance__` shard — owns the user-zone for per-app overrides
3
+ * and registers the `app.customize` element-scope action. The state
4
+ * itself lives in appearanceState.svelte.ts (so unit tests don't have
5
+ * to boot a real ShardContext). This file binds the zone on activate
6
+ * and unbinds on deactivate, and contributes the action.
7
+ */
8
+ import { VERSION } from '../version';
9
+ import { listRegisteredApps } from '../api';
10
+ import { getSelection } from '../actions/selection.svelte';
11
+ import { modalManager } from '../overlays/modal';
12
+ import AppAppearanceModal from './AppAppearanceModal.svelte';
13
+ import { __bindZone, __unbindZone, } from './appearanceState.svelte';
14
+ function readSelection() {
15
+ const sel = getSelection();
16
+ if (!sel || sel.type !== 'app')
17
+ return null;
18
+ return sel.ref;
19
+ }
20
+ function runCustomize(_ctx) {
21
+ var _a;
22
+ const ref = readSelection();
23
+ if (!ref)
24
+ return;
25
+ const m = listRegisteredApps().find((x) => x.id === ref.appId);
26
+ const props = {
27
+ appId: ref.appId,
28
+ appLabel: (_a = m === null || m === void 0 ? void 0 : m.label) !== null && _a !== void 0 ? _a : ref.appId,
29
+ };
30
+ modalManager.open(AppAppearanceModal, props);
31
+ }
32
+ export const appearanceShard = {
33
+ manifest: {
34
+ id: '__app-appearance__',
35
+ label: 'App Appearance',
36
+ version: VERSION,
37
+ views: [],
38
+ },
39
+ activate(ctx) {
40
+ const zone = ctx.state({
41
+ user: { overrides: {} },
42
+ });
43
+ __bindZone(zone);
44
+ const customize = {
45
+ id: 'app.customize',
46
+ label: 'Customize…',
47
+ scope: { element: 'app' },
48
+ contextItem: true,
49
+ group: 'appearance',
50
+ run: runCustomize,
51
+ };
52
+ ctx.actions.register(customize);
53
+ },
54
+ autostart() {
55
+ // Self-start so the `app.customize` action is registered before the
56
+ // user right-clicks a home card. No imperative work required.
57
+ },
58
+ deactivate() {
59
+ __unbindZone();
60
+ },
61
+ };
@@ -0,0 +1,15 @@
1
+ import type { StateZones } from '../state/zones.svelte';
2
+ import type { AppAppearance } from './types';
3
+ export interface AppearanceZoneSchema {
4
+ user: {
5
+ overrides: Record<string, AppAppearance>;
6
+ };
7
+ }
8
+ /** Bind the shard's zone to this module. Called by the shard's activate. */
9
+ export declare function __bindZone(s: StateZones<AppearanceZoneSchema>): void;
10
+ /** Unbind the zone (deactivate). */
11
+ export declare function __unbindZone(): void;
12
+ export declare function getAppearance(appId: string): AppAppearance | undefined;
13
+ export declare function setAppearance(appId: string, value: AppAppearance | undefined): void;
14
+ /** Test-only: replace the bound zone with a memory shim. */
15
+ export declare function __resetForTests(): void;
@@ -0,0 +1,59 @@
1
+ /*
2
+ * Per-user-per-browser visual overrides for apps. The store + helpers
3
+ * live separately from the shard so the state can be unit-tested without
4
+ * booting the shard system, and so the AppAppearanceModal can import
5
+ * get/set without creating an import cycle through the shard's modal
6
+ * import.
7
+ *
8
+ * EXPLICITLY TEMPORARY. A future ADR is expected to add icon/color
9
+ * fields to the app manifest itself.
10
+ */
11
+ let zoneState = null;
12
+ /** Bind the shard's zone to this module. Called by the shard's activate. */
13
+ export function __bindZone(s) {
14
+ zoneState = s;
15
+ }
16
+ /** Unbind the zone (deactivate). */
17
+ export function __unbindZone() {
18
+ zoneState = null;
19
+ }
20
+ function isEmpty(v) {
21
+ return v.icon === undefined && v.color === undefined && v.label === undefined;
22
+ }
23
+ export function getAppearance(appId) {
24
+ const map = zoneState === null || zoneState === void 0 ? void 0 : zoneState.user.overrides;
25
+ if (!map)
26
+ return undefined;
27
+ const v = map[appId];
28
+ if (!v)
29
+ return undefined;
30
+ if (isEmpty(v))
31
+ return undefined;
32
+ return v;
33
+ }
34
+ export function setAppearance(appId, value) {
35
+ if (!zoneState)
36
+ return;
37
+ const map = zoneState.user.overrides;
38
+ const next = Object.assign({}, map);
39
+ if (value === undefined || isEmpty(value)) {
40
+ delete next[appId];
41
+ }
42
+ else {
43
+ next[appId] = value;
44
+ }
45
+ zoneState.user.overrides = next;
46
+ }
47
+ /** Test-only: replace the bound zone with a memory shim. */
48
+ export function __resetForTests() {
49
+ zoneState = {
50
+ ephemeral: {},
51
+ session: {},
52
+ workspace: {},
53
+ user: { overrides: {} },
54
+ };
55
+ }
56
+ // Initialise the memory shim at module load so tests can call set/get
57
+ // without first invoking activate. Production replaces this via
58
+ // __bindZone() inside appearanceShard.activate().
59
+ __resetForTests();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { setAppearance, getAppearance, __resetForTests } from './appearanceState.svelte';
3
+ describe('appearanceShard get/set', () => {
4
+ beforeEach(() => __resetForTests());
5
+ it('returns undefined for an unset app', () => {
6
+ expect(getAppearance('foo')).toBeUndefined();
7
+ });
8
+ it('round-trips an icon override', () => {
9
+ setAppearance('foo', { icon: 'house' });
10
+ expect(getAppearance('foo')).toEqual({ icon: 'house' });
11
+ });
12
+ it('round-trips a color override', () => {
13
+ setAppearance('foo', { color: '#ff0000' });
14
+ expect(getAppearance('foo')).toEqual({ color: '#ff0000' });
15
+ });
16
+ it('round-trips a label override', () => {
17
+ setAppearance('foo', { label: 'My Label' });
18
+ expect(getAppearance('foo')).toEqual({ label: 'My Label' });
19
+ });
20
+ it('clears the entry when all fields are undefined', () => {
21
+ setAppearance('foo', { icon: 'house', color: '#ff0000', label: 'X' });
22
+ setAppearance('foo', { icon: undefined, color: undefined, label: undefined });
23
+ expect(getAppearance('foo')).toBeUndefined();
24
+ });
25
+ it('clears the entry when given undefined directly', () => {
26
+ setAppearance('foo', { icon: 'house' });
27
+ setAppearance('foo', undefined);
28
+ expect(getAppearance('foo')).toBeUndefined();
29
+ });
30
+ });
@@ -0,0 +1,3 @@
1
+ export { appearanceShard } from './appearanceShard.svelte';
2
+ export { getAppearance, setAppearance } from './appearanceState.svelte';
3
+ export type { AppAppearance } from './types';
@@ -0,0 +1,2 @@
1
+ export { appearanceShard } from './appearanceShard.svelte';
2
+ export { getAppearance, setAppearance } from './appearanceState.svelte';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Per-app per-user-per-browser visual override. Stored in the user zone
3
+ * of the __app-appearance__ shard. Every field is optional — an empty
4
+ * AppAppearance object is treated as "no override" and removed from the
5
+ * map by setAppearance().
6
+ */
7
+ export interface AppAppearance {
8
+ icon?: string;
9
+ color?: string;
10
+ label?: string;
11
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -74,6 +74,13 @@ export interface AppManifest {
74
74
  * When absent, the canonical fallback is used. See MenuContainer.
75
75
  */
76
76
  menus?: MenuContainer[];
77
+ /**
78
+ * Optional default home-card icon — a lucide icon id from the bundled
79
+ * sprite (see `src/assets/icons.svg`). User-set per-browser overrides
80
+ * (via the home-card Customize action) take precedence; if neither is
81
+ * set the framework falls back to `box`.
82
+ */
83
+ icon?: string;
77
84
  }
78
85
  /**
79
86
  * Context object passed to `App.activate`. Provides app-scoped state zones
@@ -0,0 +1,2 @@
1
+ export declare const ICON_IDS: readonly ["activity", "align-horizontal-justify-center", "align-horizontal-justify-end", "align-horizontal-justify-start", "app-window", "archive", "archive-restore", "axis-3d", "box", "brick-wall", "bug", "building-2", "cable", "calendar", "camera", "check", "chevron-down", "chevron-right", "circle-check", "circle-dot", "circle-minus", "circle-x", "clipboard", "clipboard-paste", "clock", "compass", "component", "copy", "cpu", "crop", "crosshair", "crown", "dollar-sign", "download", "droplet", "eraser", "euro", "external-link", "eye", "eye-off", "file", "file-archive", "file-diff", "file-plus", "file-text", "flame", "flip-horizontal-2", "flip-vertical-2", "folder", "folder-open", "folder-plus", "folder-tree", "gallery-vertical-end", "gamepad-2", "gauge", "gem", "git-branch", "git-commit-horizontal", "git-merge", "globe", "grid-2x2", "grid-3x3", "group", "hard-drive", "heart", "history", "house", "image", "info", "joystick", "key", "layers", "layout-dashboard", "layout-grid", "layout-list", "layout-panel-left", "layout-panel-top", "layout-template", "lightbulb", "link", "list-ordered", "list-tree", "lock", "log-out", "magnet", "mail", "map", "maximize", "minimize", "moon", "mouse-pointer", "move", "move-3d", "music", "navigation", "network", "notebook-pen", "palette", "pause", "pencil", "pipette", "play", "plus", "pointer", "pound-sterling", "receipt", "redo-2", "refresh-cw", "rocket", "rotate-3d", "rotate-ccw", "rotate-cw", "ruler", "save", "scissors", "scroll-text", "search", "send", "server", "settings", "shield", "skull", "sliders-horizontal", "snowflake", "sparkles", "square", "square-terminal", "star", "sun", "sword", "table-properties", "target", "texture", "timer", "trash-2", "triangle-alert", "type", "undo-2", "ungroup", "unity", "upload", "user", "users", "video", "volume-2", "wand-sparkles", "wind", "x", "zap", "zoom-in", "zoom-out"];
2
+ export type IconId = (typeof ICON_IDS)[number];
@@ -0,0 +1,154 @@
1
+ // GENERATED — do not edit. See scripts/sync-icon-ids.ts
2
+ export const ICON_IDS = [
3
+ 'activity',
4
+ 'align-horizontal-justify-center',
5
+ 'align-horizontal-justify-end',
6
+ 'align-horizontal-justify-start',
7
+ 'app-window',
8
+ 'archive',
9
+ 'archive-restore',
10
+ 'axis-3d',
11
+ 'box',
12
+ 'brick-wall',
13
+ 'bug',
14
+ 'building-2',
15
+ 'cable',
16
+ 'calendar',
17
+ 'camera',
18
+ 'check',
19
+ 'chevron-down',
20
+ 'chevron-right',
21
+ 'circle-check',
22
+ 'circle-dot',
23
+ 'circle-minus',
24
+ 'circle-x',
25
+ 'clipboard',
26
+ 'clipboard-paste',
27
+ 'clock',
28
+ 'compass',
29
+ 'component',
30
+ 'copy',
31
+ 'cpu',
32
+ 'crop',
33
+ 'crosshair',
34
+ 'crown',
35
+ 'dollar-sign',
36
+ 'download',
37
+ 'droplet',
38
+ 'eraser',
39
+ 'euro',
40
+ 'external-link',
41
+ 'eye',
42
+ 'eye-off',
43
+ 'file',
44
+ 'file-archive',
45
+ 'file-diff',
46
+ 'file-plus',
47
+ 'file-text',
48
+ 'flame',
49
+ 'flip-horizontal-2',
50
+ 'flip-vertical-2',
51
+ 'folder',
52
+ 'folder-open',
53
+ 'folder-plus',
54
+ 'folder-tree',
55
+ 'gallery-vertical-end',
56
+ 'gamepad-2',
57
+ 'gauge',
58
+ 'gem',
59
+ 'git-branch',
60
+ 'git-commit-horizontal',
61
+ 'git-merge',
62
+ 'globe',
63
+ 'grid-2x2',
64
+ 'grid-3x3',
65
+ 'group',
66
+ 'hard-drive',
67
+ 'heart',
68
+ 'history',
69
+ 'house',
70
+ 'image',
71
+ 'info',
72
+ 'joystick',
73
+ 'key',
74
+ 'layers',
75
+ 'layout-dashboard',
76
+ 'layout-grid',
77
+ 'layout-list',
78
+ 'layout-panel-left',
79
+ 'layout-panel-top',
80
+ 'layout-template',
81
+ 'lightbulb',
82
+ 'link',
83
+ 'list-ordered',
84
+ 'list-tree',
85
+ 'lock',
86
+ 'log-out',
87
+ 'magnet',
88
+ 'mail',
89
+ 'map',
90
+ 'maximize',
91
+ 'minimize',
92
+ 'moon',
93
+ 'mouse-pointer',
94
+ 'move',
95
+ 'move-3d',
96
+ 'music',
97
+ 'navigation',
98
+ 'network',
99
+ 'notebook-pen',
100
+ 'palette',
101
+ 'pause',
102
+ 'pencil',
103
+ 'pipette',
104
+ 'play',
105
+ 'plus',
106
+ 'pointer',
107
+ 'pound-sterling',
108
+ 'receipt',
109
+ 'redo-2',
110
+ 'refresh-cw',
111
+ 'rocket',
112
+ 'rotate-3d',
113
+ 'rotate-ccw',
114
+ 'rotate-cw',
115
+ 'ruler',
116
+ 'save',
117
+ 'scissors',
118
+ 'scroll-text',
119
+ 'search',
120
+ 'send',
121
+ 'server',
122
+ 'settings',
123
+ 'shield',
124
+ 'skull',
125
+ 'sliders-horizontal',
126
+ 'snowflake',
127
+ 'sparkles',
128
+ 'square',
129
+ 'square-terminal',
130
+ 'star',
131
+ 'sun',
132
+ 'sword',
133
+ 'table-properties',
134
+ 'target',
135
+ 'texture',
136
+ 'timer',
137
+ 'trash-2',
138
+ 'triangle-alert',
139
+ 'type',
140
+ 'undo-2',
141
+ 'ungroup',
142
+ 'unity',
143
+ 'upload',
144
+ 'user',
145
+ 'users',
146
+ 'video',
147
+ 'volume-2',
148
+ 'wand-sparkles',
149
+ 'wind',
150
+ 'x',
151
+ 'zap',
152
+ 'zoom-in',
153
+ 'zoom-out',
154
+ ];
package/dist/host.js CHANGED
@@ -23,6 +23,7 @@ import { sh3coreShard } from './sh3core-shard/sh3coreShard.svelte';
23
23
  import { shellShard } from './shell-shard/shellShard.svelte';
24
24
  import { storeShard } from './app/store/storeShard.svelte';
25
25
  import { projectsShard } from './projects-shard/projectsShard.svelte';
26
+ import { appearanceShard } from './app-appearance';
26
27
  import { __setBackend, backends } from './state/zones.svelte';
27
28
  import { loadInstalledPackages } from './registry/installer';
28
29
  import { setLocalOwner } from './auth/index';
@@ -67,7 +68,7 @@ export async function bootstrap(config) {
67
68
  }
68
69
  const exShards = new Set(config === null || config === void 0 ? void 0 : config.excludeShards);
69
70
  // 1. Framework-owned shards
70
- const frameworkShards = [sh3coreShard, shellShard, storeShard, adminShard, projectsShard];
71
+ const frameworkShards = [sh3coreShard, shellShard, storeShard, adminShard, projectsShard, appearanceShard];
71
72
  for (const shard of frameworkShards) {
72
73
  if (!exShards.has(shard.manifest.id)) {
73
74
  registerShardInternal(shard);
@@ -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 {};