sh3-core 0.15.0 → 0.15.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 (141) hide show
  1. package/dist/actions/ctx-actions.svelte.test.js +111 -0
  2. package/dist/actions/dispatcher.svelte.js +23 -2
  3. package/dist/actions/dispatcher.test.js +33 -0
  4. package/dist/actions/listActionsFromEntries.test.js +78 -0
  5. package/dist/actions/listActive.d.ts +2 -1
  6. package/dist/actions/listActive.js +43 -17
  7. package/dist/actions/listeners.d.ts +16 -0
  8. package/dist/actions/listeners.js +68 -14
  9. package/dist/actions/programmatic-dispatch.svelte.test.d.ts +1 -0
  10. package/dist/actions/programmatic-dispatch.svelte.test.js +98 -0
  11. package/dist/actions/types.d.ts +37 -0
  12. package/dist/api.d.ts +1 -1
  13. package/dist/app/store/verbs.js +4 -0
  14. package/dist/app-appearance/appearanceShard.svelte.js +19 -6
  15. package/dist/app-appearance/appearanceState.svelte.js +3 -3
  16. package/dist/host.js +2 -1
  17. package/dist/layouts-shard/LayoutSaveModal.svelte +145 -0
  18. package/dist/layouts-shard/LayoutSaveModal.svelte.d.ts +12 -0
  19. package/dist/layouts-shard/LayoutsSection.svelte +142 -0
  20. package/dist/layouts-shard/LayoutsSection.svelte.d.ts +3 -0
  21. package/dist/layouts-shard/filter.d.ts +3 -0
  22. package/dist/layouts-shard/filter.js +66 -0
  23. package/dist/layouts-shard/filter.test.d.ts +1 -0
  24. package/dist/layouts-shard/filter.test.js +123 -0
  25. package/dist/layouts-shard/index.d.ts +1 -0
  26. package/dist/layouts-shard/index.js +1 -0
  27. package/dist/layouts-shard/layoutsApi.d.ts +12 -0
  28. package/dist/layouts-shard/layoutsApi.js +41 -0
  29. package/dist/layouts-shard/layoutsApi.test.d.ts +1 -0
  30. package/dist/layouts-shard/layoutsApi.test.js +74 -0
  31. package/dist/layouts-shard/layoutsShard.svelte.d.ts +11 -0
  32. package/dist/layouts-shard/layoutsShard.svelte.js +231 -0
  33. package/dist/layouts-shard/layoutsShard.svelte.test.d.ts +1 -0
  34. package/dist/layouts-shard/layoutsShard.svelte.test.js +215 -0
  35. package/dist/layouts-shard/layoutsState.svelte.d.ts +9 -0
  36. package/dist/layouts-shard/layoutsState.svelte.js +50 -0
  37. package/dist/layouts-shard/layoutsState.test.d.ts +1 -0
  38. package/dist/layouts-shard/layoutsState.test.js +43 -0
  39. package/dist/layouts-shard/types.d.ts +21 -0
  40. package/dist/layouts-shard/types.js +6 -0
  41. package/dist/{app-appearance/AppAppearanceModal.svelte → overlays/EntityAppearanceModal.svelte} +36 -31
  42. package/dist/overlays/EntityAppearanceModal.svelte.d.ts +19 -0
  43. package/dist/overlays/EntityAppearanceModal.test.d.ts +1 -0
  44. package/dist/overlays/EntityAppearanceModal.test.js +57 -0
  45. package/dist/overlays/FloatFrame.svelte +149 -8
  46. package/dist/overlays/FloatFrame.svelte.d.ts +1 -1
  47. package/dist/overlays/FloatLayer.svelte +2 -2
  48. package/dist/overlays/float.d.ts +38 -1
  49. package/dist/overlays/float.js +82 -0
  50. package/dist/overlays/float.test.js +394 -0
  51. package/dist/overlays/floatMaximized.svelte.d.ts +4 -0
  52. package/dist/overlays/floatMaximized.svelte.js +30 -0
  53. package/dist/runtime/runVerb-shell.test.d.ts +1 -0
  54. package/dist/runtime/runVerb-shell.test.js +231 -0
  55. package/dist/sh3core-shard/ShellHome.svelte +3 -0
  56. package/dist/sh3core-shard/sh3coreShard.svelte.d.ts +7 -0
  57. package/dist/sh3core-shard/sh3coreShard.svelte.js +23 -0
  58. package/dist/shards/activate-runtime.test.js +24 -2
  59. package/dist/shards/activate.svelte.js +18 -4
  60. package/dist/shards/types.d.ts +44 -4
  61. package/dist/shell-shard/CommandLine.svelte +143 -0
  62. package/dist/shell-shard/CommandLine.svelte.d.ts +26 -0
  63. package/dist/shell-shard/CommandLine.svelte.test.d.ts +1 -0
  64. package/dist/shell-shard/CommandLine.svelte.test.js +43 -0
  65. package/dist/shell-shard/InputLine.svelte +17 -40
  66. package/dist/shell-shard/InputLine.svelte.d.ts +2 -0
  67. package/dist/shell-shard/ScrollbackView.svelte +10 -3
  68. package/dist/shell-shard/ScrollbackView.svelte.d.ts +1 -0
  69. package/dist/shell-shard/Terminal.svelte +94 -22
  70. package/dist/shell-shard/buffer-store.d.ts +15 -0
  71. package/dist/shell-shard/buffer-store.js +124 -0
  72. package/dist/shell-shard/buffer-store.svelte.test.d.ts +1 -0
  73. package/dist/shell-shard/buffer-store.svelte.test.js +107 -0
  74. package/dist/shell-shard/buffer-zone-state.svelte.d.ts +38 -0
  75. package/dist/shell-shard/buffer-zone-state.svelte.js +31 -0
  76. package/dist/shell-shard/contract.d.ts +7 -0
  77. package/dist/shell-shard/dispatch-custom.test.js +3 -1
  78. package/dist/shell-shard/dispatch-gating.test.js +6 -2
  79. package/dist/shell-shard/dispatch-invoke.test.js +10 -8
  80. package/dist/shell-shard/dispatch.d.ts +7 -2
  81. package/dist/shell-shard/dispatch.js +23 -27
  82. package/dist/shell-shard/display-cwd.d.ts +1 -0
  83. package/dist/shell-shard/display-cwd.js +27 -0
  84. package/dist/shell-shard/display-cwd.test.d.ts +1 -0
  85. package/dist/shell-shard/display-cwd.test.js +29 -0
  86. package/dist/shell-shard/entries/StatusEntry.svelte +2 -0
  87. package/dist/shell-shard/manifest.js +2 -1
  88. package/dist/shell-shard/manifest.test.d.ts +1 -0
  89. package/dist/shell-shard/manifest.test.js +8 -0
  90. package/dist/shell-shard/mode-buffer.svelte.d.ts +8 -0
  91. package/dist/shell-shard/mode-buffer.svelte.js +19 -0
  92. package/dist/shell-shard/mode-buffer.svelte.test.d.ts +1 -0
  93. package/dist/shell-shard/mode-buffer.svelte.test.js +25 -0
  94. package/dist/shell-shard/modes/builtin.js +2 -0
  95. package/dist/shell-shard/modes/types.d.ts +8 -0
  96. package/dist/shell-shard/protocol.d.ts +12 -6
  97. package/dist/shell-shard/replay.d.ts +3 -0
  98. package/dist/shell-shard/replay.js +44 -0
  99. package/dist/shell-shard/replay.svelte.test.d.ts +1 -0
  100. package/dist/shell-shard/replay.svelte.test.js +47 -0
  101. package/dist/shell-shard/rich-registry.d.ts +5 -0
  102. package/dist/shell-shard/rich-registry.js +25 -0
  103. package/dist/shell-shard/rich-registry.test.d.ts +1 -0
  104. package/dist/shell-shard/rich-registry.test.js +31 -0
  105. package/dist/shell-shard/scrollback.svelte.d.ts +2 -0
  106. package/dist/shell-shard/scrollback.svelte.js +23 -0
  107. package/dist/shell-shard/scrollback.svelte.test.d.ts +1 -0
  108. package/dist/shell-shard/scrollback.svelte.test.js +51 -0
  109. package/dist/shell-shard/session-client.svelte.d.ts +18 -2
  110. package/dist/shell-shard/session-client.svelte.js +21 -4
  111. package/dist/shell-shard/shellApi.d.ts +2 -1
  112. package/dist/shell-shard/shellApi.js +32 -3
  113. package/dist/shell-shard/shellApi.svelte.test.d.ts +1 -0
  114. package/dist/shell-shard/shellApi.svelte.test.js +59 -0
  115. package/dist/shell-shard/shellShard.svelte.js +11 -1
  116. package/dist/shell-shard/terminal-dispatch.test.js +3 -1
  117. package/dist/shell-shard/verbs/apps.js +9 -0
  118. package/dist/shell-shard/verbs/env.js +4 -0
  119. package/dist/shell-shard/verbs/help.js +9 -1
  120. package/dist/shell-shard/verbs/help.svelte.test.d.ts +1 -0
  121. package/dist/shell-shard/verbs/help.svelte.test.js +53 -0
  122. package/dist/shell-shard/verbs/history.js +8 -1
  123. package/dist/shell-shard/verbs/index.js +0 -8
  124. package/dist/shell-shard/verbs/shards.js +5 -0
  125. package/dist/shell-shard/verbs/views.js +9 -0
  126. package/dist/shell-shard/verbs/zones.js +9 -0
  127. package/dist/verbs/types.d.ts +9 -0
  128. package/dist/version.d.ts +1 -1
  129. package/dist/version.js +1 -1
  130. package/package.json +1 -1
  131. package/dist/app-appearance/AppAppearanceModal.svelte.d.ts +0 -8
  132. package/dist/shell-shard/verbs/cat.d.ts +0 -2
  133. package/dist/shell-shard/verbs/cat.js +0 -34
  134. package/dist/shell-shard/verbs/cd.test.js +0 -56
  135. package/dist/shell-shard/verbs/ls.d.ts +0 -2
  136. package/dist/shell-shard/verbs/ls.js +0 -29
  137. package/dist/shell-shard/verbs/ls.test.js +0 -49
  138. package/dist/shell-shard/verbs/session.d.ts +0 -4
  139. package/dist/shell-shard/verbs/session.js +0 -97
  140. /package/dist/{shell-shard/verbs/cd.test.d.ts → actions/ctx-actions.svelte.test.d.ts} +0 -0
  141. /package/dist/{shell-shard/verbs/ls.test.d.ts → actions/listActionsFromEntries.test.d.ts} +0 -0
