sh3-core 0.10.5 → 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.
- package/dist/Shell.svelte +12 -31
- package/dist/__test__/reset.js +6 -0
- package/dist/actions/CommandPalette.svelte +68 -0
- package/dist/actions/CommandPalette.svelte.d.ts +11 -0
- package/dist/actions/ContextMenu.svelte +97 -0
- package/dist/actions/ContextMenu.svelte.d.ts +9 -0
- package/dist/actions/bindings-store.d.ts +8 -0
- package/dist/actions/bindings-store.js +27 -0
- package/dist/actions/bindings-store.test.d.ts +1 -0
- package/dist/actions/bindings-store.test.js +25 -0
- package/dist/actions/bindings.d.ts +4 -0
- package/dist/actions/bindings.js +17 -0
- package/dist/actions/bindings.test.d.ts +1 -0
- package/dist/actions/bindings.test.js +30 -0
- package/dist/actions/contextMenuModel.d.ts +16 -0
- package/dist/actions/contextMenuModel.js +71 -0
- package/dist/actions/contextMenuModel.test.d.ts +1 -0
- package/dist/actions/contextMenuModel.test.js +44 -0
- package/dist/actions/dispatcher.svelte.d.ts +34 -0
- package/dist/actions/dispatcher.svelte.js +117 -0
- package/dist/actions/dispatcher.test.d.ts +1 -0
- package/dist/actions/dispatcher.test.js +155 -0
- package/dist/actions/listeners.d.ts +11 -0
- package/dist/actions/listeners.js +180 -0
- package/dist/actions/listeners.test.d.ts +1 -0
- package/dist/actions/listeners.test.js +149 -0
- package/dist/actions/palette-scorer.d.ts +11 -0
- package/dist/actions/palette-scorer.js +49 -0
- package/dist/actions/palette-scorer.test.d.ts +1 -0
- package/dist/actions/palette-scorer.test.js +40 -0
- package/dist/actions/paletteModel.d.ts +4 -0
- package/dist/actions/paletteModel.js +40 -0
- package/dist/actions/paletteModel.test.d.ts +1 -0
- package/dist/actions/paletteModel.test.js +33 -0
- package/dist/actions/registry.d.ts +10 -0
- package/dist/actions/registry.js +36 -0
- package/dist/actions/registry.test.d.ts +1 -0
- package/dist/actions/registry.test.js +49 -0
- package/dist/actions/selection.svelte.d.ts +8 -0
- package/dist/actions/selection.svelte.js +44 -0
- package/dist/actions/selection.test.d.ts +1 -0
- package/dist/actions/selection.test.js +51 -0
- package/dist/actions/shardContext.test.d.ts +1 -0
- package/dist/actions/shardContext.test.js +41 -0
- package/dist/actions/shellActions.test.d.ts +1 -0
- package/dist/actions/shellActions.test.js +22 -0
- package/dist/actions/shortcuts.d.ts +5 -0
- package/dist/actions/shortcuts.js +87 -0
- package/dist/actions/shortcuts.test.d.ts +1 -0
- package/dist/actions/shortcuts.test.js +49 -0
- package/dist/actions/state.svelte.d.ts +16 -0
- package/dist/actions/state.svelte.js +76 -0
- package/dist/actions/state.test.d.ts +1 -0
- package/dist/actions/state.test.js +40 -0
- package/dist/actions/syncMountedViewIds.test.d.ts +1 -0
- package/dist/actions/syncMountedViewIds.test.js +97 -0
- package/dist/actions/types.d.ts +56 -0
- package/dist/actions/types.js +7 -0
- package/dist/api.d.ts +2 -2
- package/dist/api.js +1 -1
- package/dist/apps/lifecycle.js +13 -3
- package/dist/createShell.js +4 -1
- package/dist/host.js +6 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/layout/inspection.d.ts +11 -1
- package/dist/layout/inspection.js +13 -1
- package/dist/layout/ops-locate.test.d.ts +1 -0
- package/dist/layout/ops-locate.test.js +103 -0
- package/dist/layout/ops.d.ts +8 -0
- package/dist/layout/ops.js +27 -0
- package/dist/layout/slotHostPool.svelte.js +24 -0
- package/dist/layout/slotHostPool.test.js +14 -0
- package/dist/layout/types.d.ts +7 -0
- package/dist/overlays/FloatFrame.svelte +23 -11
- package/dist/overlays/ModalFrame.svelte +9 -1
- package/dist/overlays/ModalFrame.svelte.d.ts +1 -0
- package/dist/overlays/__test__/DummyFrame.svelte +6 -0
- package/dist/overlays/__test__/DummyFrame.svelte.d.ts +6 -0
- package/dist/overlays/float.d.ts +6 -0
- package/dist/overlays/float.js +24 -9
- package/dist/overlays/float.test.js +175 -0
- package/dist/overlays/floatDismiss.d.ts +8 -0
- package/dist/overlays/floatDismiss.js +68 -0
- package/dist/overlays/modal.js +5 -1
- package/dist/overlays/modal.test.d.ts +1 -0
- package/dist/overlays/modal.test.js +55 -0
- package/dist/overlays/popup.d.ts +2 -0
- package/dist/overlays/popup.js +24 -4
- package/dist/overlays/popup.test.d.ts +1 -0
- package/dist/overlays/popup.test.js +95 -0
- package/dist/overlays/types.d.ts +17 -1
- package/dist/primitives/Button.svelte +144 -0
- package/dist/primitives/Button.svelte.d.ts +18 -0
- package/dist/primitives/icon-context.d.ts +15 -0
- package/dist/primitives/icon-context.js +29 -0
- package/dist/sh3core-shard/sh3coreShard.svelte.js +50 -0
- package/dist/shards/activate.svelte.js +14 -0
- package/dist/shards/types.d.ts +19 -0
- package/dist/shards/types.js +5 -4
- package/dist/shell-shard/locateSlot.test.d.ts +1 -0
- package/dist/shell-shard/locateSlot.test.js +101 -0
- package/dist/shell-shard/shellShard.svelte.d.ts +7 -0
- package/dist/shell-shard/shellShard.svelte.js +34 -1
- package/dist/shellRuntime.svelte.d.ts +19 -0
- package/dist/shellRuntime.svelte.js +30 -0
- package/dist/tokens.css +11 -1
- package/dist/verbs/types.d.ts +9 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/apps/terminal/manifest.d.ts +0 -8
- package/dist/apps/terminal/manifest.js +0 -14
- package/dist/apps/terminal/terminal-app.d.ts +0 -7
- package/dist/apps/terminal/terminal-app.js +0 -14
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { tick } from 'svelte';
|
|
3
|
+
import { popupManager, __resetPopupManagerForTest } from './popup';
|
|
4
|
+
import { registerLayerRoot, unregisterLayerRoot } from './roots';
|
|
5
|
+
import DummyFrame from './__test__/DummyFrame.svelte';
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
function makeLayerRoot() {
|
|
10
|
+
const el = document.createElement('div');
|
|
11
|
+
el.style.position = 'relative';
|
|
12
|
+
document.body.appendChild(el);
|
|
13
|
+
registerLayerRoot('popup', el);
|
|
14
|
+
return el;
|
|
15
|
+
}
|
|
16
|
+
function teardownLayerRoot(el) {
|
|
17
|
+
unregisterLayerRoot('popup');
|
|
18
|
+
el.remove();
|
|
19
|
+
}
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// P.1 — virtual-point anchor (context-menu use case)
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
describe('popup — P.1 virtual-point anchor', () => {
|
|
24
|
+
let layerRoot;
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
// Give the viewport a real size so the overflow clamp in PopupFrame
|
|
27
|
+
// doesn't crush our anchor coordinates to the clamped minimum.
|
|
28
|
+
vi.stubGlobal('innerWidth', 2000);
|
|
29
|
+
vi.stubGlobal('innerHeight', 2000);
|
|
30
|
+
layerRoot = makeLayerRoot();
|
|
31
|
+
});
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
__resetPopupManagerForTest();
|
|
34
|
+
teardownLayerRoot(layerRoot);
|
|
35
|
+
vi.unstubAllGlobals();
|
|
36
|
+
});
|
|
37
|
+
it('accepts { x, y } as anchor without throwing', () => {
|
|
38
|
+
expect(() => {
|
|
39
|
+
popupManager.show(DummyFrame, { anchor: { x: 100, y: 200 } }, {});
|
|
40
|
+
}).not.toThrow();
|
|
41
|
+
});
|
|
42
|
+
it('mounts the popup host inside the layer root', () => {
|
|
43
|
+
popupManager.show(DummyFrame, { anchor: { x: 100, y: 200 } }, {});
|
|
44
|
+
const host = layerRoot.querySelector('.sh3-popup-host');
|
|
45
|
+
expect(host).not.toBeNull();
|
|
46
|
+
});
|
|
47
|
+
it('renders the dummy frame content inside the host', async () => {
|
|
48
|
+
popupManager.show(DummyFrame, { anchor: { x: 100, y: 200 } }, {});
|
|
49
|
+
await tick();
|
|
50
|
+
const content = layerRoot.querySelector('.dummy-popup-content');
|
|
51
|
+
expect(content).not.toBeNull();
|
|
52
|
+
});
|
|
53
|
+
it('positions the popup frame at bottom-start of the virtual point', async () => {
|
|
54
|
+
popupManager.show(DummyFrame, { anchor: { x: 100, y: 200 } }, {});
|
|
55
|
+
await tick();
|
|
56
|
+
// PopupFrame sets top = anchor.bottom + 4 = 200 + 4 = 204 (zero-size rect: bottom === y).
|
|
57
|
+
// left = anchor.left = 100 (no overflow with 2000px viewport).
|
|
58
|
+
const frame = layerRoot.querySelector('.popup-frame');
|
|
59
|
+
expect(frame).not.toBeNull();
|
|
60
|
+
expect(frame.style.top).toBe('204px');
|
|
61
|
+
expect(frame.style.left).toBe('100px');
|
|
62
|
+
});
|
|
63
|
+
it('close() removes the host from the DOM', async () => {
|
|
64
|
+
popupManager.show(DummyFrame, { anchor: { x: 50, y: 80 } }, {});
|
|
65
|
+
await tick();
|
|
66
|
+
expect(layerRoot.querySelector('.sh3-popup-host')).not.toBeNull();
|
|
67
|
+
popupManager.close();
|
|
68
|
+
await tick();
|
|
69
|
+
expect(layerRoot.querySelector('.sh3-popup-host')).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// P.2 — HTMLElement anchor still works (regression guard)
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
describe('popup — P.2 HTMLElement anchor regression', () => {
|
|
76
|
+
let layerRoot;
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
vi.stubGlobal('innerWidth', 2000);
|
|
79
|
+
vi.stubGlobal('innerHeight', 2000);
|
|
80
|
+
layerRoot = makeLayerRoot();
|
|
81
|
+
});
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
__resetPopupManagerForTest();
|
|
84
|
+
teardownLayerRoot(layerRoot);
|
|
85
|
+
vi.unstubAllGlobals();
|
|
86
|
+
});
|
|
87
|
+
it('accepts an HTMLElement anchor without throwing', () => {
|
|
88
|
+
const anchor = document.createElement('button');
|
|
89
|
+
document.body.appendChild(anchor);
|
|
90
|
+
expect(() => {
|
|
91
|
+
popupManager.show(DummyFrame, { anchor }, {});
|
|
92
|
+
}).not.toThrow();
|
|
93
|
+
anchor.remove();
|
|
94
|
+
});
|
|
95
|
+
});
|
package/dist/overlays/types.d.ts
CHANGED
|
@@ -9,8 +9,17 @@ export type ToastHandle = OverlayHandle;
|
|
|
9
9
|
export type ToastLevel = 'info' | 'warn' | 'error' | 'success';
|
|
10
10
|
/** Where a popup should sit relative to its anchor. Phase 5 ships bottom-start. */
|
|
11
11
|
export type PopupPlacement = 'bottom-start';
|
|
12
|
+
/**
|
|
13
|
+
* Anchor for a popup — either an HTMLElement whose bounding rect is used, or
|
|
14
|
+
* a virtual point `{ x, y }` in viewport coordinates (used by context menus
|
|
15
|
+
* that anchor at pointer coordinates).
|
|
16
|
+
*/
|
|
17
|
+
export type PopupAnchor = HTMLElement | {
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
};
|
|
12
21
|
export interface PopupOptions {
|
|
13
|
-
anchor:
|
|
22
|
+
anchor: PopupAnchor;
|
|
14
23
|
placement?: PopupPlacement;
|
|
15
24
|
}
|
|
16
25
|
export interface ToastOptions {
|
|
@@ -28,4 +37,11 @@ export interface ModalOptions {
|
|
|
28
37
|
* views like non-centered palettes or inspectors.
|
|
29
38
|
*/
|
|
30
39
|
boxStyle?: string;
|
|
40
|
+
/**
|
|
41
|
+
* When true, a click on the frame area outside the dialog box closes the
|
|
42
|
+
* modal. Default false — content owns its own close UI. Opt-in is for
|
|
43
|
+
* picker-style modals like the command palette where outside-click is the
|
|
44
|
+
* expected dismissal gesture.
|
|
45
|
+
*/
|
|
46
|
+
dismissOnBackdrop?: boolean;
|
|
31
47
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Button — themed button primitive for shards and apps.
|
|
4
|
+
*
|
|
5
|
+
* Icons resolve via a three-tier lookup (see primitives/icon-context.ts).
|
|
6
|
+
* `icon` accepts either a sprite symbol id ("save") or a direct URL
|
|
7
|
+
* ending in .svg / containing a slash ("./foo.svg"); the URL form
|
|
8
|
+
* bypasses the active sprite entirely.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Snippet } from 'svelte';
|
|
12
|
+
import coreSpriteUrl from '../assets/icons.svg';
|
|
13
|
+
import { getIconSprite, type ButtonVariant } from './icon-context';
|
|
14
|
+
|
|
15
|
+
let {
|
|
16
|
+
variant = 'default',
|
|
17
|
+
icon,
|
|
18
|
+
sprite,
|
|
19
|
+
disabled = false,
|
|
20
|
+
type = 'button',
|
|
21
|
+
title,
|
|
22
|
+
ariaLabel,
|
|
23
|
+
onclick,
|
|
24
|
+
children,
|
|
25
|
+
}: {
|
|
26
|
+
variant?: ButtonVariant;
|
|
27
|
+
/** Sprite symbol id, or a direct .svg URL. */
|
|
28
|
+
icon?: string;
|
|
29
|
+
/** Override the sprite sheet URL for this button only. */
|
|
30
|
+
sprite?: string;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
type?: 'button' | 'submit' | 'reset';
|
|
33
|
+
title?: string;
|
|
34
|
+
ariaLabel?: string;
|
|
35
|
+
onclick?: (event: MouseEvent) => void;
|
|
36
|
+
children?: Snippet;
|
|
37
|
+
} = $props();
|
|
38
|
+
|
|
39
|
+
const contextSprite = getIconSprite();
|
|
40
|
+
|
|
41
|
+
function isDirectUrl(value: string): boolean {
|
|
42
|
+
return value.includes('/') || value.endsWith('.svg');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const iconHref = $derived.by(() => {
|
|
46
|
+
if (!icon) return undefined;
|
|
47
|
+
if (isDirectUrl(icon)) return icon;
|
|
48
|
+
const base = sprite ?? contextSprite ?? coreSpriteUrl;
|
|
49
|
+
return `${base}#${icon}`;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const iconOnly = $derived(variant === 'icon' || (!!icon && !children));
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<button
|
|
56
|
+
{type}
|
|
57
|
+
class="sh3-btn sh3-btn--{variant}"
|
|
58
|
+
class:sh3-btn--icon-only={iconOnly}
|
|
59
|
+
{disabled}
|
|
60
|
+
{title}
|
|
61
|
+
aria-label={ariaLabel ?? (iconOnly ? title : undefined)}
|
|
62
|
+
{onclick}
|
|
63
|
+
>
|
|
64
|
+
{#if iconHref}
|
|
65
|
+
<svg class="sh3-btn__icon" aria-hidden="true">
|
|
66
|
+
<use href={iconHref} />
|
|
67
|
+
</svg>
|
|
68
|
+
{/if}
|
|
69
|
+
{#if children}
|
|
70
|
+
<span class="sh3-btn__label">{@render children()}</span>
|
|
71
|
+
{/if}
|
|
72
|
+
</button>
|
|
73
|
+
|
|
74
|
+
<style>
|
|
75
|
+
.sh3-btn {
|
|
76
|
+
appearance: none;
|
|
77
|
+
display: inline-flex;
|
|
78
|
+
align-items: center;
|
|
79
|
+
justify-content: center;
|
|
80
|
+
gap: var(--shell-pad-sm);
|
|
81
|
+
padding: 6px 14px;
|
|
82
|
+
background: var(--shell-accent);
|
|
83
|
+
color: var(--shell-fg-on-accent);
|
|
84
|
+
border: none;
|
|
85
|
+
border-radius: var(--shell-radius);
|
|
86
|
+
cursor: pointer;
|
|
87
|
+
font-family: inherit;
|
|
88
|
+
font-size: 0.875rem;
|
|
89
|
+
line-height: var(--shell-line);
|
|
90
|
+
}
|
|
91
|
+
.sh3-btn:hover:not(:disabled) { filter: brightness(1.12); }
|
|
92
|
+
.sh3-btn:active:not(:disabled) { filter: brightness(0.92); }
|
|
93
|
+
.sh3-btn:focus-visible {
|
|
94
|
+
box-shadow: var(--shell-focus-ring);
|
|
95
|
+
outline: none;
|
|
96
|
+
}
|
|
97
|
+
.sh3-btn:disabled {
|
|
98
|
+
opacity: 0.55;
|
|
99
|
+
cursor: not-allowed;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.sh3-btn--alert {
|
|
103
|
+
background: var(--shell-error);
|
|
104
|
+
color: var(--shell-fg-on-error);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.sh3-btn--ghost {
|
|
108
|
+
background: transparent;
|
|
109
|
+
color: var(--shell-fg);
|
|
110
|
+
border: 1px solid var(--shell-border);
|
|
111
|
+
}
|
|
112
|
+
.sh3-btn--ghost:hover:not(:disabled) {
|
|
113
|
+
background: var(--shell-bg-elevated);
|
|
114
|
+
filter: none;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.sh3-btn--icon {
|
|
118
|
+
background: transparent;
|
|
119
|
+
color: var(--shell-fg-muted);
|
|
120
|
+
padding: var(--shell-pad-sm);
|
|
121
|
+
}
|
|
122
|
+
.sh3-btn--icon:hover:not(:disabled) {
|
|
123
|
+
background: var(--shell-bg-elevated);
|
|
124
|
+
color: var(--shell-fg);
|
|
125
|
+
filter: none;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.sh3-btn--icon-only {
|
|
129
|
+
padding: var(--shell-pad-sm);
|
|
130
|
+
width: 26px;
|
|
131
|
+
height: 26px;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.sh3-btn__icon {
|
|
135
|
+
width: 16px;
|
|
136
|
+
height: 16px;
|
|
137
|
+
flex-shrink: 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.sh3-btn__label {
|
|
141
|
+
display: inline-flex;
|
|
142
|
+
white-space: nowrap;
|
|
143
|
+
}
|
|
144
|
+
</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type ButtonVariant } from './icon-context';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
variant?: ButtonVariant;
|
|
5
|
+
/** Sprite symbol id, or a direct .svg URL. */
|
|
6
|
+
icon?: string;
|
|
7
|
+
/** Override the sprite sheet URL for this button only. */
|
|
8
|
+
sprite?: string;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
type?: 'button' | 'submit' | 'reset';
|
|
11
|
+
title?: string;
|
|
12
|
+
ariaLabel?: string;
|
|
13
|
+
onclick?: (event: MouseEvent) => void;
|
|
14
|
+
children?: Snippet;
|
|
15
|
+
};
|
|
16
|
+
declare const Button: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
17
|
+
type Button = ReturnType<typeof Button>;
|
|
18
|
+
export default Button;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visual variants for <Button>. Exported from this module (rather than
|
|
3
|
+
* from Button.svelte itself) so TypeDoc, which only sees `.svelte`
|
|
4
|
+
* imports through the ambient `*.svelte` asset declaration, can
|
|
5
|
+
* discover it. Button.svelte re-imports this type.
|
|
6
|
+
*/
|
|
7
|
+
export type ButtonVariant = 'default' | 'alert' | 'ghost' | 'icon';
|
|
8
|
+
/**
|
|
9
|
+
* Register an icon sprite URL for this subtree. Call from a view's
|
|
10
|
+
* <script> block to have every descendant <Button icon="..."/> resolve
|
|
11
|
+
* the icon name against this sprite.
|
|
12
|
+
*/
|
|
13
|
+
export declare function provideIcons(spriteUrl: string): void;
|
|
14
|
+
/** Read the currently-active sprite URL, or undefined if none set. */
|
|
15
|
+
export declare function getIconSprite(): string | undefined;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Icon sprite context — lets a view opt all descendant <Button> (and any
|
|
3
|
+
* future icon-consuming primitive) into using a different sprite sheet
|
|
4
|
+
* than sh3-core's default assets/icons.svg.
|
|
5
|
+
*
|
|
6
|
+
* Resolution order inside Button is:
|
|
7
|
+
* 1. `sprite` prop on the Button (explicit one-off override)
|
|
8
|
+
* 2. nearest `provideIcons(url)` call up the Svelte tree (this module)
|
|
9
|
+
* 3. sh3-core's bundled sprite (fallback)
|
|
10
|
+
*
|
|
11
|
+
* This is tree-scoped (Svelte context), so it does not cross custom
|
|
12
|
+
* element / iframe boundaries. Shards that render inside such boundaries
|
|
13
|
+
* should pass `sprite` explicitly or call provideIcons() from their own
|
|
14
|
+
* root component.
|
|
15
|
+
*/
|
|
16
|
+
import { getContext, setContext } from 'svelte';
|
|
17
|
+
const ICON_SPRITE_KEY = Symbol('sh3.icon-sprite');
|
|
18
|
+
/**
|
|
19
|
+
* Register an icon sprite URL for this subtree. Call from a view's
|
|
20
|
+
* <script> block to have every descendant <Button icon="..."/> resolve
|
|
21
|
+
* the icon name against this sprite.
|
|
22
|
+
*/
|
|
23
|
+
export function provideIcons(spriteUrl) {
|
|
24
|
+
setContext(ICON_SPRITE_KEY, spriteUrl);
|
|
25
|
+
}
|
|
26
|
+
/** Read the currently-active sprite URL, or undefined if none set. */
|
|
27
|
+
export function getIconSprite() {
|
|
28
|
+
return getContext(ICON_SPRITE_KEY);
|
|
29
|
+
}
|
|
@@ -25,6 +25,9 @@ import { mount, unmount } from 'svelte';
|
|
|
25
25
|
import ShellHome from './ShellHome.svelte';
|
|
26
26
|
import KeysAndPeers from '../shell/views/KeysAndPeers.svelte';
|
|
27
27
|
import { VERSION } from '../version';
|
|
28
|
+
import { __setBindingsZone } from '../actions/bindings-store';
|
|
29
|
+
import { registeredApps } from '../apps/registry.svelte';
|
|
30
|
+
import { launchApp } from '../apps/lifecycle';
|
|
28
31
|
export const sh3coreShard = {
|
|
29
32
|
manifest: {
|
|
30
33
|
id: '__sh3core__',
|
|
@@ -36,6 +39,24 @@ export const sh3coreShard = {
|
|
|
36
39
|
],
|
|
37
40
|
},
|
|
38
41
|
activate(ctx) {
|
|
42
|
+
const zones = ctx.state({
|
|
43
|
+
user: {
|
|
44
|
+
bindings: {},
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
__setBindingsZone(zones.user);
|
|
48
|
+
ctx.actions.register({
|
|
49
|
+
id: 'sh3.palette.open',
|
|
50
|
+
label: 'Command Palette…',
|
|
51
|
+
scope: ['home', 'app'],
|
|
52
|
+
defaultShortcut: 'Mod+K',
|
|
53
|
+
contextItem: false,
|
|
54
|
+
paletteItem: false,
|
|
55
|
+
run(_dispatchCtx) {
|
|
56
|
+
// Lazy import to avoid circular module load at boot.
|
|
57
|
+
import('../actions/listeners').then(({ openPalette }) => openPalette());
|
|
58
|
+
},
|
|
59
|
+
});
|
|
39
60
|
const factory = {
|
|
40
61
|
mount(container, _context) {
|
|
41
62
|
const instance = mount(ShellHome, { target: container });
|
|
@@ -55,6 +76,35 @@ export const sh3coreShard = {
|
|
|
55
76
|
};
|
|
56
77
|
ctx.registerView('sh3core:home', factory);
|
|
57
78
|
ctx.registerView('shell:keys-and-peers', keysFactory);
|
|
79
|
+
// Dynamic launcher actions: one "Launch <App>" per registered app, kept
|
|
80
|
+
// in sync as packages install/uninstall via the registry. Re-launching
|
|
81
|
+
// the active app is harmless (lifecycle treats it as a resume).
|
|
82
|
+
const launcherUnregisters = new Map();
|
|
83
|
+
$effect.root(() => {
|
|
84
|
+
$effect(() => {
|
|
85
|
+
const currentIds = new Set();
|
|
86
|
+
for (const [id, app] of registeredApps) {
|
|
87
|
+
currentIds.add(id);
|
|
88
|
+
if (launcherUnregisters.has(id))
|
|
89
|
+
continue;
|
|
90
|
+
const off = ctx.actions.register({
|
|
91
|
+
id: `sh3.app.launch:${id}`,
|
|
92
|
+
label: `Launch ${app.manifest.label}`,
|
|
93
|
+
scope: ['home', 'app'],
|
|
94
|
+
run() {
|
|
95
|
+
void launchApp(id);
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
launcherUnregisters.set(id, off);
|
|
99
|
+
}
|
|
100
|
+
for (const id of [...launcherUnregisters.keys()]) {
|
|
101
|
+
if (!currentIds.has(id)) {
|
|
102
|
+
launcherUnregisters.get(id)();
|
|
103
|
+
launcherUnregisters.delete(id);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
58
108
|
},
|
|
59
109
|
autostart() {
|
|
60
110
|
// Intentionally empty. Defining this field is what puts the sh3core
|
|
@@ -29,6 +29,9 @@ import { createShardKeysApi } from '../keys/client';
|
|
|
29
29
|
import { PERMISSION_KEYS_MINT } from '../keys/types';
|
|
30
30
|
import { subscribe } from '../keys/revocation-bus.svelte';
|
|
31
31
|
import { register as contributionsRegister, list as contributionsList, listPoints as contributionsListPoints, onChange as contributionsOnChange, } from '../contributions';
|
|
32
|
+
import { registerAction } from '../actions/registry';
|
|
33
|
+
import { makeSelectionApi, clearSelectionForShard } from '../actions/selection.svelte';
|
|
34
|
+
import { openContextMenu as shellOpenContextMenu, openPalette as shellOpenPalette } from '../actions/listeners';
|
|
32
35
|
/**
|
|
33
36
|
* Reactive registry of every shard known to the host. Keys are shard ids.
|
|
34
37
|
* Populated once at boot by the glob-discovery loop in main.ts (through
|
|
@@ -171,6 +174,16 @@ export async function activateShard(id) {
|
|
|
171
174
|
})
|
|
172
175
|
: undefined,
|
|
173
176
|
contributions,
|
|
177
|
+
actions: {
|
|
178
|
+
register(action) {
|
|
179
|
+
const dispose = registerAction(action, id);
|
|
180
|
+
entry.cleanupFns.push(async () => dispose());
|
|
181
|
+
return dispose;
|
|
182
|
+
},
|
|
183
|
+
selection: makeSelectionApi(id),
|
|
184
|
+
openContextMenu(opts) { shellOpenContextMenu(opts); },
|
|
185
|
+
openPalette(opts) { shellOpenPalette(opts); },
|
|
186
|
+
},
|
|
174
187
|
};
|
|
175
188
|
entry.ctx = ctx;
|
|
176
189
|
// Wire onKeyRevoked hook: subscribe to the revocation bus for this shard.
|
|
@@ -228,6 +241,7 @@ export function deactivateShard(id) {
|
|
|
228
241
|
fwUnregisterVerb(name);
|
|
229
242
|
for (const viewId of entry.viewIds)
|
|
230
243
|
unregisterView(viewId);
|
|
244
|
+
clearSelectionForShard(id);
|
|
231
245
|
active.delete(id);
|
|
232
246
|
activeShards.delete(id);
|
|
233
247
|
}
|
package/dist/shards/types.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ import type { EnvState } from '../env/types';
|
|
|
6
6
|
import type { Verb } from '../verbs/types';
|
|
7
7
|
import type { ShardContextKeys } from '../keys/types';
|
|
8
8
|
import type { ContributionsApi } from '../contributions/types';
|
|
9
|
+
import type { ActionsApi } from '../actions/types';
|
|
10
|
+
import type { TreeRootRef } from '../layout/types';
|
|
9
11
|
export { PERMISSION_KEYS_MINT, type ShardContextKeys, type ApiKeyPublic, type MintOpts, ScopeEscalationError, ConsentDeniedError } from '../keys/types';
|
|
10
12
|
/**
|
|
11
13
|
* The object returned by `ViewFactory.mount`. The framework calls
|
|
@@ -59,6 +61,18 @@ export interface MountContext {
|
|
|
59
61
|
* Call this whenever the view's save-state changes.
|
|
60
62
|
*/
|
|
61
63
|
setDirty(dirty: boolean): void;
|
|
64
|
+
/**
|
|
65
|
+
* Snapshot of this view's current host location. Returns
|
|
66
|
+
* `{ kind: 'docked' }` when the containing slot is anywhere in the
|
|
67
|
+
* active docked tree, `{ kind: 'float', floatId }` when it lives
|
|
68
|
+
* inside a float's subtree, or `null` when the slot is no longer
|
|
69
|
+
* present in the active layout.
|
|
70
|
+
*
|
|
71
|
+
* Not reactive — each call walks the current layout. Call it when
|
|
72
|
+
* you need to decide something (e.g. toggle a pop-out button,
|
|
73
|
+
* branch in a context-menu handler).
|
|
74
|
+
*/
|
|
75
|
+
location(): TreeRootRef | null;
|
|
62
76
|
}
|
|
63
77
|
/**
|
|
64
78
|
* The shard-side adapter that knows how to bring a view to life inside a
|
|
@@ -232,6 +246,11 @@ export interface ShardContext {
|
|
|
232
246
|
* docs/sh3-rfcs/2026-04-20-shard-contribution-points.md.
|
|
233
247
|
*/
|
|
234
248
|
contributions: ContributionsApi;
|
|
249
|
+
/**
|
|
250
|
+
* Register UI actions (keyboard shortcuts, context-menu items, palette
|
|
251
|
+
* entries). Actions are auto-unregistered when the shard deactivates.
|
|
252
|
+
*/
|
|
253
|
+
actions: ActionsApi;
|
|
235
254
|
}
|
|
236
255
|
/**
|
|
237
256
|
* A shard module. Shards are the fundamental unit of contribution in SH3.
|
package/dist/shards/types.js
CHANGED
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
* - A ViewFactory knows how to mount a view into a raw HTMLElement and
|
|
13
13
|
* return a handle the framework uses to unmount / notify of resizes.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* Action contributions (commands, hotkeys, context menus) shipped in v0.11.0
|
|
16
|
+
* via `ctx.actions` (see `../actions/types.ts`). Still deferred to later
|
|
17
|
+
* phases: bus scoping, toolbar registration, modal provider contributions,
|
|
18
|
+
* background services, lazy activation events. They'll slot into `ShardContext`
|
|
19
|
+
* as new `register*` methods without disturbing the phase-4 shape.
|
|
19
20
|
*/
|
|
20
21
|
export { PERMISSION_KEYS_MINT, ScopeEscalationError, ConsentDeniedError } from '../keys/types';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { resetFramework } from '../__test__/reset';
|
|
3
|
+
import { makeApp, makeAppManifest, makeTabEntry, makeTabsNode, makeTree } from '../__test__/fixtures';
|
|
4
|
+
import { registerApp } from '../apps/registry.svelte';
|
|
5
|
+
import { launchApp } from '../apps/lifecycle';
|
|
6
|
+
import { floatManager, bindFloatStore } from '../overlays/float';
|
|
7
|
+
import { layoutStore } from '../layout/store.svelte';
|
|
8
|
+
import { makeShellApiForTest } from './shellShard.svelte';
|
|
9
|
+
describe('ShellApi.locateSlot', () => {
|
|
10
|
+
beforeEach(resetFramework);
|
|
11
|
+
it('returns docked for a slot in the docked tree', async () => {
|
|
12
|
+
registerApp(makeApp({
|
|
13
|
+
manifest: makeAppManifest({ id: 'test-app-docked' }),
|
|
14
|
+
initialLayout: [
|
|
15
|
+
{
|
|
16
|
+
name: 'default',
|
|
17
|
+
tree: makeTree(makeTabsNode([makeTabEntry({ slotId: 'dock-x' })])),
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
}));
|
|
21
|
+
await launchApp('test-app-docked');
|
|
22
|
+
const shell = makeShellApiForTest();
|
|
23
|
+
expect(shell.locateSlot('dock-x')).toEqual({ kind: 'docked' });
|
|
24
|
+
});
|
|
25
|
+
it('returns float for a slot in a float', async () => {
|
|
26
|
+
var _a;
|
|
27
|
+
registerApp(makeApp({
|
|
28
|
+
manifest: makeAppManifest({ id: 'test-app-float' }),
|
|
29
|
+
initialLayout: [
|
|
30
|
+
{
|
|
31
|
+
name: 'default',
|
|
32
|
+
tree: makeTree(makeTabsNode([makeTabEntry({ slotId: 'stay-docked' })])),
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
}));
|
|
36
|
+
await launchApp('test-app-float');
|
|
37
|
+
// Shell.svelte normally binds the float manager to the active tree's
|
|
38
|
+
// floats array during boot. In tests we bind it manually so
|
|
39
|
+
// floatManager.open() writes into the tree that locateSlot walks.
|
|
40
|
+
bindFloatStore(layoutStore.floats, () => ({ w: 1024, h: 768 }));
|
|
41
|
+
const floatId = floatManager.open('v', { title: 'Float X' });
|
|
42
|
+
const openedFloat = layoutStore.floats.find((f) => f.id === floatId);
|
|
43
|
+
expect(openedFloat).toBeDefined();
|
|
44
|
+
const tabs = openedFloat.content.type === 'tabs' ? openedFloat.content : null;
|
|
45
|
+
const floatSlotId = (_a = tabs === null || tabs === void 0 ? void 0 : tabs.tabs[0]) === null || _a === void 0 ? void 0 : _a.slotId;
|
|
46
|
+
expect(floatSlotId).toBeTruthy();
|
|
47
|
+
const shell = makeShellApiForTest();
|
|
48
|
+
expect(shell.locateSlot(floatSlotId)).toEqual({ kind: 'float', floatId });
|
|
49
|
+
});
|
|
50
|
+
it('returns null for an absent slot', async () => {
|
|
51
|
+
registerApp(makeApp({
|
|
52
|
+
manifest: makeAppManifest({ id: 'test-app-absent' }),
|
|
53
|
+
initialLayout: [
|
|
54
|
+
{
|
|
55
|
+
name: 'default',
|
|
56
|
+
tree: makeTree(makeTabsNode([makeTabEntry({ slotId: 'existing' })])),
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
}));
|
|
60
|
+
await launchApp('test-app-absent');
|
|
61
|
+
const shell = makeShellApiForTest();
|
|
62
|
+
expect(shell.locateSlot('nonexistent-slot')).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
import { popoutView, inspectActiveLayout } from '../layout/inspection';
|
|
66
|
+
describe('ShellApi.locateSlot — round-trip', () => {
|
|
67
|
+
beforeEach(resetFramework);
|
|
68
|
+
it('tracks the docked → popout → float transition', async () => {
|
|
69
|
+
registerApp(makeApp({
|
|
70
|
+
manifest: makeAppManifest({ id: 'test-app-rt' }),
|
|
71
|
+
initialLayout: [
|
|
72
|
+
{
|
|
73
|
+
name: 'default',
|
|
74
|
+
tree: makeTree(makeTabsNode([
|
|
75
|
+
makeTabEntry({ slotId: 'rt-1', viewId: 'rt:one' }),
|
|
76
|
+
makeTabEntry({ slotId: 'rt-anchor', viewId: 'rt:anchor' }),
|
|
77
|
+
])),
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
}));
|
|
81
|
+
await launchApp('test-app-rt');
|
|
82
|
+
// Shell.svelte would bind this at boot; in tests we do it ourselves.
|
|
83
|
+
bindFloatStore(layoutStore.floats, () => ({ w: 1024, h: 768 }));
|
|
84
|
+
const shell = makeShellApiForTest();
|
|
85
|
+
// Starts docked.
|
|
86
|
+
expect(shell.locateSlot('rt-1')).toEqual({ kind: 'docked' });
|
|
87
|
+
// Popout removes the original docked tab and opens a float with a
|
|
88
|
+
// fresh slotId wrapped around the same view.
|
|
89
|
+
const floatId = popoutView('rt-1');
|
|
90
|
+
expect(floatId).not.toBeNull();
|
|
91
|
+
expect(shell.locateSlot('rt-1')).toBeNull();
|
|
92
|
+
// The float-era slotId resolves to the new float.
|
|
93
|
+
const { root } = inspectActiveLayout();
|
|
94
|
+
const fl = root.floats.find((f) => f.id === floatId);
|
|
95
|
+
const floatSlot = fl.content.type === 'tabs' ? fl.content.tabs[0].slotId : '';
|
|
96
|
+
expect(floatSlot).toBeTruthy();
|
|
97
|
+
expect(shell.locateSlot(floatSlot)).toEqual({ kind: 'float', floatId });
|
|
98
|
+
// Anchor tab is untouched and still docked.
|
|
99
|
+
expect(shell.locateSlot('rt-anchor')).toEqual({ kind: 'docked' });
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -1,2 +1,9 @@
|
|
|
1
1
|
import type { Shard } from '../api';
|
|
2
|
+
import type { ShellApi } from './registry';
|
|
3
|
+
/**
|
|
4
|
+
* Test-only ShellApi constructor. Bypasses the admin gate and uses a
|
|
5
|
+
* stub ShardContext. Only methods that do not consult `ctx` are
|
|
6
|
+
* guaranteed to work — `locateSlot`, `listFloats`, `listApps`, etc.
|
|
7
|
+
*/
|
|
8
|
+
export declare function makeShellApiForTest(): ShellApi;
|
|
2
9
|
export declare const shellShard: Shard;
|
|
@@ -19,7 +19,7 @@ import { registerV1Verbs } from './verbs';
|
|
|
19
19
|
import { listRegisteredApps, getActiveApp } from '../apps/registry.svelte';
|
|
20
20
|
import { launchApp } from '../apps/lifecycle';
|
|
21
21
|
import { registeredShards } from '../shards/activate.svelte';
|
|
22
|
-
import { inspectActiveLayout, focusView, closeTab, popoutView, dockFloat, dockIntoActiveLayout } from '../layout/inspection';
|
|
22
|
+
import { inspectActiveLayout, focusView, closeTab, popoutView, dockFloat, dockIntoActiveLayout, locateSlot as locateSlotInActiveLayout } from '../layout/inspection';
|
|
23
23
|
import { floatManager } from '../overlays/float';
|
|
24
24
|
import { listStandaloneViews } from '../shards/activate.svelte';
|
|
25
25
|
import { getUser, isAdmin } from '../auth/index';
|
|
@@ -121,6 +121,15 @@ function makeShellApi(_ctx) {
|
|
|
121
121
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
122
122
|
}
|
|
123
123
|
},
|
|
124
|
+
// → layout/inspection: locateSlot(slotId) returns TreeRootRef | null
|
|
125
|
+
locateSlot(slotId) {
|
|
126
|
+
try {
|
|
127
|
+
return locateSlotInActiveLayout(slotId);
|
|
128
|
+
}
|
|
129
|
+
catch (_a) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
},
|
|
124
133
|
// → overlays/float: floatManager.list() returns FloatEntry[]
|
|
125
134
|
listFloats() {
|
|
126
135
|
return floatManager.list().map((f) => {
|
|
@@ -156,6 +165,14 @@ function makeShellApi(_ctx) {
|
|
|
156
165
|
},
|
|
157
166
|
};
|
|
158
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Test-only ShellApi constructor. Bypasses the admin gate and uses a
|
|
170
|
+
* stub ShardContext. Only methods that do not consult `ctx` are
|
|
171
|
+
* guaranteed to work — `locateSlot`, `listFloats`, `listApps`, etc.
|
|
172
|
+
*/
|
|
173
|
+
export function makeShellApiForTest() {
|
|
174
|
+
return makeShellApi({});
|
|
175
|
+
}
|
|
159
176
|
export const shellShard = {
|
|
160
177
|
manifest,
|
|
161
178
|
activate(ctx) {
|
|
@@ -165,6 +182,22 @@ export const shellShard = {
|
|
|
165
182
|
}
|
|
166
183
|
registerV1Verbs(ctx);
|
|
167
184
|
const shell = makeShellApi(ctx);
|
|
185
|
+
// The AZERTY `²` key (top-left on FR keyboards, below Escape) opens the
|
|
186
|
+
// terminal view — focusing it if already mounted, floating it otherwise.
|
|
187
|
+
// Migrated from Shell.svelte's inline keydown handler as proof-of-concept
|
|
188
|
+
// for the Actions framework (Task 23 / DF1). Registered here because this
|
|
189
|
+
// shard owns the terminal view and is already admin-gated.
|
|
190
|
+
ctx.actions.register({
|
|
191
|
+
id: 'shell.terminal.toggle',
|
|
192
|
+
label: 'Open Terminal',
|
|
193
|
+
scope: ['home', 'app'],
|
|
194
|
+
defaultShortcut: '²',
|
|
195
|
+
allowInInputs: false,
|
|
196
|
+
run() {
|
|
197
|
+
if (!focusView('shell:terminal'))
|
|
198
|
+
floatManager.open('shell:terminal', { title: 'Shell' });
|
|
199
|
+
},
|
|
200
|
+
});
|
|
168
201
|
const factory = {
|
|
169
202
|
mount(container, _context) {
|
|
170
203
|
var _a;
|