sh3-core 0.10.4 → 0.11.2

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 (122) hide show
  1. package/dist/Shell.svelte +12 -31
  2. package/dist/__test__/fixtures.js +1 -0
  3. package/dist/__test__/reset.js +6 -0
  4. package/dist/actions/CommandPalette.svelte +68 -0
  5. package/dist/actions/CommandPalette.svelte.d.ts +11 -0
  6. package/dist/actions/ContextMenu.svelte +97 -0
  7. package/dist/actions/ContextMenu.svelte.d.ts +9 -0
  8. package/dist/actions/bindings-store.d.ts +8 -0
  9. package/dist/actions/bindings-store.js +27 -0
  10. package/dist/actions/bindings-store.test.d.ts +1 -0
  11. package/dist/actions/bindings-store.test.js +25 -0
  12. package/dist/actions/bindings.d.ts +4 -0
  13. package/dist/actions/bindings.js +17 -0
  14. package/dist/actions/bindings.test.d.ts +1 -0
  15. package/dist/actions/bindings.test.js +30 -0
  16. package/dist/actions/contextMenuModel.d.ts +16 -0
  17. package/dist/actions/contextMenuModel.js +71 -0
  18. package/dist/actions/contextMenuModel.test.d.ts +1 -0
  19. package/dist/actions/contextMenuModel.test.js +44 -0
  20. package/dist/actions/dispatcher.svelte.d.ts +34 -0
  21. package/dist/actions/dispatcher.svelte.js +117 -0
  22. package/dist/actions/dispatcher.test.d.ts +1 -0
  23. package/dist/actions/dispatcher.test.js +155 -0
  24. package/dist/actions/listeners.d.ts +11 -0
  25. package/dist/actions/listeners.js +180 -0
  26. package/dist/actions/listeners.test.d.ts +1 -0
  27. package/dist/actions/listeners.test.js +149 -0
  28. package/dist/actions/palette-scorer.d.ts +11 -0
  29. package/dist/actions/palette-scorer.js +49 -0
  30. package/dist/actions/palette-scorer.test.d.ts +1 -0
  31. package/dist/actions/palette-scorer.test.js +40 -0
  32. package/dist/actions/paletteModel.d.ts +4 -0
  33. package/dist/actions/paletteModel.js +40 -0
  34. package/dist/actions/paletteModel.test.d.ts +1 -0
  35. package/dist/actions/paletteModel.test.js +33 -0
  36. package/dist/actions/registry.d.ts +10 -0
  37. package/dist/actions/registry.js +36 -0
  38. package/dist/actions/registry.test.d.ts +1 -0
  39. package/dist/actions/registry.test.js +49 -0
  40. package/dist/actions/selection.svelte.d.ts +8 -0
  41. package/dist/actions/selection.svelte.js +44 -0
  42. package/dist/actions/selection.test.d.ts +1 -0
  43. package/dist/actions/selection.test.js +51 -0
  44. package/dist/actions/shardContext.test.d.ts +1 -0
  45. package/dist/actions/shardContext.test.js +41 -0
  46. package/dist/actions/shellActions.test.d.ts +1 -0
  47. package/dist/actions/shellActions.test.js +22 -0
  48. package/dist/actions/shortcuts.d.ts +5 -0
  49. package/dist/actions/shortcuts.js +87 -0
  50. package/dist/actions/shortcuts.test.d.ts +1 -0
  51. package/dist/actions/shortcuts.test.js +49 -0
  52. package/dist/actions/state.svelte.d.ts +16 -0
  53. package/dist/actions/state.svelte.js +76 -0
  54. package/dist/actions/state.test.d.ts +1 -0
  55. package/dist/actions/state.test.js +40 -0
  56. package/dist/actions/syncMountedViewIds.test.d.ts +1 -0
  57. package/dist/actions/syncMountedViewIds.test.js +97 -0
  58. package/dist/actions/types.d.ts +56 -0
  59. package/dist/actions/types.js +7 -0
  60. package/dist/api.d.ts +2 -2
  61. package/dist/api.js +1 -1
  62. package/dist/apps/lifecycle.js +13 -3
  63. package/dist/createShell.js +4 -1
  64. package/dist/host.js +6 -3
  65. package/dist/index.d.ts +2 -0
  66. package/dist/index.js +2 -0
  67. package/dist/layout/LayoutRenderer.browser.test.js +78 -0
  68. package/dist/layout/LayoutRenderer.svelte +1 -0
  69. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-6-fixed-slots-freezes-the-handle-adjacent-to-a-fixed-pane--dblclick-does-not-collapse-1.png +0 -0
  70. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-6-fixed-slots-hides-the-collapse-widget-on-a-fixed-pane-1.png +0 -0
  71. package/dist/layout/inspection.d.ts +11 -1
  72. package/dist/layout/inspection.js +13 -1
  73. package/dist/layout/ops-locate.test.d.ts +1 -0
  74. package/dist/layout/ops-locate.test.js +103 -0
  75. package/dist/layout/ops.d.ts +8 -0
  76. package/dist/layout/ops.js +27 -0
  77. package/dist/layout/slotHostPool.svelte.js +24 -0
  78. package/dist/layout/slotHostPool.test.js +14 -0
  79. package/dist/layout/types.d.ts +15 -0
  80. package/dist/overlays/FloatFrame.svelte +23 -11
  81. package/dist/overlays/ModalFrame.svelte +9 -1
  82. package/dist/overlays/ModalFrame.svelte.d.ts +1 -0
  83. package/dist/overlays/__test__/DummyFrame.svelte +6 -0
  84. package/dist/overlays/__test__/DummyFrame.svelte.d.ts +6 -0
  85. package/dist/overlays/float.d.ts +6 -0
  86. package/dist/overlays/float.js +24 -9
  87. package/dist/overlays/float.test.js +175 -0
  88. package/dist/overlays/floatDismiss.d.ts +8 -0
  89. package/dist/overlays/floatDismiss.js +68 -0
  90. package/dist/overlays/modal.js +5 -1
  91. package/dist/overlays/modal.test.d.ts +1 -0
  92. package/dist/overlays/modal.test.js +55 -0
  93. package/dist/overlays/popup.d.ts +2 -0
  94. package/dist/overlays/popup.js +24 -4
  95. package/dist/overlays/popup.test.d.ts +1 -0
  96. package/dist/overlays/popup.test.js +95 -0
  97. package/dist/overlays/types.d.ts +17 -1
  98. package/dist/primitives/Button.svelte +144 -0
  99. package/dist/primitives/Button.svelte.d.ts +18 -0
  100. package/dist/primitives/ResizableSplitter.svelte +38 -3
  101. package/dist/primitives/ResizableSplitter.svelte.d.ts +7 -0
  102. package/dist/primitives/icon-context.d.ts +15 -0
  103. package/dist/primitives/icon-context.js +29 -0
  104. package/dist/sh3core-shard/sh3coreShard.svelte.js +50 -0
  105. package/dist/shards/activate.svelte.js +14 -0
  106. package/dist/shards/types.d.ts +19 -0
  107. package/dist/shards/types.js +5 -4
  108. package/dist/shell-shard/locateSlot.test.d.ts +1 -0
  109. package/dist/shell-shard/locateSlot.test.js +101 -0
  110. package/dist/shell-shard/shellShard.svelte.d.ts +7 -0
  111. package/dist/shell-shard/shellShard.svelte.js +34 -1
  112. package/dist/shellRuntime.svelte.d.ts +19 -0
  113. package/dist/shellRuntime.svelte.js +30 -0
  114. package/dist/tokens.css +11 -1
  115. package/dist/verbs/types.d.ts +9 -0
  116. package/dist/version.d.ts +1 -1
  117. package/dist/version.js +1 -1
  118. package/package.json +1 -1
  119. package/dist/apps/terminal/manifest.d.ts +0 -8
  120. package/dist/apps/terminal/manifest.js +0 -14
  121. package/dist/apps/terminal/terminal-app.d.ts +0 -7
  122. package/dist/apps/terminal/terminal-app.js +0 -14