@@ -1,58 +1,65 @@
1
1
  <script lang="ts">
2
2
  /*
3
- * Customizepick an icon and color override for a single app.
4
- * Reads the existing override on mount via untrack (Svelte 5 idiom for
5
- * one-shot prop reads), so editing the form doesn't subscribe to the
6
- * shard's reactive state. Save/Reset/Cancel are mutually exclusive.
3
+ * EntityAppearanceModalgeneralization of the per-entity Customize
4
+ * modal. Used by __app-appearance__ for apps and __layouts__ for saved
5
+ * layouts. Purely presentational: callers pass an
6
+ * onSave({ label, icon, color }) callback and decide what an empty
7
+ * label means (apps treat empty as "use manifest"; layouts mark the
8
+ * input required via requireLabel: true).
7
9
  */
8
10
 
9
11
  import { untrack } from 'svelte';
10
12
  import IconPicker from '../primitives/widgets/IconPicker.svelte';
11
13
  import ColorSwatch from '../primitives/widgets/ColorSwatch.svelte';
12
14
  import iconsUrl from '../assets/icons.svg';
13
- import { listRegisteredApps } from '../api';
14
- import { getAppearance, setAppearance } from './appearanceState.svelte';
15
15
 
16
16
  interface Props {
17
- appId: string;
18
- appLabel: string;
17
+ entityLabel: string;
18
+ initialAppearance?: { icon?: string; color?: string };
19
+ defaultIcon: string;
20
+ requireLabel: boolean;
21
+ onSave(next: { label: string; icon?: string; color?: string }): void;
22
+ onReset(): void;
19
23
  close: () => void;
20
24
  }
