sh3-core 0.6.0 → 0.7.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.
- package/dist/Shell.svelte +20 -14
- package/dist/api.d.ts +7 -3
- package/dist/api.js +1 -0
- package/dist/app/admin/adminApp.js +2 -1
- package/dist/app/admin/adminShard.svelte.js +2 -1
- package/dist/app/store/StoreView.svelte +11 -5
- package/dist/app/store/storeApp.js +2 -1
- package/dist/app/store/storeShard.svelte.js +14 -4
- package/dist/app/store/verbs.d.ts +4 -0
- package/dist/app/store/verbs.js +220 -0
- package/dist/apps/terminal/manifest.js +2 -1
- package/dist/apps/types.d.ts +28 -7
- package/dist/build.d.ts +5 -2
- package/dist/build.js +21 -10
- package/dist/env/client.d.ts +10 -2
- package/dist/env/client.js +13 -2
- package/dist/layout/LayoutRenderer.svelte +21 -9
- package/dist/layout/LayoutRenderer.svelte.d.ts +2 -0
- package/dist/layout/SlotDropZone.svelte +4 -1
- package/dist/layout/SlotDropZone.svelte.d.ts +2 -0
- package/dist/layout/drag.svelte.d.ts +5 -2
- package/dist/layout/drag.svelte.js +43 -11
- package/dist/layout/floats.d.ts +35 -0
- package/dist/layout/floats.js +73 -0
- package/dist/layout/floats.test.d.ts +1 -0
- package/dist/layout/floats.test.js +114 -0
- package/dist/layout/inspection.d.ts +2 -2
- package/dist/layout/inspection.js +6 -6
- package/dist/layout/ops.d.ts +14 -1
- package/dist/layout/ops.js +17 -0
- package/dist/layout/ops.test.d.ts +1 -0
- package/dist/layout/ops.test.js +36 -0
- package/dist/layout/presets.d.ts +2 -0
- package/dist/layout/presets.js +49 -0
- package/dist/layout/presets.test.d.ts +1 -0
- package/dist/layout/presets.test.js +71 -0
- package/dist/layout/store.svelte.d.ts +17 -13
- package/dist/layout/store.svelte.js +98 -36
- package/dist/layout/tree-walk.d.ts +12 -1
- package/dist/layout/tree-walk.js +13 -0
- package/dist/layout/tree-walk.test.d.ts +1 -0
- package/dist/layout/tree-walk.test.js +41 -0
- package/dist/layout/types.d.ts +96 -6
- package/dist/layout/types.js +1 -1
- package/dist/overlays/FloatFrame.svelte +142 -0
- package/dist/overlays/FloatFrame.svelte.d.ts +7 -0
- package/dist/overlays/FloatLayer.svelte +28 -0
- package/dist/overlays/FloatLayer.svelte.d.ts +3 -0
- package/dist/overlays/float.d.ts +29 -0
- package/dist/overlays/float.js +119 -0
- package/dist/overlays/float.test.d.ts +1 -0
- package/dist/overlays/float.test.js +37 -0
- package/dist/overlays/presets.d.ts +21 -0
- package/dist/overlays/presets.js +63 -0
- package/dist/overlays/presets.test.d.ts +1 -0
- package/dist/overlays/presets.test.js +40 -0
- package/dist/registry/client.d.ts +14 -0
- package/dist/registry/client.js +37 -0
- package/dist/registry/client.test.d.ts +1 -0
- package/dist/registry/client.test.js +54 -0
- package/dist/registry/installer.js +18 -5
- package/dist/registry/schema.js +5 -0
- package/dist/registry/types.d.ts +9 -0
- package/dist/shards/activate.svelte.js +9 -2
- package/dist/shards/registry.d.ts +5 -0
- package/dist/shards/registry.js +19 -3
- package/dist/shards/registry.test.d.ts +1 -0
- package/dist/shards/registry.test.js +62 -0
- package/dist/shards/types.d.ts +36 -4
- package/dist/shell-shard/Terminal.svelte +17 -12
- package/dist/shell-shard/manifest.js +2 -1
- package/dist/shell-shard/registry.d.ts +2 -64
- package/dist/shell-shard/registry.js +9 -17
- package/dist/shell-shard/shellShard.svelte.js +4 -1
- package/dist/shell-shard/verbs/apps.d.ts +1 -1
- package/dist/shell-shard/verbs/clear.d.ts +1 -1
- package/dist/shell-shard/verbs/help.d.ts +2 -2
- package/dist/shell-shard/verbs/help.js +3 -2
- package/dist/shell-shard/verbs/history.d.ts +1 -1
- package/dist/shell-shard/verbs/index.d.ts +2 -2
- package/dist/shell-shard/verbs/index.js +18 -18
- package/dist/shell-shard/verbs/session.d.ts +1 -1
- package/dist/shell-shard/verbs/shards.d.ts +1 -1
- package/dist/shell-shard/verbs/views.d.ts +1 -1
- package/dist/shell-shard/verbs/zones.d.ts +1 -1
- package/dist/shellRuntime.svelte.d.ts +6 -0
- package/dist/shellRuntime.svelte.js +4 -0
- package/dist/verbs/types.d.ts +62 -0
- package/dist/verbs/types.js +8 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +6 -3
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Single floating panel frame.
|
|
3
|
+
|
|
4
|
+
Renders:
|
|
5
|
+
- Header bar (title + close button, receives pointerdown for drag).
|
|
6
|
+
- Body that mounts the float's content subtree via LayoutRenderer
|
|
7
|
+
using rootRef={{ kind: 'float', floatId: entry.id }} so the
|
|
8
|
+
renderer reads from layoutStore.tree.floats[...].content instead
|
|
9
|
+
of layoutStore.root.
|
|
10
|
+
|
|
11
|
+
Behavior:
|
|
12
|
+
- Pointer drag on header mutates entry.position in place. The entry
|
|
13
|
+
is a live reference from layoutStore.tree.floats, so mutation
|
|
14
|
+
reactivity flows through the workspace-zone proxy.
|
|
15
|
+
- Click anywhere on the frame raises it (calls floatManager.focus).
|
|
16
|
+
- Close button calls floatManager.close.
|
|
17
|
+
-->
|
|
18
|
+
<script lang="ts">
|
|
19
|
+
import LayoutRenderer from '../layout/LayoutRenderer.svelte';
|
|
20
|
+
import { floatManager } from './float';
|
|
21
|
+
import type { FloatEntry } from '../layout/types';
|
|
22
|
+
|
|
23
|
+
interface Props {
|
|
24
|
+
entry: FloatEntry;
|
|
25
|
+
}
|
|
26
|
+
const { entry }: Props = $props();
|
|
27
|
+
|
|
28
|
+
let dragging = $state(false);
|
|
29
|
+
let dragOffset = { x: 0, y: 0 };
|
|
30
|
+
|
|
31
|
+
function onHeaderPointerDown(e: PointerEvent): void {
|
|
32
|
+
if (e.button !== 0) return;
|
|
33
|
+
const target = e.currentTarget as HTMLElement;
|
|
34
|
+
target.setPointerCapture(e.pointerId);
|
|
35
|
+
dragging = true;
|
|
36
|
+
dragOffset = { x: e.clientX - entry.position.x, y: e.clientY - entry.position.y };
|
|
37
|
+
floatManager.focus(entry.id);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function onHeaderPointerMove(e: PointerEvent): void {
|
|
41
|
+
if (!dragging) return;
|
|
42
|
+
entry.position.x = e.clientX - dragOffset.x;
|
|
43
|
+
entry.position.y = e.clientY - dragOffset.y;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function onHeaderPointerUp(e: PointerEvent): void {
|
|
47
|
+
if (!dragging) return;
|
|
48
|
+
dragging = false;
|
|
49
|
+
const target = e.currentTarget as HTMLElement;
|
|
50
|
+
if (target.hasPointerCapture(e.pointerId)) {
|
|
51
|
+
target.releasePointerCapture(e.pointerId);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function onFrameClick(): void {
|
|
56
|
+
floatManager.focus(entry.id);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function onClose(e: MouseEvent): void {
|
|
60
|
+
e.stopPropagation();
|
|
61
|
+
floatManager.close(entry.id);
|
|
62
|
+
}
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
66
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
67
|
+
<div
|
|
68
|
+
class="sh3-float-frame"
|
|
69
|
+
style:left="{entry.position.x}px"
|
|
70
|
+
style:top="{entry.position.y}px"
|
|
71
|
+
style:width="{entry.size.w}px"
|
|
72
|
+
style:height="{entry.size.h}px"
|
|
73
|
+
onclick={onFrameClick}
|
|
74
|
+
role="dialog"
|
|
75
|
+
aria-label={entry.title ?? 'Float panel'}
|
|
76
|
+
tabindex="-1"
|
|
77
|
+
>
|
|
78
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
79
|
+
<header
|
|
80
|
+
class="sh3-float-header"
|
|
81
|
+
onpointerdown={onHeaderPointerDown}
|
|
82
|
+
onpointermove={onHeaderPointerMove}
|
|
83
|
+
onpointerup={onHeaderPointerUp}
|
|
84
|
+
onpointercancel={onHeaderPointerUp}
|
|
85
|
+
>
|
|
86
|
+
<span class="sh3-float-title">{entry.title ?? entry.content.type}</span>
|
|
87
|
+
<button class="sh3-float-close" onclick={onClose} aria-label="Close float">×</button>
|
|
88
|
+
</header>
|
|
89
|
+
<div class="sh3-float-body">
|
|
90
|
+
<LayoutRenderer rootRef={{ kind: 'float', floatId: entry.id }} path={[]} />
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<style>
|
|
95
|
+
.sh3-float-frame {
|
|
96
|
+
position: absolute;
|
|
97
|
+
display: flex;
|
|
98
|
+
flex-direction: column;
|
|
99
|
+
background: var(--shell-grad-bg-elevated, var(--shell-bg-elevated, #1e1e1e));
|
|
100
|
+
color: var(--shell-fg);
|
|
101
|
+
border: 1px solid var(--shell-border-strong);
|
|
102
|
+
border-radius: var(--shell-radius);
|
|
103
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
104
|
+
pointer-events: auto;
|
|
105
|
+
}
|
|
106
|
+
.sh3-float-header {
|
|
107
|
+
display: flex;
|
|
108
|
+
align-items: center;
|
|
109
|
+
justify-content: space-between;
|
|
110
|
+
padding: 4px 8px;
|
|
111
|
+
background: var(--shell-bg, #111);
|
|
112
|
+
cursor: move;
|
|
113
|
+
user-select: none;
|
|
114
|
+
border-bottom: 1px solid var(--shell-border-strong);
|
|
115
|
+
border-top-left-radius: var(--shell-radius);
|
|
116
|
+
border-top-right-radius: var(--shell-radius);
|
|
117
|
+
flex-shrink: 0;
|
|
118
|
+
}
|
|
119
|
+
.sh3-float-title {
|
|
120
|
+
font-size: 12px;
|
|
121
|
+
color: var(--shell-fg);
|
|
122
|
+
overflow: hidden;
|
|
123
|
+
text-overflow: ellipsis;
|
|
124
|
+
white-space: nowrap;
|
|
125
|
+
}
|
|
126
|
+
.sh3-float-close {
|
|
127
|
+
background: transparent;
|
|
128
|
+
border: none;
|
|
129
|
+
color: var(--shell-fg);
|
|
130
|
+
font-size: 16px;
|
|
131
|
+
line-height: 1;
|
|
132
|
+
cursor: pointer;
|
|
133
|
+
padding: 0 4px;
|
|
134
|
+
flex-shrink: 0;
|
|
135
|
+
}
|
|
136
|
+
.sh3-float-body {
|
|
137
|
+
flex: 1;
|
|
138
|
+
position: relative;
|
|
139
|
+
overflow: hidden;
|
|
140
|
+
min-height: 0;
|
|
141
|
+
}
|
|
142
|
+
</style>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Layer 1 overlay root — iterates the active LayoutTree's floats and
|
|
3
|
+
renders a FloatFrame for each. Mounted into the layer-1 DOM root by
|
|
4
|
+
Shell.svelte. Reactivity flows from the workspace-zone proxy through
|
|
5
|
+
layoutStore.floats into this component, so mutations (open, close,
|
|
6
|
+
position changes, reorder) re-render automatically.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import { layoutStore } from '../layout/store.svelte';
|
|
10
|
+
import FloatFrame from './FloatFrame.svelte';
|
|
11
|
+
|
|
12
|
+
const floats = $derived(layoutStore.floats);
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<div class="sh3-float-layer">
|
|
16
|
+
{#each floats as entry (entry.id)}
|
|
17
|
+
<FloatFrame {entry} />
|
|
18
|
+
{/each}
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<style>
|
|
22
|
+
.sh3-float-layer {
|
|
23
|
+
position: absolute;
|
|
24
|
+
inset: 0;
|
|
25
|
+
pointer-events: none;
|
|
26
|
+
/* Children are pointer-events: auto so the layer itself passes through. */
|
|
27
|
+
}
|
|
28
|
+
</style>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { FloatEntry } from '../layout/types';
|
|
2
|
+
import type { Size } from '../layout/floats';
|
|
3
|
+
export interface FloatOptions {
|
|
4
|
+
title?: string;
|
|
5
|
+
position?: {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
};
|
|
9
|
+
size?: Size;
|
|
10
|
+
}
|
|
11
|
+
export interface FloatManager {
|
|
12
|
+
open(viewId: string, options?: FloatOptions): string;
|
|
13
|
+
close(floatId: string): void;
|
|
14
|
+
list(): FloatEntry[];
|
|
15
|
+
focus(floatId: string): void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Bind the manager to the active LayoutTree's `floats` array. Called
|
|
19
|
+
* from Shell.svelte during boot. `getBounds` returns the current
|
|
20
|
+
* tree-allocated area for cascade-position wraparound.
|
|
21
|
+
*/
|
|
22
|
+
export declare function bindFloatStore(floats: FloatEntry[], getBounds: () => {
|
|
23
|
+
w: number;
|
|
24
|
+
h: number;
|
|
25
|
+
}): void;
|
|
26
|
+
export declare function unbindFloatStore(): void;
|
|
27
|
+
/** Test-only reset. Clears in-memory fallback and unbinds any store. */
|
|
28
|
+
export declare function __resetFloatManagerForTest(): void;
|
|
29
|
+
export declare const floatManager: FloatManager;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Float manager — layer 1 of the overlay stack.
|
|
3
|
+
*
|
|
4
|
+
* Public API (shell.float):
|
|
5
|
+
* open(viewId, options?) → floatId
|
|
6
|
+
* close(floatId)
|
|
7
|
+
* list() → FloatEntry[]
|
|
8
|
+
* focus(floatId) — raise to top of z-order within layer 1
|
|
9
|
+
*
|
|
10
|
+
* Semantics (see docs/design/layout.md layer stack and docs/superpowers/spec/
|
|
11
|
+
* 2026-04-11-layout-topology-design.md):
|
|
12
|
+
* - Float content is drawn by FloatLayer.svelte via the same LayoutRenderer
|
|
13
|
+
* used by the docked tree. The manager only mutates data; rendering is
|
|
14
|
+
* a pure reaction to the LayoutTree's `floats` array.
|
|
15
|
+
* - Cascade default position, default size = max(600x400, computed min).
|
|
16
|
+
* - Click-to-raise z-order: the most recently focused float is the last
|
|
17
|
+
* element of `floats[]` and therefore drawn on top.
|
|
18
|
+
* - Auto-close on empty is enforced by the drag commit path (see Task 3.3),
|
|
19
|
+
* not by this manager.
|
|
20
|
+
* - Multiple floats of the same viewId are allowed; toggle semantics are
|
|
21
|
+
* userland (caller checks list() and decides).
|
|
22
|
+
*
|
|
23
|
+
* Binding:
|
|
24
|
+
* The manager is bound to a live FloatEntry[] (the active LayoutTree's
|
|
25
|
+
* floats) by `bindFloatStore()` during Shell boot. Before binding, an
|
|
26
|
+
* in-memory fallback array is used — this is both the test environment
|
|
27
|
+
* and the pre-boot state.
|
|
28
|
+
*/
|
|
29
|
+
import { computeMinSize, cascadePosition, generateFloatId } from '../layout/floats';
|
|
30
|
+
// ----- storage binding ---------------------------------------------------
|
|
31
|
+
let fallbackFloats = [];
|
|
32
|
+
let boundFloats = null;
|
|
33
|
+
let getTreeBounds = () => ({ w: 1600, h: 900 });
|
|
34
|
+
/**
|
|
35
|
+
* Bind the manager to the active LayoutTree's `floats` array. Called
|
|
36
|
+
* from Shell.svelte during boot. `getBounds` returns the current
|
|
37
|
+
* tree-allocated area for cascade-position wraparound.
|
|
38
|
+
*/
|
|
39
|
+
export function bindFloatStore(floats, getBounds) {
|
|
40
|
+
boundFloats = floats;
|
|
41
|
+
getTreeBounds = getBounds;
|
|
42
|
+
}
|
|
43
|
+
export function unbindFloatStore() {
|
|
44
|
+
boundFloats = null;
|
|
45
|
+
getTreeBounds = () => ({ w: 1600, h: 900 });
|
|
46
|
+
}
|
|
47
|
+
/** Test-only reset. Clears in-memory fallback and unbinds any store. */
|
|
48
|
+
export function __resetFloatManagerForTest() {
|
|
49
|
+
fallbackFloats = [];
|
|
50
|
+
boundFloats = null;
|
|
51
|
+
getTreeBounds = () => ({ w: 1600, h: 900 });
|
|
52
|
+
}
|
|
53
|
+
function activeStore() {
|
|
54
|
+
return boundFloats !== null && boundFloats !== void 0 ? boundFloats : fallbackFloats;
|
|
55
|
+
}
|
|
56
|
+
// ----- slot id minting ---------------------------------------------------
|
|
57
|
+
let floatSlotCounter = 0;
|
|
58
|
+
function mintFloatSlotId(viewId) {
|
|
59
|
+
floatSlotCounter += 1;
|
|
60
|
+
return `float:${viewId}:${floatSlotCounter}`;
|
|
61
|
+
}
|
|
62
|
+
// ----- API ---------------------------------------------------------------
|
|
63
|
+
const DEFAULT_SIZE = { w: 600, h: 400 };
|
|
64
|
+
function maxSize(a, b) {
|
|
65
|
+
return { w: Math.max(a.w, b.w), h: Math.max(a.h, b.h) };
|
|
66
|
+
}
|
|
67
|
+
function openFloat(viewId, options = {}) {
|
|
68
|
+
var _a, _b, _c;
|
|
69
|
+
const store = activeStore();
|
|
70
|
+
const id = generateFloatId();
|
|
71
|
+
// Wrap the slot in a single-tab TabsNode so the tab strip acts as a
|
|
72
|
+
// drag handle — that's the only way to drag the view back into the
|
|
73
|
+
// docked tree. The TabsNode's tab strip appears at the top of the
|
|
74
|
+
// float body; the frame header still moves the float as a whole.
|
|
75
|
+
const slotId = mintFloatSlotId(viewId);
|
|
76
|
+
const label = (_a = options.title) !== null && _a !== void 0 ? _a : viewId;
|
|
77
|
+
const content = {
|
|
78
|
+
type: 'tabs',
|
|
79
|
+
tabs: [{ slotId, viewId, label }],
|
|
80
|
+
activeTab: 0,
|
|
81
|
+
};
|
|
82
|
+
const computedMin = computeMinSize(content);
|
|
83
|
+
const size = (_b = options.size) !== null && _b !== void 0 ? _b : maxSize(DEFAULT_SIZE, computedMin);
|
|
84
|
+
const position = (_c = options.position) !== null && _c !== void 0 ? _c : cascadePosition(store, getTreeBounds());
|
|
85
|
+
const entry = {
|
|
86
|
+
id,
|
|
87
|
+
content,
|
|
88
|
+
position,
|
|
89
|
+
size,
|
|
90
|
+
title: options.title,
|
|
91
|
+
};
|
|
92
|
+
store.push(entry);
|
|
93
|
+
return id;
|
|
94
|
+
}
|
|
95
|
+
function closeFloat(floatId) {
|
|
96
|
+
const store = activeStore();
|
|
97
|
+
const idx = store.findIndex((f) => f.id === floatId);
|
|
98
|
+
if (idx < 0)
|
|
99
|
+
return;
|
|
100
|
+
store.splice(idx, 1);
|
|
101
|
+
}
|
|
102
|
+
function listFloats() {
|
|
103
|
+
// Return a snapshot so callers can iterate without racing mutations.
|
|
104
|
+
return activeStore().slice();
|
|
105
|
+
}
|
|
106
|
+
function focusFloat(floatId) {
|
|
107
|
+
const store = activeStore();
|
|
108
|
+
const idx = store.findIndex((f) => f.id === floatId);
|
|
109
|
+
if (idx < 0 || idx === store.length - 1)
|
|
110
|
+
return;
|
|
111
|
+
const [entry] = store.splice(idx, 1);
|
|
112
|
+
store.push(entry);
|
|
113
|
+
}
|
|
114
|
+
export const floatManager = {
|
|
115
|
+
open: openFloat,
|
|
116
|
+
close: closeFloat,
|
|
117
|
+
list: listFloats,
|
|
118
|
+
focus: focusFloat,
|
|
119
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { floatManager, __resetFloatManagerForTest } from './float';
|
|
3
|
+
describe('floatManager', () => {
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
__resetFloatManagerForTest();
|
|
6
|
+
});
|
|
7
|
+
it('open() returns a stable id and list() includes the new float', () => {
|
|
8
|
+
const id = floatManager.open('test:view');
|
|
9
|
+
expect(typeof id).toBe('string');
|
|
10
|
+
expect(id.length).toBeGreaterThan(0);
|
|
11
|
+
const listed = floatManager.list();
|
|
12
|
+
expect(listed).toHaveLength(1);
|
|
13
|
+
expect(listed[0].id).toBe(id);
|
|
14
|
+
});
|
|
15
|
+
it('close(id) removes the float from list()', () => {
|
|
16
|
+
const id = floatManager.open('test:view');
|
|
17
|
+
floatManager.close(id);
|
|
18
|
+
expect(floatManager.list()).toHaveLength(0);
|
|
19
|
+
});
|
|
20
|
+
it('focus(id) moves the float to the end of the list (top of z-order)', () => {
|
|
21
|
+
const a = floatManager.open('view:a');
|
|
22
|
+
const b = floatManager.open('view:b');
|
|
23
|
+
const c = floatManager.open('view:c');
|
|
24
|
+
floatManager.focus(a);
|
|
25
|
+
const order = floatManager.list().map((f) => f.id);
|
|
26
|
+
expect(order).toEqual([b, c, a]);
|
|
27
|
+
});
|
|
28
|
+
it('open() respects options.position and options.size', () => {
|
|
29
|
+
const id = floatManager.open('test:view', {
|
|
30
|
+
position: { x: 100, y: 200 },
|
|
31
|
+
size: { w: 800, h: 500 },
|
|
32
|
+
});
|
|
33
|
+
const f = floatManager.list().find((e) => e.id === id);
|
|
34
|
+
expect(f.position).toEqual({ x: 100, y: 200 });
|
|
35
|
+
expect(f.size).toEqual({ w: 800, h: 500 });
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AppLayoutBlob } from '../layout/types';
|
|
2
|
+
export interface PresetManager {
|
|
3
|
+
/** All known preset names in declaration order. */
|
|
4
|
+
list(): string[];
|
|
5
|
+
/** Currently active preset name. */
|
|
6
|
+
active(): string;
|
|
7
|
+
/** Switch to the named preset. Throws if unknown. */
|
|
8
|
+
switch(name: string): void;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Bind the manager to the attached app's `AppLayoutBlob` workspace-zone
|
|
12
|
+
* proxy. Called from `attachApp` in the layout store.
|
|
13
|
+
*/
|
|
14
|
+
export declare function bindPresetBlob(blob: AppLayoutBlob): void;
|
|
15
|
+
/** Unbind on detach. Called from `detachApp`. */
|
|
16
|
+
export declare function unbindPresetBlob(): void;
|
|
17
|
+
/** Test-only bind alias for tests that build a synthetic blob. */
|
|
18
|
+
export declare function __bindPresetBlobForTest(blob: AppLayoutBlob): void;
|
|
19
|
+
/** Test-only reset. Clears the binding. */
|
|
20
|
+
export declare function __resetPresetManagerForTest(): void;
|
|
21
|
+
export declare const presetManager: PresetManager;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Preset manager — controls which named LayoutPreset is currently active
|
|
3
|
+
* for the attached app. Mutations flow through the bound `AppLayoutBlob`
|
|
4
|
+
* proxy (workspace state zone), so switches persist automatically.
|
|
5
|
+
*
|
|
6
|
+
* v1 always reads/writes the 'default' variant; non-default keys sit inert
|
|
7
|
+
* until the rescoped DF10 selection policy lands (ADR-012).
|
|
8
|
+
*
|
|
9
|
+
* Saved-state-per-preset semantics:
|
|
10
|
+
* On switch-out, the current tree is already live-bound to
|
|
11
|
+
* blob.presets[activePreset].default via the layout store — customizations
|
|
12
|
+
* persist without any explicit save step. On switch-in, updating
|
|
13
|
+
* blob.activePreset causes `activeLayout()` in the store to re-drill into
|
|
14
|
+
* the new preset's default variant; reactivity propagates to renderers.
|
|
15
|
+
*
|
|
16
|
+
* Binding lifecycle mirrors the float manager: attachApp() calls
|
|
17
|
+
* bindPresetBlob(proxy), detachApp() calls unbindPresetBlob(). Before binding,
|
|
18
|
+
* all methods throw — there is no pre-boot fallback.
|
|
19
|
+
*/
|
|
20
|
+
let boundBlob = null;
|
|
21
|
+
/**
|
|
22
|
+
* Bind the manager to the attached app's `AppLayoutBlob` workspace-zone
|
|
23
|
+
* proxy. Called from `attachApp` in the layout store.
|
|
24
|
+
*/
|
|
25
|
+
export function bindPresetBlob(blob) {
|
|
26
|
+
boundBlob = blob;
|
|
27
|
+
}
|
|
28
|
+
/** Unbind on detach. Called from `detachApp`. */
|
|
29
|
+
export function unbindPresetBlob() {
|
|
30
|
+
boundBlob = null;
|
|
31
|
+
}
|
|
32
|
+
/** Test-only bind alias for tests that build a synthetic blob. */
|
|
33
|
+
export function __bindPresetBlobForTest(blob) {
|
|
34
|
+
boundBlob = blob;
|
|
35
|
+
}
|
|
36
|
+
/** Test-only reset. Clears the binding. */
|
|
37
|
+
export function __resetPresetManagerForTest() {
|
|
38
|
+
boundBlob = null;
|
|
39
|
+
}
|
|
40
|
+
function requireBlob() {
|
|
41
|
+
if (!boundBlob) {
|
|
42
|
+
throw new Error('presetManager: no app attached — bind an AppLayoutBlob first');
|
|
43
|
+
}
|
|
44
|
+
return boundBlob;
|
|
45
|
+
}
|
|
46
|
+
function listPresets() {
|
|
47
|
+
return Object.keys(requireBlob().presets);
|
|
48
|
+
}
|
|
49
|
+
function activePreset() {
|
|
50
|
+
return requireBlob().activePreset;
|
|
51
|
+
}
|
|
52
|
+
function switchPreset(name) {
|
|
53
|
+
const blob = requireBlob();
|
|
54
|
+
if (!(name in blob.presets)) {
|
|
55
|
+
throw new Error(`presetManager.switch: unknown preset "${name}"`);
|
|
56
|
+
}
|
|
57
|
+
blob.activePreset = name;
|
|
58
|
+
}
|
|
59
|
+
export const presetManager = {
|
|
60
|
+
list: listPresets,
|
|
61
|
+
active: activePreset,
|
|
62
|
+
switch: switchPreset,
|
|
63
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { presetManager, __bindPresetBlobForTest, __resetPresetManagerForTest, } from './presets';
|
|
3
|
+
const makeBlob = (activePreset, names) => ({
|
|
4
|
+
layoutVersion: 1,
|
|
5
|
+
activePreset,
|
|
6
|
+
presets: Object.fromEntries(names.map((n) => [
|
|
7
|
+
n,
|
|
8
|
+
{
|
|
9
|
+
default: {
|
|
10
|
+
docked: { type: 'slot', slotId: `${n}-s`, viewId: 'v' },
|
|
11
|
+
floats: [],
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
])),
|
|
15
|
+
});
|
|
16
|
+
describe('presetManager', () => {
|
|
17
|
+
beforeEach(() => __resetPresetManagerForTest());
|
|
18
|
+
it('list() returns all preset names in insertion order', () => {
|
|
19
|
+
__bindPresetBlobForTest(makeBlob('author', ['author', 'review', 'inspect']));
|
|
20
|
+
expect(presetManager.list()).toEqual(['author', 'review', 'inspect']);
|
|
21
|
+
});
|
|
22
|
+
it('active() returns the active preset name', () => {
|
|
23
|
+
__bindPresetBlobForTest(makeBlob('review', ['author', 'review']));
|
|
24
|
+
expect(presetManager.active()).toBe('review');
|
|
25
|
+
});
|
|
26
|
+
it('switch(name) updates active preset', () => {
|
|
27
|
+
const blob = makeBlob('author', ['author', 'review']);
|
|
28
|
+
__bindPresetBlobForTest(blob);
|
|
29
|
+
presetManager.switch('review');
|
|
30
|
+
expect(blob.activePreset).toBe('review');
|
|
31
|
+
expect(presetManager.active()).toBe('review');
|
|
32
|
+
});
|
|
33
|
+
it('switch(name) throws if preset name is unknown', () => {
|
|
34
|
+
__bindPresetBlobForTest(makeBlob('author', ['author']));
|
|
35
|
+
expect(() => presetManager.switch('nope')).toThrow(/unknown preset/);
|
|
36
|
+
});
|
|
37
|
+
it('list() throws when no blob is bound', () => {
|
|
38
|
+
expect(() => presetManager.list()).toThrow(/no app attached/);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -60,6 +60,20 @@ export declare function fetchRegistries(urls: string[]): Promise<ResolvedPackage
|
|
|
60
60
|
* @throws If the fetch fails, the server returns a non-OK status, or the integrity check fails.
|
|
61
61
|
*/
|
|
62
62
|
export declare function fetchBundle(version: PackageVersion, sourceRegistry?: string): Promise<ArrayBuffer>;
|
|
63
|
+
/**
|
|
64
|
+
* Download a server-side bundle and optionally verify its SRI integrity.
|
|
65
|
+
*
|
|
66
|
+
* Mirrors `fetchBundle` except `serverIntegrity` is optional in contract v1
|
|
67
|
+
* (see ADR-015 proposal). When absent, the download is returned without
|
|
68
|
+
* verification and a warning is logged so operators notice provisional
|
|
69
|
+
* registries. When present, an integrity mismatch throws.
|
|
70
|
+
*
|
|
71
|
+
* @param version - The `PackageVersion` describing the server bundle.
|
|
72
|
+
* @param sourceRegistry - The registry URL (used to resolve relative paths).
|
|
73
|
+
* @returns Raw server bundle bytes.
|
|
74
|
+
* @throws If `serverBundleUrl` is absent, the fetch fails, or an integrity mismatch occurs.
|
|
75
|
+
*/
|
|
76
|
+
export declare function fetchServerBundle(version: PackageVersion, sourceRegistry?: string): Promise<ArrayBuffer>;
|
|
63
77
|
/**
|
|
64
78
|
* Build a `PackageMeta` record from a resolved package and a chosen version.
|
|
65
79
|
*
|
package/dist/registry/client.js
CHANGED
|
@@ -93,6 +93,41 @@ export async function fetchBundle(version, sourceRegistry) {
|
|
|
93
93
|
await verifyIntegrity(data, version.integrity);
|
|
94
94
|
return data;
|
|
95
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Download a server-side bundle and optionally verify its SRI integrity.
|
|
98
|
+
*
|
|
99
|
+
* Mirrors `fetchBundle` except `serverIntegrity` is optional in contract v1
|
|
100
|
+
* (see ADR-015 proposal). When absent, the download is returned without
|
|
101
|
+
* verification and a warning is logged so operators notice provisional
|
|
102
|
+
* registries. When present, an integrity mismatch throws.
|
|
103
|
+
*
|
|
104
|
+
* @param version - The `PackageVersion` describing the server bundle.
|
|
105
|
+
* @param sourceRegistry - The registry URL (used to resolve relative paths).
|
|
106
|
+
* @returns Raw server bundle bytes.
|
|
107
|
+
* @throws If `serverBundleUrl` is absent, the fetch fails, or an integrity mismatch occurs.
|
|
108
|
+
*/
|
|
109
|
+
export async function fetchServerBundle(version, sourceRegistry) {
|
|
110
|
+
if (!version.serverBundleUrl) {
|
|
111
|
+
throw new Error('fetchServerBundle called on a version with no serverBundleUrl');
|
|
112
|
+
}
|
|
113
|
+
let url = version.serverBundleUrl;
|
|
114
|
+
if (sourceRegistry && !/^https?:\/\//i.test(url)) {
|
|
115
|
+
url = new URL(url, sourceRegistry).href;
|
|
116
|
+
}
|
|
117
|
+
const response = await fetch(url);
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
throw new Error(`Server bundle fetch failed: HTTP ${response.status} ${response.statusText} from ${url}`);
|
|
120
|
+
}
|
|
121
|
+
const data = await response.arrayBuffer();
|
|
122
|
+
if (version.serverIntegrity) {
|
|
123
|
+
await verifyIntegrity(data, version.serverIntegrity);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
console.warn(`[sh3] Server bundle at ${url} has no serverIntegrity declared — skipping SRI check. `
|
|
127
|
+
+ 'This will become an error once the formal registry spec (ADR-015) lands.');
|
|
128
|
+
}
|
|
129
|
+
return data;
|
|
130
|
+
}
|
|
96
131
|
/**
|
|
97
132
|
* Build a `PackageMeta` record from a resolved package and a chosen version.
|
|
98
133
|
*
|
|
@@ -113,5 +148,7 @@ export function buildPackageMeta(resolved, version) {
|
|
|
113
148
|
sourceRegistry: resolved.sourceRegistry,
|
|
114
149
|
integrity: version.integrity,
|
|
115
150
|
requires: version.requires,
|
|
151
|
+
hasServerBundle: Boolean(version.serverBundleUrl),
|
|
152
|
+
serverIntegrity: version.serverIntegrity,
|
|
116
153
|
};
|
|
117
154
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildPackageMeta } from './client.js';
|
|
3
|
+
function makeResolved(version) {
|
|
4
|
+
return {
|
|
5
|
+
entry: {
|
|
6
|
+
id: 'test-pkg',
|
|
7
|
+
type: 'shard',
|
|
8
|
+
label: 'Test',
|
|
9
|
+
description: 'd',
|
|
10
|
+
author: { name: 'a' },
|
|
11
|
+
versions: [version],
|
|
12
|
+
},
|
|
13
|
+
latest: version,
|
|
14
|
+
sourceRegistry: 'https://example.com/registry.json',
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
describe('buildPackageMeta', () => {
|
|
18
|
+
it('sets hasServerBundle false when no serverBundleUrl', () => {
|
|
19
|
+
const v = {
|
|
20
|
+
version: '1.0.0',
|
|
21
|
+
contractVersion: '0.1.0',
|
|
22
|
+
bundleUrl: '/b.js',
|
|
23
|
+
integrity: 'sha384-xxx',
|
|
24
|
+
};
|
|
25
|
+
const meta = buildPackageMeta(makeResolved(v), v);
|
|
26
|
+
expect(meta.hasServerBundle).toBe(false);
|
|
27
|
+
expect(meta.serverIntegrity).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
it('sets hasServerBundle true and propagates serverIntegrity when present', () => {
|
|
30
|
+
const v = {
|
|
31
|
+
version: '1.0.0',
|
|
32
|
+
contractVersion: '0.1.0',
|
|
33
|
+
bundleUrl: '/b.js',
|
|
34
|
+
integrity: 'sha384-xxx',
|
|
35
|
+
serverBundleUrl: '/s.js',
|
|
36
|
+
serverIntegrity: 'sha384-yyy',
|
|
37
|
+
};
|
|
38
|
+
const meta = buildPackageMeta(makeResolved(v), v);
|
|
39
|
+
expect(meta.hasServerBundle).toBe(true);
|
|
40
|
+
expect(meta.serverIntegrity).toBe('sha384-yyy');
|
|
41
|
+
});
|
|
42
|
+
it('sets hasServerBundle true even when serverIntegrity is missing', () => {
|
|
43
|
+
const v = {
|
|
44
|
+
version: '1.0.0',
|
|
45
|
+
contractVersion: '0.1.0',
|
|
46
|
+
bundleUrl: '/b.js',
|
|
47
|
+
integrity: 'sha384-xxx',
|
|
48
|
+
serverBundleUrl: '/s.js',
|
|
49
|
+
};
|
|
50
|
+
const meta = buildPackageMeta(makeResolved(v), v);
|
|
51
|
+
expect(meta.hasServerBundle).toBe(true);
|
|
52
|
+
expect(meta.serverIntegrity).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -88,13 +88,20 @@ export async function installPackage(bundle, meta) {
|
|
|
88
88
|
error: `Failed to persist package: ${err instanceof Error ? err.message : String(err)}`,
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
|
-
// 5.
|
|
91
|
+
// 5. Stamp loader-assigned version (ADR-013) then register all shards
|
|
92
|
+
// and apps from the bundle. External package authors omit `version`
|
|
93
|
+
// from their source manifests; the authoritative value is the
|
|
94
|
+
// registry entry's `PackageVersion.version`, carried on `meta.version`.
|
|
92
95
|
let hotLoaded = true;
|
|
93
96
|
try {
|
|
94
|
-
for (const shard of loaded.shards)
|
|
97
|
+
for (const shard of loaded.shards) {
|
|
98
|
+
shard.manifest = Object.assign(Object.assign({}, shard.manifest), { version: meta.version });
|
|
95
99
|
registerShard(shard);
|
|
96
|
-
|
|
100
|
+
}
|
|
101
|
+
for (const app of loaded.apps) {
|
|
102
|
+
app.manifest = Object.assign(Object.assign({}, app.manifest), { version: meta.version });
|
|
97
103
|
registerApp(app);
|
|
104
|
+
}
|
|
98
105
|
}
|
|
99
106
|
catch (err) {
|
|
100
107
|
console.warn(`[sh3] Package "${meta.id}" installed but registration failed (will retry on next boot):`, err instanceof Error ? err.message : err);
|
|
@@ -153,10 +160,16 @@ export async function loadInstalledPackages() {
|
|
|
153
160
|
continue;
|
|
154
161
|
}
|
|
155
162
|
const loaded = await loadBundleModule(bytes);
|
|
156
|
-
|
|
163
|
+
// Stamp loader-assigned version (ADR-013) from the persisted
|
|
164
|
+
// InstalledPackage record before registration.
|
|
165
|
+
for (const shard of loaded.shards) {
|
|
166
|
+
shard.manifest = Object.assign(Object.assign({}, shard.manifest), { version: pkg.version });
|
|
157
167
|
registerShard(shard);
|
|
158
|
-
|
|
168
|
+
}
|
|
169
|
+
for (const app of loaded.apps) {
|
|
170
|
+
app.manifest = Object.assign(Object.assign({}, app.manifest), { version: pkg.version });
|
|
159
171
|
registerApp(app);
|
|
172
|
+
}
|
|
160
173
|
if (loaded.shards.length === 0 && loaded.apps.length === 0) {
|
|
161
174
|
console.warn(`[sh3] Package "${pkg.id}" contains no valid shards or apps, skipping`);
|
|
162
175
|
}
|