@@ -93,6 +93,33 @@ export function findTabInTree(tree, slotId) {
93
93
  }
94
94
  return null;
95
95
  }
96
+ /**
97
+ * Locate the root a slot currently lives under in the given tree. Handles
98
+ * both tab entries and bare slot leaves, docked first then each float's
99
+ * content. Returns null when the slot is not present anywhere. Pure —
100
+ * takes the tree as input so callers with an in-hand tree (and tests)
101
+ * don't need the layout store.
102
+ */
103
+ export function locateSlotIn(tree, slotId) {
104
+ if (containsSlot(tree.docked, slotId))
105
+ return { kind: 'docked' };
106
+ for (const f of tree.floats) {
107
+ if (containsSlot(f.content, slotId))
108
+ return { kind: 'float', floatId: f.id };
109
+ }
110
+ return null;
111
+ }
112
+ function containsSlot(node, slotId) {
113
+ if (node.type === 'slot')
114
+ return node.slotId === slotId;
115
+ if (node.type === 'tabs')
116
+ return node.tabs.some((t) => t.slotId === slotId);
117
+ for (const child of node.children) {
118
+ if (containsSlot(child, slotId))
119
+ return true;
120
+ }
121
+ return false;
122
+ }
96
123
  // ---------- Tab removal ----------------------------------------------------
97
124
  /**
98
125
  * Remove a tab from its current location, returning the removed entry
@@ -33,6 +33,8 @@
33
33
  * phase 6 has no view that needs it.
34
34
  */
35
35
  import { getView, __addViewRegistrationListener } from '../shards/registry';
36
+ import { locateSlotIn } from './ops';
37
+ import { activeLayout } from './store.svelte';
36
38
  const pool = new Map();
37
39
  const pendingDestroy = new Set();