21
25
 
22
- let { appId, appLabel, close }: Props = $props();
26
+ let {
27
+ entityLabel,
28
+ initialAppearance,
29
+ defaultIcon,
30
+ requireLabel,
31
+ onSave,
32
+ onReset,
33
+ close,
34
+ }: Props = $props();
23
35
 
24
- const initial = untrack(() => getAppearance(appId));
25
- const manifestIcon = untrack(
26
- () => listRegisteredApps().find((m) => m.id === appId)?.icon,
27
- );
36
+ const initial = untrack(() => initialAppearance);
28
37
 
29
38
  let icon = $state<string | undefined>(initial?.icon);
30
39
  let color = $state<string | undefined>(initial?.color);
31
- let label = $state<string>(initial?.label ?? '');
40
+ let label = $state<string>(untrack(() => entityLabel));
32
41
  let pickerOpen = $state<boolean>(initial?.icon !== undefined);
33
42
 
43
+ const trimmed = $derived(label.trim());
44
+ const saveDisabled = $derived(requireLabel && trimmed === '');
45
+ const effectiveLabel = $derived(trimmed === '' ? entityLabel : trimmed);
46
+ const effectiveIcon = $derived(icon ?? defaultIcon);
34
47
  const hasOverride = $derived(initial !== undefined);
35
- const effectiveLabel = $derived(label.trim() === '' ? appLabel : label.trim());
36
- const effectiveIcon = $derived(icon ?? manifestIcon ?? 'box');
37
48
 
38
49
  function save() {
39
- const trimmed = label.trim();
40
- setAppearance(appId, {
41
- icon,
42
- color,
43
- label: trimmed === '' ? undefined : trimmed,
44
- });
50
+ if (saveDisabled) return;
51
+ onSave({ label: trimmed, icon, color });
45
52
  close();
46
53
  }
47
54
 
48
55
  function reset() {
49
- setAppearance(appId, undefined);
56
+ onReset();
50
57
  close();
51
58
  }
52
59
  </script>
53
60
 
54
61
  <div class="app-appearance">
55
- <h2>Customize {appLabel}</h2>
62
+ <h2>Customize {entityLabel}</h2>
56
63
 
57
64
  <div class="preview">
58
65
  <div
@@ -67,11 +74,11 @@
67
74
  </div>
68
75
  </div>
69
76
 
