sh3-core 0.15.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,7 @@ function findInstalled(id) {
17
17
  export const installVerb = {
18
18
  name: 'install',
19
19
  summary: 'Install a package by id from the catalog.',
20
+ programmatic: true,
20
21
  async run(ctx, args) {
21
22
  var _a, _b, _c;
22
23
  const id = args[0];
@@ -117,6 +118,7 @@ export const installVerb = {
117
118
  export const uninstallVerb = {
118
119
  name: 'uninstall',
119
120
  summary: 'Uninstall an installed package by id.',
121
+ programmatic: true,
120
122
  async run(ctx, args) {
121
123
  const id = args[0];
122
124
  if (!id) {
@@ -168,6 +170,7 @@ export const updateVerb = {
168
170
  summary: 'Update an installed package (sh3-store:update <id> [version]). When ' +
169
171
  'version is omitted, bumps to latest; with a version, installs that ' +
170
172
  'exact version (downgrade or same-version reinstall allowed).',
173
+ programmatic: true,
171
174
  async run(ctx, args) {
172
175
  var _a, _b;
173
176
  const id = args[0];
@@ -243,6 +246,7 @@ export const updateVerb = {
243
246
  export const appinfoVerb = {
244
247
  name: 'appinfo',
245
248
  summary: 'Show info about a package (installed status, version, catalog details).',
249
+ programmatic: true,
246
250
  async run(ctx, args) {
247
251
  const id = args[0];
248
252
  if (!id) {
@@ -2,34 +2,55 @@
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';
23
34
 
24
35
  interface Props {
25
36
  entry: FloatEntry;
26
37
  }
27
- const { entry }: Props = $props();
38
+ // `entry` is the live workspace-zone proxy from `layoutStore.floats`; the
39
+ // drag/resize/dismiss-listener paths mutate `entry.position` and
40
+ // `entry.size` in place, which is the canonical reactive flow. Marking
41
+ // it `$bindable()` opts into that mutation so Svelte 5 doesn't emit the
42
+ // ownership_invalid_mutation warning. The parent (FloatLayer) uses
43
+ // `bind:entry` to acknowledge the contract.
44
+ let { entry = $bindable() }: Props = $props();
28
45
 
29
46
  let dragging = $state(false);
30
47
  let dragOffset = { x: 0, y: 0 };
48
+ let resizing = $state(false);
49
+ let resizeStart = { pointer: { x: 0, y: 0 }, size: { w: 0, h: 0 }, min: { w: 0, h: 0 } };
31
50
  let frameEl: HTMLDivElement | undefined = $state();
32
51
 
52
+ const isMaximized = $derived(floatManager.isMaximized(entry.id));
53
+
33
54
  $effect(() => {
34
55
  if (!entry.dismissable) return;
35
56
  if (!frameEl) return;
@@ -62,8 +83,9 @@
62
83
  function onHeaderPointerDown(e: PointerEvent): void {
63
84
  if (e.button !== 0) return;
64
85
  if ((e.target as HTMLElement).closest('.sh3-float-close')) return;
86
+ if ((e.target as HTMLElement).closest('.sh3-float-maximize')) return;
65
87
  const target = e.currentTarget as HTMLElement;
66
- target.setPointerCapture(e.pointerId);
88
+ target.setPointerCapture?.(e.pointerId);
67
89
  dragging = true;
68
90
  dragOffset = { x: e.clientX - entry.position.x, y: e.clientY - entry.position.y };
69
91
  floatManager.focus(entry.id);
@@ -71,6 +93,11 @@
71
93
 
72
94
  function onHeaderPointerMove(e: PointerEvent): void {
73
95
  if (!dragging) return;
96
+ // Implicit un-maximize on first drag movement (no-op if not maximized).
97
+ // We unmaximize on move rather than pointerdown so a casual click —
98
+ // which precedes a dblclick — does not destroy the saved prev rect
99
+ // and break the dblclick toggle.
100
+ floatManager.unmaximize(entry.id);
74
101
  entry.position.x = e.clientX - dragOffset.x;
75
102
  entry.position.y = e.clientY - dragOffset.y;
76
103
  }
@@ -79,7 +106,45 @@
79
106
  if (!dragging) return;
80
107
  dragging = false;
81
108
  const target = e.currentTarget as HTMLElement;
82
- if (target.hasPointerCapture(e.pointerId)) {
109
+ if (target.hasPointerCapture?.(e.pointerId)) {
110
+ target.releasePointerCapture(e.pointerId);
111
+ }
112
+ }
113
+
114
+ function onHeaderDblClick(e: MouseEvent): void {
115
+ if ((e.target as HTMLElement).closest('.sh3-float-close')) return;
116
+ if ((e.target as HTMLElement).closest('.sh3-float-maximize')) return;
117
+ floatManager.toggleMaximize(entry.id);
118
+ }
119
+
120
+ function onGripPointerDown(e: PointerEvent): void {
121
+ if (e.button !== 0) return;
122
+ e.stopPropagation();
123
+ const target = e.currentTarget as HTMLElement;
124
+ target.setPointerCapture?.(e.pointerId);
125
+ resizing = true;
126
+ resizeStart = {
127
+ pointer: { x: e.clientX, y: e.clientY },
128
+ size: { w: entry.size.w, h: entry.size.h },
129
+ min: computeMinSize(entry.content),
130
+ };
131
+ floatManager.focus(entry.id);
132
+ }
133
+
134
+ function onGripPointerMove(e: PointerEvent): void {
135
+ if (!resizing) return;
136
+ floatManager.unmaximize(entry.id);
137
+ const dx = e.clientX - resizeStart.pointer.x;
138
+ const dy = e.clientY - resizeStart.pointer.y;
139
+ entry.size.w = Math.max(resizeStart.min.w, resizeStart.size.w + dx);
140
+ entry.size.h = Math.max(resizeStart.min.h, resizeStart.size.h + dy);
141
+ }
142
+
143
+ function onGripPointerUp(e: PointerEvent): void {
144
+ if (!resizing) return;
145
+ resizing = false;
146
+ const target = e.currentTarget as HTMLElement;
147
+ if (target.hasPointerCapture?.(e.pointerId)) {
83
148
  target.releasePointerCapture(e.pointerId);
84
149
  }
85
150
  }
@@ -88,6 +153,11 @@
88
153
  floatManager.focus(entry.id);
89
154
  }
90
155
 
156
+ function onMaximize(e: MouseEvent): void {
157
+ e.stopPropagation();
158
+ floatManager.toggleMaximize(entry.id);
159
+ }
160
+
91
161
  function onClose(e: MouseEvent): void {
92
162
  e.stopPropagation();
93
163
  floatManager.close(entry.id);
@@ -117,14 +187,33 @@
117
187
  onpointermove={onHeaderPointerMove}
118
188
  onpointerup={onHeaderPointerUp}
119
189
  onpointercancel={onHeaderPointerUp}
190
+ ondblclick={onHeaderDblClick}
120
191
  >
121
192
  <span class="sh3-float-title">{entry.title}</span>
122
- <button class="sh3-float-close" onclick={onClose} aria-label="Close float">×</button>
193
+ <span class="sh3-float-header-actions">
194
+ <button
195
+ class="sh3-float-maximize"
196
+ onclick={onMaximize}
197
+ aria-label={isMaximized ? 'Restore float' : 'Maximize float'}
198
+ aria-pressed={isMaximized}
199
+ >{isMaximized ? '\u{1F5D7}' : '\u{1F5D6}'}</button>
200
+ <button class="sh3-float-close" onclick={onClose} aria-label="Close float">×</button>
201
+ </span>
123
202
  </header>
124
203
  {/if}
125
204
  <div class="sh3-float-body">
126
205
  <LayoutRenderer rootRef={{ kind: 'float', floatId: entry.id }} path={[]} />
127
206
  </div>
207
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
208
+ <div
209
+ class="sh3-float-resize-grip"
210
+ role="presentation"
211
+ aria-hidden="true"
212
+ onpointerdown={onGripPointerDown}
213
+ onpointermove={onGripPointerMove}
214
+ onpointerup={onGripPointerUp}
215
+ onpointercancel={onGripPointerUp}
216
+ ></div>
128
217
  </div>
129
218
 
130
219
  <style>
@@ -159,15 +248,26 @@
159
248
  text-overflow: ellipsis;
160
249
  white-space: nowrap;
161
250
  }
251
+ .sh3-float-header-actions {
252
+ display: inline-flex;
253
+ align-items: center;
254
+ gap: 2px;
255
+ flex-shrink: 0;
256
+ }
257
+ .sh3-float-maximize,
162
258
  .sh3-float-close {
163
259
  background: transparent;
164
260
  border: none;
165
261
  color: var(--shell-fg);
166
- font-size: 16px;
167
262
  line-height: 1;
168
263
  cursor: pointer;
169
264
  padding: 0 4px;
170
- flex-shrink: 0;
265
+ }
266
+ .sh3-float-maximize {
267
+ font-size: 12px;
268
+ }
269
+ .sh3-float-close {
270
+ font-size: 16px;
171
271
  }
172
272
  .sh3-float-body {
173
273
  flex: 1;
@@ -175,4 +275,28 @@
175
275
  overflow: hidden;
176
276
  min-height: 0;
177
277
  }
278
+ .sh3-float-resize-grip {
279
+ position: absolute;
280
+ right: 0;
281
+ bottom: 0;
282
+ width: 16px;
283
+ height: 16px;
284
+ cursor: nwse-resize;
285
+ /* Subtle visual hint without being obtrusive — two diagonal lines made
286
+ from a CSS gradient stripe. */
287
+ background:
288
+ linear-gradient(
289
+ 135deg,
290
+ transparent 0,
291
+ transparent 6px,
292
+ var(--shell-border-strong) 6px,
293
+ var(--shell-border-strong) 7px,
294
+ transparent 7px,
295
+ transparent 10px,
296
+ var(--shell-border-strong) 10px,
297
+ var(--shell-border-strong) 11px,
298
+ transparent 11px
299
+ );
300
+ border-bottom-right-radius: var(--shell-radius);
301
+ }
178
302
  </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
 
@@ -32,6 +32,27 @@ export interface FloatManager {
32
32
  close(floatId: string): void;
33
33
  list(): FloatEntry[];
34
34
  focus(floatId: string): void;
35
+ /**
36
+ * Snapshot the current rect, override with the float layer bounds, and
37
+ * raise. No-op if the float is already maximized or unknown. Bounds are
38
+ * frozen at maximize time — shell resize while maximized does not refit.
39
+ */
40
+ maximize(floatId: string): void;
41
+ /**
42
+ * Roll the rect back to the snapshot taken at maximize time. No-op if
43
+ * the float was not maximized.
44
+ */
45
+ restore(floatId: string): void;
46
+ /** `isMaximized(id) ? restore(id) : maximize(id)`. */
47
+ toggleMaximize(floatId: string): void;
48
+ /**
49
+ * Forget the saved rect snapshot without rolling back. Used by drag /
50
+ * resize handlers to "exit maximize but keep the current rect" — distinct
51
+ * from `restore`, which would snap back. Safe no-op when the float was
52
+ * not maximized.
53
+ */
54
+ unmaximize(floatId: string): void;
55
+ isMaximized(floatId: string): boolean;
35
56
  }
36
57
  /**
37
58
  * 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) {
@@ -135,6 +144,8 @@ function closeFloat(floatId) {
135
144
  return;
136
145
  store.splice(idx, 1);
137
146
  parentHosts.delete(floatId);
147
+ maximizedRects.delete(floatId);
148
+ setMaximizedReactive(floatId, false);
138
149
  }
139
150
  function listFloats() {
140
151
  // Return a snapshot so callers can iterate without racing mutations.
@@ -148,9 +159,64 @@ function focusFloat(floatId) {
148
159
  const [entry] = store.splice(idx, 1);
149
160
  store.push(entry);
150
161
  }
162
+ function maximizeFloat(id) {
163
+ if (maximizedRects.has(id))
164
+ return;
165
+ const entry = activeStore().find((f) => f.id === id);
166
+ if (!entry)
167
+ return;
168
+ maximizedRects.set(id, {
169
+ position: { x: entry.position.x, y: entry.position.y },
170
+ size: { w: entry.size.w, h: entry.size.h },
171
+ });
172
+ setMaximizedReactive(id, true);
173
+ const bounds = getTreeBounds();
174
+ entry.position.x = 0;
175
+ entry.position.y = 0;
176
+ entry.size.w = bounds.w;
177
+ entry.size.h = bounds.h;
178
+ focusFloat(id);
179
+ }
180
+ function restoreFloat(id) {
181
+ const prev = maximizedRects.get(id);
182
+ if (!prev)
183
+ return;
184
+ const entry = activeStore().find((f) => f.id === id);
185
+ if (!entry) {
186
+ maximizedRects.delete(id);
187
+ setMaximizedReactive(id, false);
188
+ return;
189
+ }
190
+ entry.position.x = prev.position.x;
191
+ entry.position.y = prev.position.y;
192
+ entry.size.w = prev.size.w;
193
+ entry.size.h = prev.size.h;
194
+ maximizedRects.delete(id);
195
+ setMaximizedReactive(id, false);
196
+ }
197
+ function toggleMaximizeFloat(id) {
198
+ if (maximizedRects.has(id))
199
+ restoreFloat(id);
200
+ else
201
+ maximizeFloat(id);
202
+ }
203
+ function unmaximizeFloat(id) {
204
+ if (!maximizedRects.has(id))
205
+ return;
206
+ maximizedRects.delete(id);
207
+ setMaximizedReactive(id, false);
208
+ }
209
+ function isMaximizedFloat(id) {
210
+ return readMaximizedReactive(id);
211
+ }
151
212
  export const floatManager = {
152
213
  open: openFloat,
153
214
  close: closeFloat,
154
215
  list: listFloats,
155
216
  focus: focusFloat,
217
+ maximize: maximizeFloat,
218
+ restore: restoreFloat,
219
+ toggleMaximize: toggleMaximizeFloat,
220
+ unmaximize: unmaximizeFloat,
221
+ isMaximized: isMaximizedFloat,
156
222
  };