38
40
  /**
@@ -59,6 +61,9 @@ function onViewRegistered(viewId, factory) {
59
61
  setDirty(dirty) {
60
62
  dirtyState[slotId] = dirty;
61
63
  },
64
+ location() {
65
+ return locateSlotIn(activeLayout(), slotId);
66
+ },
62
67
  };
63
68
  queueMicrotask(() => {
64
69
  var _a, _b;
@@ -129,6 +134,8 @@ function createHost(slotId, viewId, label, meta) {
129
134
  const host = document.createElement('div');
130
135
  host.className = 'slot-host';
131
136
  host.dataset.slotId = slotId;
137
+ if (viewId)
138
+ host.setAttribute('data-sh3-view', viewId);
132
139
  // Position:absolute inset:0 so the host fills whichever wrapper it is
133
140
  // attached to. The wrapper is what the layout engine sizes; the host
134
141
  // just tracks it. Styles are set inline (not in a class) so consumers
@@ -163,6 +170,9 @@ function createHost(slotId, viewId, label, meta) {
163
170
  setDirty(dirty) {
164
171
  dirtyState[slotId] = dirty;
165
172
  },
173
+ location() {
174
+ return locateSlotIn(activeLayout(), slotId);
175
+ },
166
176
  };
167
177
  entry.handle = factory === null || factory === void 0 ? void 0 : factory.mount(host, ctx);
168
178
  if (((_a = entry.handle) === null || _a === void 0 ? void 0 : _a.closable) || slotId.startsWith('float:')) {
@@ -201,6 +211,20 @@ export function acquireSlotHost(slotId, viewId, label, meta) {
201
211
  entry = createHost(slotId, viewId, label, meta);
202
212
  pool.set(slotId, entry);
203
213
  }
214
+ else if (entry.viewId !== viewId) {
215
+ // viewId on an existing slot should be stable — the pool does not support
216
+ // swapping views for the same slotId. Keep the focus-tracking attribute
217
+ // in sync with whatever we have and warn; the underlying view handle will
218
+ // not be reconstructed.
219
+ console.warn(`[sh3] acquireSlotHost("${slotId}") called with viewId "${viewId}" ` +
220
+ `but existing pooled entry has viewId "${entry.viewId}". Attribute synced; ` +
221
+ `view handle unchanged.`);
222
+ entry.viewId = viewId;
223
+ if (viewId)
224
+ entry.host.setAttribute('data-sh3-view', viewId);
225
+ else
226
+ entry.host.removeAttribute('data-sh3-view');
227
+ }
204
228
  entry.refcount++;
205
229
  return entry.host;
206
230
  }
@@ -102,3 +102,17 @@ describe('slotHostPool — D.5 root swap preserves app slots', () => {
102
102
  expect(teardown).not.toHaveBeenCalled();
103
103
  });
104
104
  });
105
+ // ─── D.6 ─────────────────────────────────────────────────────────────────────
106
+ describe('slotHostPool — D.6 data-sh3-view attribute', () => {
107
+ beforeEach(resetFramework);
108
+ it('pooled host has data-sh3-view when viewId is set', () => {
109
+ const host = acquireSlotHost('slot-1', 'editor', 'Editor');
110
+ expect(host.getAttribute('data-sh3-view')).toBe('editor');
111
+ releaseSlotHost('slot-1');
112
+ });
113
+ it('pooled host has no data-sh3-view when viewId is null', () => {
114
+ const host = acquireSlotHost('slot-2', null, 'Empty');
115
+ expect(host.hasAttribute('data-sh3-view')).toBe(false);
116
+ releaseSlotHost('slot-2');
117
+ });
118
+ });
@@ -21,6 +21,14 @@ export interface SplitNode {
21
21
  pinned?: SizeMode[];
22
22
  /** Per-child collapsed state. Omitted means all expanded. */
23
23
  collapsed?: boolean[];
24
+ /**
25
+ * Per-child fixed flag. A fixed child has no collapse widget and the
26
+ * resize handles on either side of it are frozen (non-interactive,
27
+ * rendered thinner). A non-fixed child whose every neighbor is fixed
28
+ * also loses its collapse widget — there's nowhere for the freed
29
+ * space to be used.
30
+ */
31
+ fixed?: boolean[];
24
32
  /** Ordered child nodes. Length must equal `sizes` length. */
25
33
  children: LayoutNode[];
26
34
  }
@@ -107,6 +115,13 @@ export interface FloatEntry {
107
115
  };
108
116
  /** Optional human-readable title; defaults to the active view's label. */
109
117
  title?: string;
118
+ /**
119
+ * When true, this float dismisses on any pointerdown outside its frame,
120
+ * renders its content as a raw slot (no tab-strip handle, not dockable),
121
+ * and hides chrome when `title` is unset. See
122
+ * docs/superpowers/specs/2026-04-21-dismissable-float-design.md.
123
+ */
124
+ dismissable?: boolean;
110
125
  }