70
- <label class="row"><span>Name <em>(empty = default)</em></span>
77
+ <label class="row"><span>Name {#if !requireLabel}<em>(empty = default)</em>{/if}</span>
71
78
  <input
72
79
  type="text"
73
80
  bind:value={label}
74
- placeholder={appLabel}
81
+ placeholder={entityLabel}
75
82
  class="name-input"
76
83
  />
77
84
  </label>
@@ -95,9 +102,9 @@
95
102
  </label>
96
103
 
97
104
  <div class="actions">
98
- <button type="button" class="primary" onclick={save}>Save</button>
99
- <button type="button" onclick={reset} disabled={!hasOverride}>Reset</button>
100
- <button type="button" onclick={close}>Cancel</button>
105
+ <button type="button" class="primary" onclick={save} disabled={saveDisabled}>Save</button>
106
+ <button type="button" data-role="reset" onclick={reset} disabled={!hasOverride}>Reset</button>
107
+ <button type="button" onclick={() => close()}>Cancel</button>
101
108
  </div>
102
109
  </div>
103
110
 
@@ -158,8 +165,6 @@
158
165
  -webkit-line-clamp: 2;
159
166
  line-clamp: 2;
160
167
  }
161
- .preview-card-icon { width: 24px; height: 24px; color: var(--shell-fg); }
162
- .preview-card-label { font-weight: 600; padding: 0 4px; line-height: 1.2; }
163
168
  .actions { display: flex; gap: 8px; margin-top: 16px; }