111
126
  /**
112
127
  * Root shape of a workspace layout. The docked tree is the primary
@@ -18,6 +18,7 @@
18
18
  <script lang="ts">
19
19
  import LayoutRenderer from '../layout/LayoutRenderer.svelte';
20
20
  import { floatManager } from './float';
21
+ import { registerDismissableFrame, unregisterDismissableFrame } from './floatDismiss';
21
22
  import type { FloatEntry } from '../layout/types';
22
23
 
23
24
  interface Props {
@@ -27,6 +28,14 @@
27
28
 
28
29
  let dragging = $state(false);
29
30
  let dragOffset = { x: 0, y: 0 };
31
+ let frameEl: HTMLDivElement | undefined = $state();
32
+
33
+ $effect(() => {
34
+ if (!entry.dismissable) return;
35
+ if (!frameEl) return;
36
+ registerDismissableFrame(entry.id, frameEl);
37
+ return () => unregisterDismissableFrame(entry.id);
38
+ });
30
39
 
31
40
  function onHeaderPointerDown(e: PointerEvent): void {
32
41
  if (e.button !== 0) return;
@@ -67,6 +76,7 @@
67
76
  <!-- svelte-ignore a11y_click_events_have_key_events -->
68
77
  <div
69
78
  class="sh3-float-frame"
79
+ bind:this={frameEl}
70
80
  style:left="{entry.position.x}px"
71
81
  style:top="{entry.position.y}px"
72
82
  style:width="{entry.size.w}px"
@@ -76,17 +86,19 @@
76
86
  aria-label={entry.title ?? 'Float panel'}
77
87
  tabindex="-1"
78
88
  >
79
- <!-- svelte-ignore a11y_no_static_element_interactions -->
80
- <header
81
- class="sh3-float-header"
82
- onpointerdown={onHeaderPointerDown}
83
- onpointermove={onHeaderPointerMove}
84
- onpointerup={onHeaderPointerUp}
85
- onpointercancel={onHeaderPointerUp}
86
- >
87
- <span class="sh3-float-title">{entry.title ?? entry.content.type}</span>
88
- <button class="sh3-float-close" onclick={onClose} aria-label="Close float">×</button>
89
- </header>
89
+ {#if entry.title}
90
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
91
+ <header
92
+ class="sh3-float-header"
93
+ onpointerdown={onHeaderPointerDown}
94
+ onpointermove={onHeaderPointerMove}
95
+ onpointerup={onHeaderPointerUp}
96
+ onpointercancel={onHeaderPointerUp}
97
+ >
98
+ <span class="sh3-float-title">{entry.title}</span>
99
+ <button class="sh3-float-close" onclick={onClose} aria-label="Close float">×</button>
100
+ </header>
101
+ {/if}
90
102
  <div class="sh3-float-body">
91
103
  <LayoutRenderer rootRef={{ kind: 'float', floatId: entry.id }} path={[]} />
92
104
  </div>
@@ -34,11 +34,13 @@
34
34
  contentProps,
35
35
  close,
36
36
  boxStyle,
37
+ onBackdropClick,
37
38
  }: {
38
39
  Content: Component<Record<string, unknown>>;
39
40
  contentProps: Record<string, unknown>;
40
41
  close: () => void;
41
42
  boxStyle?: string;
43
+ onBackdropClick?: () => void;
42
44
  } = $props();
43
45
 
44
46
  let box: HTMLDivElement;
@@ -47,9 +49,15 @@
47
49
  if (!box) return;
48
50
  return createFocusTrap(box);
49
51
  });
52
+
53
+ function handleFrameClick(ev: MouseEvent): void {
54
+ if (!onBackdropClick) return;
55
+ if (ev.target !== ev.currentTarget) return; // descendant click — let it through
56
+ onBackdropClick();
57
+ }
50
58
  </script>
51
59
 
52
- <div class="modal-frame" role="presentation">
60
+ <div class="modal-frame" role="presentation" onclick={handleFrameClick}>
53
61
  <div
54
62
  class="modal-box"
55
63
  role="dialog"
@@ -4,6 +4,7 @@ type $$ComponentProps = {
4
4
  contentProps: Record<string, unknown>;
5
5
  close: () => void;
6
6
  boxStyle?: string;
7
+ onBackdropClick?: () => void;
7
8
  };
8
9
  declare const ModalFrame: Component<$$ComponentProps, {}, "">;
9
10
  type ModalFrame = ReturnType<typeof ModalFrame>;
@@ -0,0 +1,6 @@
1
+ <script lang="ts">
2
+ // Minimal component for tests that need a popupManager.show() target.
3
+ // Popup infrastructure passes `close` as a prop; we accept it but don't use it here.
4
+ let { close: _close }: { close: () => void } = $props();
5
+ </script>
6
+ <div class="dummy-popup-content" style="width:10px;height:10px"></div>
@@ -0,0 +1,6 @@
1
+ type $$ComponentProps = {
2
+ close: () => void;
3
+ };
4
+ declare const DummyFrame: import("svelte").Component<$$ComponentProps, {}, "">;
5
+ type DummyFrame = ReturnType<typeof DummyFrame>;
6
+ export default DummyFrame;
@@ -9,6 +9,12 @@ export interface FloatOptions {
9
9
  size?: Size;
10
10
  /** Instance data threaded to the view factory via `MountContext.meta`. */
11
11
  meta?: Record<string, unknown>;
12
+ /**
13
+ * When true, the float dismisses on any pointerdown outside its frame,
14
+ * is not dockable, and renders without a header when `title` is unset.
15
+ * See docs/superpowers/specs/2026-04-21-dismissable-float-design.md.
16
+ */
17
+ dismissable?: boolean;
12
18
  }
13
19
  export interface FloatManager {
14
20
  open(viewId: string, options?: FloatOptions): string;
@@ -73,15 +73,28 @@ function openFloat(viewId, options = {}) {
73
73
  // docked tree. The TabsNode's tab strip appears at the top of the
74
74
  // float body; the frame header still moves the float as a whole.
75
75
  const slotId = mintFloatSlotId(viewId);
76
- const label = (_a = options.title) !== null && _a !== void 0 ? _a : viewId;
77
- const tab = { slotId, viewId, label };
78
- if (options.meta)
79
- tab.meta = options.meta;
80
- const content = {
81
- type: 'tabs',
82
- tabs: [tab],
83
- activeTab: 0,
84
- };
76
+ let content;
77
+ if (options.dismissable) {
78
+ // Picker float: render the view directly as a leaf slot. No tab strip,
79
+ // no tabs wrapper, no drag-to-dock handle. Note: options.meta cannot
80
+ // thread through a bare SlotNode — only TabEntry carries meta.
81
+ content = {
82
+ type: 'slot',
83
+ slotId,
84
+ viewId,
85
+ };
86
+ }
87
+ else {
88
+ const label = (_a = options.title) !== null && _a !== void 0 ? _a : viewId;
89
+ const tab = { slotId, viewId, label };
90
+ if (options.meta)
91
+ tab.meta = options.meta;
92
+ content = {
93
+ type: 'tabs',
94
+ tabs: [tab],
95
+ activeTab: 0,
96
+ };
97
+ }
85
98
  const computedMin = computeMinSize(content);
86
99
  const size = (_b = options.size) !== null && _b !== void 0 ? _b : maxSize(DEFAULT_SIZE, computedMin);
87
100
  const position = (_c = options.position) !== null && _c !== void 0 ? _c : cascadePosition(store, getTreeBounds());
@@ -92,6 +105,8 @@ function openFloat(viewId, options = {}) {
92
105
  size,
93
106
  title: options.title,
94
107
  };
108
+ if (options.dismissable)
109
+ entry.dismissable = true;
95
110
  store.push(entry);
96
111
  return id;
97
112
  }
@@ -54,6 +54,31 @@ describe('floatManager', () => {
54
54
  expect(tabs.tabs[0].meta).toBeUndefined();
55
55
  }
56
56
  });
57
+ it('open() persists options.dismissable onto the FloatEntry', () => {
58
+ const id = floatManager.open('test:view', { dismissable: true });
59
+ const f = floatManager.list().find((e) => e.id === id);
60
+ expect(f.dismissable).toBe(true);
61
+ });
62
+ it('open() without options.dismissable leaves FloatEntry.dismissable undefined', () => {
63
+ const id = floatManager.open('test:view');
64
+ const f = floatManager.list().find((e) => e.id === id);
65
+ expect(f.dismissable).toBeUndefined();
66
+ });
67
+ it('open() with dismissable=true places content as a raw slot (no tabs wrap)', () => {
68
+ const id = floatManager.open('test:view', { dismissable: true });
69
+ const f = floatManager.list().find((e) => e.id === id);
70
+ expect(f.content.type).toBe('slot');
71
+ if (f.content.type === 'slot') {
72
+ expect(f.content.viewId).toBe('test:view');
73
+ expect(typeof f.content.slotId).toBe('string');
74
+ expect(f.content.slotId.length).toBeGreaterThan(0);
75
+ }
76
+ });
77
+ it('open() without dismissable still wraps content in a TabsNode', () => {
78
+ const id = floatManager.open('test:view');
79
+ const f = floatManager.list().find((e) => e.id === id);
80
+ expect(f.content.type).toBe('tabs');
81
+ });
57
82
  });
58
83
  // ---------------------------------------------------------------------------
59
84
  // DOM tests — floatManager + FloatLayer.svelte in happy-dom
@@ -62,6 +87,7 @@ import { renderWithShell } from '../__test__/render';
62
87
  import FloatLayer from './FloatLayer.svelte';
63
88
  import { tick } from 'svelte';
64
89
  import { resetFramework } from '../__test__/reset';