164
169
  .actions button {
165
170
  background: var(--shell-bg-elevated);
@@ -0,0 +1,19 @@
1
+ interface Props {
2
+ entityLabel: string;
3
+ initialAppearance?: {
4
+ icon?: string;
5
+ color?: string;
6
+ };
7
+ defaultIcon: string;
8
+ requireLabel: boolean;
9
+ onSave(next: {
10
+ label: string;
11
+ icon?: string;
12
+ color?: string;
13
+ }): void;
14
+ onReset(): void;
15
+ close: () => void;
16
+ }
17
+ declare const EntityAppearanceModal: import("svelte").Component<Props, {}, "">;
18
+ type EntityAppearanceModal = ReturnType<typeof EntityAppearanceModal>;
19
+ export default EntityAppearanceModal;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { mount, flushSync } from 'svelte';
3
+ import EntityAppearanceModal from './EntityAppearanceModal.svelte';
4
+ function mountModal(props = {}) {
5
+ const target = document.createElement('div');
6
+ document.body.appendChild(target);
7
+ const onSave = vi.fn();
8
+ const onReset = vi.fn();
9
+ const close = vi.fn();
10
+ const instance = mount(EntityAppearanceModal, {
11
+ target,
12
+ props: Object.assign({ entityLabel: 'My App', defaultIcon: 'box', requireLabel: false, onSave,
13
+ onReset,
14
+ close }, props),
15
+ });
16
+ return { target, onSave, onReset, close, instance };
17
+ }
18
+ describe('EntityAppearanceModal', () => {
19
+ it('renders the entityLabel as title placeholder', () => {
20
+ const { target } = mountModal();
21
+ const input = target.querySelector('input.name-input');
22
+ expect(input.placeholder).toBe('My App');
23
+ });
24
+ it('shows the "(empty = default)" hint when requireLabel is false', () => {
25
+ const { target } = mountModal({ requireLabel: false });
26
+ expect(target.textContent).toContain('(empty = default)');
27
+ });
28
+ it('hides the "(empty = default)" hint when requireLabel is true', () => {
29
+ const { target } = mountModal({ requireLabel: true });
30
+ expect(target.textContent).not.toContain('(empty = default)');
31
+ });
32
+ it('disables Save when requireLabel is true and the input is empty', async () => {
33
+ const { target } = mountModal({ requireLabel: true, entityLabel: '' });
34
+ flushSync();
35
+ const save = target.querySelector('button.primary');
36
+ expect(save.disabled).toBe(true);
37
+ });
38
+ it('calls onSave with the trimmed label, icon, color', async () => {
39
+ const { target, onSave } = mountModal({
40
+ initialAppearance: { icon: 'cog', color: '#ff0000' },
41
+ });
42
+ const input = target.querySelector('input.name-input');
43
+ input.value = ' Renamed ';
44
+ input.dispatchEvent(new Event('input', { bubbles: true }));
45
+ flushSync();
46
+ target.querySelector('button.primary').click();
47
+ expect(onSave).toHaveBeenCalledWith({ label: 'Renamed', icon: 'cog', color: '#ff0000' });
48
+ });
49
+ it('calls onReset and close when Reset is clicked', async () => {
50
+ const { target, onReset, close } = mountModal({
51
+ initialAppearance: { icon: 'cog' },
52
+ });
53
+ target.querySelector('button[data-role="reset"]').click();
54
+ expect(onReset).toHaveBeenCalled();
55
+ expect(close).toHaveBeenCalled();
56
+ });
57
+ });
@@ -2,34 +2,69 @@
2
2
  Single floating panel frame.
3
3
 
4
4
  Renders:
5
- - Header bar (title + close button, receives pointerdown for drag).
5
+ - Header bar (title + maximize/close buttons, receives pointerdown for drag).
6
6
  - Body that mounts the float's content subtree via LayoutRenderer
7
7
  using rootRef={{ kind: 'float', floatId: entry.id }} so the
8
8
  renderer reads from layoutStore.tree.floats[...].content instead
9
9
  of layoutStore.root.
10
+ - Bottom-right resize grip (always rendered, including on dismissable
11
+ pickers — its pointerdown is inside the frame so the dismiss listener
12
+ doesn't fire).
10
13
 
11
14
  Behavior:
12
15
  - Pointer drag on header mutates entry.position in place. The entry
13
16
  is a live reference from layoutStore.tree.floats, so mutation
14
17
  reactivity flows through the workspace-zone proxy.
18
+ - Pointer drag on the resize grip mutates entry.size, clamped at
19
+ computeMinSize(entry.content).
15
20
  - Click anywhere on the frame raises it (calls floatManager.focus).
16
- - Close button calls floatManager.close.
21
+ - Close button calls floatManager.close. Maximize button toggles.
22
+ - Header double-click toggles maximize (excluding clicks on the close /
23
+ maximize buttons).
24
+ - Drag or resize while maximized implicitly un-maximizes (forgets the
25
+ saved prev rect; keeps the current rect and proceeds). See spec
26
+ docs/superpowers/specs/2026-05-07-float-resize-maximize-design.md.
17
27
  -->
18
28
  <script lang="ts">
19
29
  import LayoutRenderer from '../layout/LayoutRenderer.svelte';
20
30
  import { floatManager, getFloatParentHost } from './float';
21
31
  import { registerDismissableFrame, unregisterDismissableFrame } from './floatDismiss';
32
+ import { computeMinSize } from '../layout/floats';
22
33
  import type { FloatEntry } from '../layout/types';
34
+ import { shell } from '../shellRuntime.svelte';
35
+ import { makeSelectionApi } from '../actions/selection.svelte';
36
+
37
+ const floatHeaderSelection = makeSelectionApi('__layouts__');
38
+
39
+ function openHeaderContextMenu(e: MouseEvent): void {
40
+ e.preventDefault();
41
+ floatHeaderSelection.set({ type: 'float-header', ref: { floatId: entry.id } });
42
+ shell.actions.openContextMenu({
43
+ x: e.clientX,
44
+ y: e.clientY,
45
+ scope: { element: 'float-header' },
46
+ });
47
+ }
23
48
 
24
49
  interface Props {
25
50
  entry: FloatEntry;
26
51
  }
27
- const { entry }: Props = $props();
52
+ // `entry` is the live workspace-zone proxy from `layoutStore.floats`; the
53
+ // drag/resize/dismiss-listener paths mutate `entry.position` and
54
+ // `entry.size` in place, which is the canonical reactive flow. Marking
55
+ // it `$bindable()` opts into that mutation so Svelte 5 doesn't emit the
56
+ // ownership_invalid_mutation warning. The parent (FloatLayer) uses
57
+ // `bind:entry` to acknowledge the contract.
58
+ let { entry = $bindable() }: Props = $props();
28
59
 
29
60
  let dragging = $state(false);
30
61
  let dragOffset = { x: 0, y: 0 };
62
+ let resizing = $state(false);
63
+ let resizeStart = { pointer: { x: 0, y: 0 }, size: { w: 0, h: 0 }, min: { w: 0, h: 0 } };
31
64
  let frameEl: HTMLDivElement | undefined = $state();
32
65
 
66
+ const isMaximized = $derived(floatManager.isMaximized(entry.id));
67
+
33
68
  $effect(() => {
34
69
  if (!entry.dismissable) return;
35
70
  if (!frameEl) return;
@@ -62,8 +97,9 @@
62
97
  function onHeaderPointerDown(e: PointerEvent): void {
63
98
  if (e.button !== 0) return;
64
99
  if ((e.target as HTMLElement).closest('.sh3-float-close')) return;
100
+ if ((e.target as HTMLElement).closest('.sh3-float-maximize')) return;
65
101
  const target = e.currentTarget as HTMLElement;
66
- target.setPointerCapture(e.pointerId);
102
+ target.setPointerCapture?.(e.pointerId);
67
103
  dragging = true;
68
104
  dragOffset = { x: e.clientX - entry.position.x, y: e.clientY - entry.position.y };
69
105
  floatManager.focus(entry.id);
@@ -71,6 +107,11 @@
71
107
 
72
108
  function onHeaderPointerMove(e: PointerEvent): void {
73
109
  if (!dragging) return;
110
+ // Implicit un-maximize on first drag movement (no-op if not maximized).
111
+ // We unmaximize on move rather than pointerdown so a casual click —
112
+ // which precedes a dblclick — does not destroy the saved prev rect
113
+ // and break the dblclick toggle.
114
+ floatManager.unmaximize(entry.id);
74
115
  entry.position.x = e.clientX - dragOffset.x;
75
116
  entry.position.y = e.clientY - dragOffset.y;
76
117
  }
@@ -79,7 +120,45 @@
79
120
  if (!dragging) return;
80
121
  dragging = false;
81
122
  const target = e.currentTarget as HTMLElement;
82
- if (target.hasPointerCapture(e.pointerId)) {
123
+ if (target.hasPointerCapture?.(e.pointerId)) {
124
+ target.releasePointerCapture(e.pointerId);
125
+ }
126
+ }
127
+
128
+ function onHeaderDblClick(e: MouseEvent): void {
129
+ if ((e.target as HTMLElement).closest('.sh3-float-close')) return;
130
+ if ((e.target as HTMLElement).closest('.sh3-float-maximize')) return;
131
+ floatManager.toggleMaximize(entry.id);
132
+ }
133
+
134
+ function onGripPointerDown(e: PointerEvent): void {
135
+ if (e.button !== 0) return;
136
+ e.stopPropagation();
137
+ const target = e.currentTarget as HTMLElement;
138
+ target.setPointerCapture?.(e.pointerId);
139
+ resizing = true;
140
+ resizeStart = {
141
+ pointer: { x: e.clientX, y: e.clientY },
142
+ size: { w: entry.size.w, h: entry.size.h },
143
+ min: computeMinSize(entry.content),
144
+ };
145
+ floatManager.focus(entry.id);
146
+ }
147
+
148
+ function onGripPointerMove(e: PointerEvent): void {
149
+ if (!resizing) return;
150
+ floatManager.unmaximize(entry.id);
151
+ const dx = e.clientX - resizeStart.pointer.x;
152
+ const dy = e.clientY - resizeStart.pointer.y;
153
+ entry.size.w = Math.max(resizeStart.min.w, resizeStart.size.w + dx);
154
+ entry.size.h = Math.max(resizeStart.min.h, resizeStart.size.h + dy);
155
+ }
156
+
157
+ function onGripPointerUp(e: PointerEvent): void {
158
+ if (!resizing) return;
159
+ resizing = false;
160
+ const target = e.currentTarget as HTMLElement;
161
+ if (target.hasPointerCapture?.(e.pointerId)) {
83
162
  target.releasePointerCapture(e.pointerId);
84
163
  }
85
164
  }
@@ -88,6 +167,11 @@
88
167
  floatManager.focus(entry.id);
89
168
  }
90
169
 
170
+ function onMaximize(e: MouseEvent): void {
171
+ e.stopPropagation();
172
+ floatManager.toggleMaximize(entry.id);
173
+ }
174
+
91
175
  function onClose(e: MouseEvent): void {
92
176
  e.stopPropagation();
93
177
  floatManager.close(entry.id);
@@ -113,18 +197,40 @@
113
197
  <!-- svelte-ignore a11y_no_static_element_interactions -->
114
198
  <header
115
199
  class="sh3-float-header"
200
+ data-sh3-scope="element:float-header"
201
+ data-float-id={entry.id}
116
202
  onpointerdown={onHeaderPointerDown}
117
203
  onpointermove={onHeaderPointerMove}
118
204
  onpointerup={onHeaderPointerUp}
119
205
  onpointercancel={onHeaderPointerUp}
206
+ ondblclick={onHeaderDblClick}
207
+ oncontextmenu={openHeaderContextMenu}
120
208
  >
121
209
  <span class="sh3-float-title">{entry.title}</span>
122
- <button class="sh3-float-close" onclick={onClose} aria-label="Close float">×</button>
210
+ <span class="sh3-float-header-actions">
211
+ <button
212
+ class="sh3-float-maximize"
213
+ onclick={onMaximize}
214
+ aria-label={isMaximized ? 'Restore float' : 'Maximize float'}
215
+ aria-pressed={isMaximized}
216
+ >{isMaximized ? '\u{1F5D7}' : '\u{1F5D6}'}</button>
217
+ <button class="sh3-float-close" onclick={onClose} aria-label="Close float">×</button>
218
+ </span>
123
219
  </header>
124
220
  {/if}
125
221
  <div class="sh3-float-body">
126
222
  <LayoutRenderer rootRef={{ kind: 'float', floatId: entry.id }} path={[]} />
127
223
  </div>
224
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
225
+ <div
226
+ class="sh3-float-resize-grip"
227
+ role="presentation"
228
+ aria-hidden="true"
229
+ onpointerdown={onGripPointerDown}
230
+ onpointermove={onGripPointerMove}
231
+ onpointerup={onGripPointerUp}
232
+ onpointercancel={onGripPointerUp}
233
+ ></div>
128
234
  </div>
129
235
 
130
236
  <style>
@@ -159,15 +265,26 @@
159
265
  text-overflow: ellipsis;
160
266
  white-space: nowrap;
161
267
  }
268
+ .sh3-float-header-actions {
269
+ display: inline-flex;
270
+ align-items: center;
271
+ gap: 2px;
272
+ flex-shrink: 0;
273
+ }
274
+ .sh3-float-maximize,
162
275
  .sh3-float-close {
163
276
  background: transparent;
164
277
  border: none;
165
278
  color: var(--shell-fg);
166
- font-size: 16px;
167
279
  line-height: 1;
168
280
  cursor: pointer;
169
281
  padding: 0 4px;
170
- flex-shrink: 0;
282
+ }
283
+ .sh3-float-maximize {
284
+ font-size: 12px;
285
+ }
286
+ .sh3-float-close {
287
+ font-size: 16px;
171
288
  }
172
289
  .sh3-float-body {
173
290
  flex: 1;
@@ -175,4 +292,28 @@
175
292
  overflow: hidden;
176
293
  min-height: 0;
177
294
  }
295
+ .sh3-float-resize-grip {
296
+ position: absolute;
297
+ right: 0;
298
+ bottom: 0;
299
+ width: 16px;
300
+ height: 16px;
301
+ cursor: nwse-resize;
302
+ /* Subtle visual hint without being obtrusive — two diagonal lines made
303
+ from a CSS gradient stripe. */
304
+ background:
305
+ linear-gradient(
306
+ 135deg,
307
+ transparent 0,
308
+ transparent 6px,
309
+ var(--shell-border-strong) 6px,
310
+ var(--shell-border-strong) 7px,
311
+ transparent 7px,
312
+ transparent 10px,
313
+ var(--shell-border-strong) 10px,
314
+ var(--shell-border-strong) 11px,
315
+ transparent 11px
316
+ );
317
+ border-bottom-right-radius: var(--shell-radius);
318
+ }
178
319
  </style>
@@ -2,6 +2,6 @@ import type { FloatEntry } from '../layout/types';
2
2
  interface Props {
3
3
  entry: FloatEntry;
4
4
  }
5
- declare const FloatFrame: import("svelte").Component<Props, {}, "">;
5
+ declare const FloatFrame: import("svelte").Component<Props, {}, "entry">;
6
6
  type FloatFrame = ReturnType<typeof FloatFrame>;
7
7
  export default FloatFrame;
@@ -13,8 +13,8 @@
13
13
  </script>
14
14
 
15
15
  <div class="sh3-float-layer">
16
- {#each floats as entry (entry.id)}
17
- <FloatFrame {entry} />
16
+ {#each floats as entry, i (entry.id)}
17
+ <FloatFrame bind:entry={floats[i]} />
18
18
  {/each}
19
19
  </div>
20
20
 
@@ -1,4 +1,4 @@
1
- import type { FloatEntry } from '../layout/types';
1
+ import type { LayoutNode, FloatEntry } from '../layout/types';
2
2
  import type { Size } from '../layout/floats';
3
3
  export interface FloatOptions {
4
4
  title?: string;
@@ -29,9 +29,46 @@ export interface FloatOptions {
29
29
  }
30
30
  export interface FloatManager {
31
31
  open(viewId: string, options?: FloatOptions): string;
32
+ /**
33
+ * Open a float with a pre-built content tree. Used by save/restore flows
34
+ * (e.g. saved layouts) where the caller has already arranged the
35
+ * structure rather than starting from a single view id. The `content`
36
+ * tree is stored verbatim — the caller is responsible for cloning if it
37
+ * shouldn't be aliased to a source.
38
+ */
39
+ openWithContent(options: {
40
+ content: LayoutNode;
41
+ size: Size;
42
+ title?: string;
43
+ position?: {
44
+ x: number;
45
+ y: number;
46
+ };
47
+ }): string;
32
48
  close(floatId: string): void;
33
49
  list(): FloatEntry[];
34
50
  focus(floatId: string): void;
51
+ /**
52
+ * Snapshot the current rect, override with the float layer bounds, and
53
+ * raise. No-op if the float is already maximized or unknown. Bounds are
54
+ * frozen at maximize time — shell resize while maximized does not refit.
55
+ */
56
+ maximize(floatId: string): void;
57
+ /**
58
+ * Roll the rect back to the snapshot taken at maximize time. No-op if
59
+ * the float was not maximized.
60
+ */
61
+ restore(floatId: string): void;
62
+ /** `isMaximized(id) ? restore(id) : maximize(id)`. */
63
+ toggleMaximize(floatId: string): void;
64
+ /**
65
+ * Forget the saved rect snapshot without rolling back. Used by drag /
66
+ * resize handlers to "exit maximize but keep the current rect" — distinct
67
+ * from `restore`, which would snap back. Safe no-op when the float was
68
+ * not maximized.
69
+ */
70
+ unmaximize(floatId: string): void;
71
+ isMaximized(floatId: string): boolean;
35
72
  }
36
73
  /**
37
74
  * Bind the manager to the active LayoutTree's `floats` array. Called
@@ -28,6 +28,7 @@
28
28
  */
29
29
  import { computeMinSize, cascadePosition, generateFloatId } from '../layout/floats';
30
30
  import { findEnclosingOverlayHost } from './parentHost';
31
+ import { setMaximizedReactive, readMaximizedReactive, __resetMaximizedReactiveForTest, } from './floatMaximized.svelte';
31
32
  // ----- storage binding ---------------------------------------------------
32
33
  let fallbackFloats = [];
33
34
  let boundFloats = null;
@@ -51,6 +52,8 @@ export function __resetFloatManagerForTest() {
51
52
  boundFloats = null;
52
53
  getTreeBounds = () => ({ w: 1600, h: 900 });
53
54
  parentHosts.clear();
55
+ maximizedRects.clear();
56
+ __resetMaximizedReactiveForTest();
54
57
  }
55
58
  function activeStore() {
56
59
  return boundFloats !== null && boundFloats !== void 0 ? boundFloats : fallbackFloats;
@@ -63,6 +66,12 @@ const parentHosts = new Map();
63
66
  export function getFloatParentHost(id) {
64
67
  return parentHosts.get(id);
65
68
  }
69
+ // ----- maximize sidecar --------------------------------------------------
70
+ // Presence in this map ⇒ the float is currently maximized; the value is the
71
+ // rect to restore to. Lives outside FloatEntry so the layout schema doesn't
72
+ // need to bump for an in-memory, non-persisted concern. Reactivity is mirrored
73
+ // into floatMaximized.svelte.ts so Svelte components observe state changes.
74
+ const maximizedRects = new Map();
66
75
  // ----- slot id minting ---------------------------------------------------
67
76
  let floatSlotCounter = 0;
68
77
  function mintFloatSlotId(viewId) {
@@ -128,6 +137,21 @@ function openFloat(viewId, options = {}) {
128
137
  store.push(entry);
129
138
  return id;
130
139
  }
140
+ function openFloatWithContent(options) {
141
+ var _a;
142
+ const store = activeStore();
143
+ const id = generateFloatId();
144
+ const position = (_a = options.position) !== null && _a !== void 0 ? _a : cascadePosition(store, getTreeBounds());
145
+ const entry = {
146
+ id,
147
+ content: options.content,
148
+ position,
149
+ size: options.size,
150
+ title: options.title,
151
+ };
152
+ store.push(entry);
153
+ return id;
154
+ }
131
155
  function closeFloat(floatId) {
132
156
  const store = activeStore();
133
157
  const idx = store.findIndex((f) => f.id === floatId);
@@ -135,6 +159,8 @@ function closeFloat(floatId) {
135
159
  return;
136
160
  store.splice(idx, 1);
137
161
  parentHosts.delete(floatId);
162
+ maximizedRects.delete(floatId);
163
+ setMaximizedReactive(floatId, false);
138
164
  }
139
165
  function listFloats() {
140
166
  // Return a snapshot so callers can iterate without racing mutations.
@@ -148,9 +174,65 @@ function focusFloat(floatId) {
148
174
  const [entry] = store.splice(idx, 1);
149
175
  store.push(entry);
150
176
  }
177
+ function maximizeFloat(id) {
178
+ if (maximizedRects.has(id))
179
+ return;
180
+ const entry = activeStore().find((f) => f.id === id);
181
+ if (!entry)
182
+ return;
183
+ maximizedRects.set(id, {
184
+ position: { x: entry.position.x, y: entry.position.y },
185
+ size: { w: entry.size.w, h: entry.size.h },
186
+ });
187
+ setMaximizedReactive(id, true);
188
+ const bounds = getTreeBounds();
189
+ entry.position.x = 0;
190
+ entry.position.y = 0;
191
+ entry.size.w = bounds.w;
192
+ entry.size.h = bounds.h;
193
+ focusFloat(id);
194
+ }
195
+ function restoreFloat(id) {
196
+ const prev = maximizedRects.get(id);
197
+ if (!prev)
198
+ return;
199
+ const entry = activeStore().find((f) => f.id === id);
200
+ if (!entry) {
201
+ maximizedRects.delete(id);
202
+ setMaximizedReactive(id, false);
203
+ return;
204
+ }
205
+ entry.position.x = prev.position.x;
206
+ entry.position.y = prev.position.y;
207
+ entry.size.w = prev.size.w;
208
+ entry.size.h = prev.size.h;
209
+ maximizedRects.delete(id);
210
+ setMaximizedReactive(id, false);
211
+ }
212
+ function toggleMaximizeFloat(id) {
213
+ if (maximizedRects.has(id))
214
+ restoreFloat(id);
215
+ else
216
+ maximizeFloat(id);
217
+ }
218
+ function unmaximizeFloat(id) {
219
+ if (!maximizedRects.has(id))
220
+ return;
221
+ maximizedRects.delete(id);
222
+ setMaximizedReactive(id, false);
223
+ }
224
+ function isMaximizedFloat(id) {
225
+ return readMaximizedReactive(id);
226
+ }
151
227
  export const floatManager = {
152
228
  open: openFloat,
229
+ openWithContent: openFloatWithContent,
153
230
  close: closeFloat,
154
231
  list: listFloats,
155
232
  focus: focusFloat,
233
+ maximize: maximizeFloat,
234
+ restore: restoreFloat,
235
+ toggleMaximize: toggleMaximizeFloat,
236
+ unmaximize: unmaximizeFloat,
237
+ isMaximized: isMaximizedFloat,
156
238
  };