90
+ import { __isDismissListenerAttachedForTest } from './floatDismiss';
65
91
  /**
66
92
  * Wire the floatManager to the same FloatEntry[] that FloatLayer reads
67
93
  * (layoutStore.floats → HOME_TREE.floats). Without this binding, the
@@ -136,3 +162,152 @@ describe('floats — F.3 close button removes float', () => {
136
162
  expect(container.querySelector('[role="dialog"][aria-label="Closeable"]')).toBeNull();
137
163
  });
138
164
  });
165
+ // ---------------------------------------------------------------------------
166
+ // F.4 — header conditional on title
167
+ // ---------------------------------------------------------------------------
168
+ describe('floats — F.4 header conditional on title', () => {
169
+ beforeEach(() => {
170
+ resetFramework();
171
+ bindManagerToStore();
172
+ });
173
+ it('renders no header when title is not provided', async () => {
174
+ const { container } = renderWithShell(FloatLayer, {});
175
+ floatManager.open('test:view', { dismissable: true });
176
+ await tick();
177
+ const frame = container.querySelector('[role="dialog"]');
178
+ expect(frame).toBeTruthy();
179
+ expect(frame.querySelector('.sh3-float-header')).toBeNull();
180
+ expect(frame.querySelector('.sh3-float-close')).toBeNull();
181
+ });
182
+ it('renders the header with close button when title is provided', async () => {
183
+ const { container } = renderWithShell(FloatLayer, {});
184
+ floatManager.open('test:view', { dismissable: true, title: 'Picker' });
185
+ await tick();
186
+ const frame = container.querySelector('[role="dialog"][aria-label="Picker"]');
187
+ expect(frame).toBeTruthy();
188
+ expect(frame.querySelector('.sh3-float-header')).not.toBeNull();
189
+ expect(frame.querySelector('button[aria-label="Close float"]')).not.toBeNull();
190
+ });
191
+ });
192
+ // ---------------------------------------------------------------------------
193
+ // F.5a — dismiss-listener lifecycle
194
+ // ---------------------------------------------------------------------------
195
+ describe('floats — F.5a dismiss-listener lifecycle', () => {
196
+ beforeEach(() => {
197
+ resetFramework();
198
+ bindManagerToStore();
199
+ });
200
+ it('attaches the document listener when a dismissable frame mounts', async () => {
201
+ renderWithShell(FloatLayer, {});
202
+ expect(__isDismissListenerAttachedForTest()).toBe(false);
203
+ floatManager.open('test:view', { dismissable: true });
204
+ await tick();
205
+ expect(__isDismissListenerAttachedForTest()).toBe(true);
206
+ });
207
+ it('detaches the document listener when the last dismissable frame unmounts', async () => {
208
+ renderWithShell(FloatLayer, {});
209
+ const id = floatManager.open('test:view', { dismissable: true });
210
+ await tick();
211
+ expect(__isDismissListenerAttachedForTest()).toBe(true);
212
+ floatManager.close(id);
213
+ await tick();
214
+ expect(__isDismissListenerAttachedForTest()).toBe(false);
215
+ });
216
+ it('does not attach the listener for non-dismissable floats', async () => {
217
+ renderWithShell(FloatLayer, {});
218
+ floatManager.open('test:view', { title: 'Regular' });
219
+ await tick();
220
+ expect(__isDismissListenerAttachedForTest()).toBe(false);
221
+ });
222
+ });
223
+ // ---------------------------------------------------------------------------
224
+ // F.5b — outside-pointerdown dismisses picker
225
+ // ---------------------------------------------------------------------------
226
+ describe('floats — F.5b outside-pointerdown dismisses picker', () => {
227
+ beforeEach(() => {
228
+ resetFramework();
229
+ bindManagerToStore();
230
+ });
231
+ function pointerDown(el) {
232
+ el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
233
+ }
234
+ it('closes a dismissable float when pointerdown fires outside its frame', async () => {
235
+ const { container } = renderWithShell(FloatLayer, {});
236
+ const id = floatManager.open('test:view', { dismissable: true, title: 'Picker' });
237
+ await tick();
238
+ expect(floatManager.list().some((f) => f.id === id)).toBe(true);
239
+ pointerDown(document.body);
240
+ await tick();
241
+ expect(floatManager.list().some((f) => f.id === id)).toBe(false);
242
+ expect(container.querySelector('[role="dialog"][aria-label="Picker"]')).toBeNull();
243
+ });
244
+ it('does not close a dismissable float when pointerdown fires inside its frame', async () => {
245
+ const { container } = renderWithShell(FloatLayer, {});
246
+ const id = floatManager.open('test:view', { dismissable: true, title: 'Picker' });
247
+ await tick();
248
+ const frame = container.querySelector('[role="dialog"][aria-label="Picker"]');
249
+ expect(frame).toBeTruthy();
250
+ pointerDown(frame);
251
+ await tick();
252
+ expect(floatManager.list().some((f) => f.id === id)).toBe(true);
253
+ });
254
+ it('does not close a non-dismissable float on outside pointerdown', async () => {
255
+ renderWithShell(FloatLayer, {});
256
+ const id = floatManager.open('test:view', { title: 'Regular' });
257
+ await tick();
258
+ pointerDown(document.body);
259
+ await tick();
260
+ expect(floatManager.list().some((f) => f.id === id)).toBe(true);
261
+ });
262
+ });
263
+ // ---------------------------------------------------------------------------
264
+ // F.6 — multi-picker interaction
265
+ // ---------------------------------------------------------------------------
266
+ describe('floats — F.6 multi-picker interaction', () => {
267
+ beforeEach(() => {
268
+ resetFramework();
269
+ bindManagerToStore();
270
+ });
271
+ function pointerDown(el) {
272
+ el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
273
+ }
274
+ it('clicking inside picker A closes picker B but keeps A', async () => {
275
+ const { container } = renderWithShell(FloatLayer, {});
276
+ const a = floatManager.open('test:view', { dismissable: true, title: 'A' });
277
+ const b = floatManager.open('test:view', { dismissable: true, title: 'B' });
278
+ await tick();
279
+ const frameA = container.querySelector('[role="dialog"][aria-label="A"]');
280
+ expect(frameA).toBeTruthy();
281
+ pointerDown(frameA);
282
+ await tick();
283
+ const ids = floatManager.list().map((f) => f.id);
284
+ expect(ids).toContain(a);
285
+ expect(ids).not.toContain(b);
286
+ });
287
+ it('clicking a persistent float closes every open picker', async () => {
288
+ const { container } = renderWithShell(FloatLayer, {});
289
+ const reg = floatManager.open('test:view', { title: 'Regular' });
290
+ const picker1 = floatManager.open('test:view', { dismissable: true, title: 'P1' });
291
+ const picker2 = floatManager.open('test:view', { dismissable: true, title: 'P2' });
292
+ await tick();
293
+ const frameReg = container.querySelector('[role="dialog"][aria-label="Regular"]');
294
+ expect(frameReg).toBeTruthy();
295
+ pointerDown(frameReg);
296
+ await tick();
297
+ const ids = floatManager.list().map((f) => f.id);
298
+ expect(ids).toContain(reg);
299
+ expect(ids).not.toContain(picker1);
300
+ expect(ids).not.toContain(picker2);
301
+ });
302
+ it('closing a picker via its × button fires exactly one close (no double-close from listener)', async () => {
303
+ const { container } = renderWithShell(FloatLayer, {});
304
+ const id = floatManager.open('test:view', { dismissable: true, title: 'Picker' });
305
+ await tick();
306
+ const closeBtn = container.querySelector('[role="dialog"][aria-label="Picker"] button[aria-label="Close float"]');
307
+ expect(closeBtn).toBeTruthy();
308
+ pointerDown(closeBtn);
309
+ closeBtn.click();
310
+ await tick();
311
+ expect(floatManager.list().some((f) => f.id === id)).toBe(false);
312
+ });
313
+ });
@@ -0,0 +1,8 @@
1
+ /** Register a dismissable float's root element. Idempotent per id. */
2
+ export declare function registerDismissableFrame(id: string, el: HTMLElement): void;
3
+ /** Unregister on frame destroy. Idempotent; safe to call for unknown ids. */
4
+ export declare function unregisterDismissableFrame(id: string): void;
5
+ /** Test-only inspector: is the singleton listener currently attached? */
6
+ export declare function __isDismissListenerAttachedForTest(): boolean;
7
+ /** Test-only reset. Clears the registry and detaches the listener. */
8
+ export declare function __resetDismissRegistryForTest(): void;
@@ -0,0 +1,68 @@
1
+ /*
2
+ * Dismiss-listener registry for dismissable ("picker") floats.
3
+ *
4
+ * Each `FloatFrame` whose entry is dismissable registers its root DOM
5
+ * element here on mount and unregisters on destroy. A singleton
6
+ * `pointerdown` listener is installed on `document` as soon as the
7
+ * registry becomes non-empty and removed when it empties.
8
+ *
9
+ * On pointerdown, every registered entry whose element does not contain
10
+ * the event target is closed via floatManager.close. The listener uses
11
+ * capture=true so it runs before any in-view click handlers; the picker's
12
+ * own action handlers fire on `click`/`pointerup` and are unaffected.
13
+ */
14
+ import { floatManager } from './float';
15
+ const registry = new Map();
16
+ let listenerAttached = false;
17
+ function onDocumentPointerDown(event) {
18
+ if (registry.size === 0)
19
+ return;
20
+ const target = event.target;
21
+ if (!target)
22
+ return;
23
+ // Snapshot ids — we mutate the registry (via close → unregister) while iterating.
24
+ const ids = Array.from(registry.keys());
25
+ for (const id of ids) {
26
+ const el = registry.get(id);
27
+ if (!el)
28
+ continue;
29
+ if (!el.contains(target)) {
30
+ floatManager.close(id);
31
+ }
32
+ }
33
+ }
34
+ function ensureListener() {
35
+ if (listenerAttached)
36
+ return;
37
+ if (typeof document === 'undefined')
38
+ return;
39
+ document.addEventListener('pointerdown', onDocumentPointerDown, true);
40
+ listenerAttached = true;
41
+ }
42
+ function teardownListenerIfEmpty() {
43
+ if (!listenerAttached)
44
+ return;
45
+ if (registry.size > 0)
46
+ return;
47
+ document.removeEventListener('pointerdown', onDocumentPointerDown, true);
48
+ listenerAttached = false;
49
+ }
50
+ /** Register a dismissable float's root element. Idempotent per id. */
51
+ export function registerDismissableFrame(id, el) {
52
+ registry.set(id, el);
53
+ ensureListener();
54
+ }
55
+ /** Unregister on frame destroy. Idempotent; safe to call for unknown ids. */
56
+ export function unregisterDismissableFrame(id) {
57
+ registry.delete(id);
58
+ teardownListenerIfEmpty();
59
+ }
60
+ /** Test-only inspector: is the singleton listener currently attached? */
61
+ export function __isDismissListenerAttachedForTest() {
62
+ return listenerAttached;
63
+ }
64
+ /** Test-only reset. Clears the registry and detaches the listener. */
65
+ export function __resetDismissRegistryForTest() {
66
+ registry.clear();
67
+ teardownListenerIfEmpty();
68
+ }
@@ -8,7 +8,10 @@
8
8
  * Semantics (from docs/design/layout.md):
9
9
  * - Modals stack. Opening a second modal pushes it on top of the first.
10
10
  * - Escape pops the topmost modal.
11
- * - Backdrop click does NOT dismiss. Content owns its own close UI.
11
+ * - Backdrop click does NOT dismiss by default. Per-modal opt-in via
12
+ * `ModalOptions.dismissOnBackdrop` (used by the command palette and
13
+ * other picker-style modals where outside-click is the expected
14
+ * dismissal gesture). See ADR-005 for the per-layer policy.
12
15
  * - Each modal has its own focus trap (installed by ModalFrame).
13
16
  *
14
17
  * Implementation notes:
@@ -120,6 +123,7 @@ function openModal(Content, props, options) {
120
123
  contentProps: (props !== null && props !== void 0 ? props : {}),
121
124
  close: handle.close,
122
125
  boxStyle: options === null || options === void 0 ? void 0 : options.boxStyle,
126
+ onBackdropClick: (options === null || options === void 0 ? void 0 : options.dismissOnBackdrop) ? handle.close : undefined,
123
127
  },
124
128
  });
125
129
  entry.host = host;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { tick } from 'svelte';
3
+ import { modalManager } from './modal';
4
+ import { registerLayerRoot, unregisterLayerRoot } from './roots';
5
+ import DummyFrame from './__test__/DummyFrame.svelte';
6
+ function makeLayerRoot() {
7
+ const el = document.createElement('div');
8
+ el.style.position = 'relative';
9
+ document.body.appendChild(el);
10
+ registerLayerRoot('modal', el);
11
+ return el;
12
+ }
13
+ function teardownLayerRoot(el) {
14
+ unregisterLayerRoot('modal');
15
+ el.remove();
16
+ }
17
+ describe('modal — backdrop dismiss policy', () => {
18
+ let layerRoot;
19
+ beforeEach(() => {
20
+ layerRoot = makeLayerRoot();
21
+ });
22
+ afterEach(() => {
23
+ modalManager.closeAll();
24
+ teardownLayerRoot(layerRoot);
25
+ });
26
+ it('default modal: clicking the frame area outside the box does NOT dismiss', async () => {
27
+ modalManager.open(DummyFrame, {});
28
+ await tick();
29
+ const frame = layerRoot.querySelector('.modal-frame');
30
+ expect(frame).not.toBeNull();
31
+ // Click the frame itself (outside the box). target === currentTarget
32
+ // is what the handler keys off.
33
+ frame.dispatchEvent(new MouseEvent('click', { bubbles: true }));
34
+ await tick();
35
+ expect(layerRoot.querySelector('.sh3-modal-host')).not.toBeNull();
36
+ });
37
+ it('opt-in modal: clicking the frame area outside the box closes it', async () => {
38
+ modalManager.open(DummyFrame, {}, { dismissOnBackdrop: true });
39
+ await tick();
40
+ const frame = layerRoot.querySelector('.modal-frame');
41
+ expect(frame).not.toBeNull();
42
+ frame.dispatchEvent(new MouseEvent('click', { bubbles: true }));
43
+ await tick();
44
+ expect(layerRoot.querySelector('.sh3-modal-host')).toBeNull();
45
+ });
46
+ it('opt-in modal: clicks on the dialog box itself do NOT dismiss', async () => {
47
+ modalManager.open(DummyFrame, {}, { dismissOnBackdrop: true });
48
+ await tick();
49
+ const box = layerRoot.querySelector('.modal-box');
50
+ expect(box).not.toBeNull();
51
+ box.dispatchEvent(new MouseEvent('click', { bubbles: true }));
52
+ await tick();
53
+ expect(layerRoot.querySelector('.sh3-modal-host')).not.toBeNull();
54
+ });
55
+ });