sh3-core 0.13.2 → 0.13.4
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/actions/MenuButton.svelte +2 -1
- package/dist/actions/contextMenuModel.d.ts +1 -1
- package/dist/actions/contextMenuModel.js +2 -1
- package/dist/actions/dispatcher.svelte.d.ts +1 -1
- package/dist/actions/dispatcher.svelte.js +2 -1
- package/dist/actions/listActive.d.ts +1 -1
- package/dist/actions/listActive.js +2 -1
- package/dist/actions/listeners.d.ts +1 -1
- package/dist/actions/listeners.js +6 -5
- package/dist/actions/menuBarModel.js +3 -2
- package/dist/actions/paletteModel.js +2 -1
- package/dist/actions/resolveLabel.test.d.ts +1 -0
- package/dist/actions/resolveLabel.test.js +14 -0
- package/dist/actions/types.d.ts +12 -1
- package/dist/actions/types.js +7 -1
- package/dist/app/store/AppUpdateAvailableModal.svelte +87 -0
- package/dist/app/store/AppUpdateAvailableModal.svelte.d.ts +11 -0
- package/dist/app/store/StoreView.svelte +15 -4
- package/dist/app/store/UninstallAppDialog.svelte +86 -0
- package/dist/app/store/UninstallAppDialog.svelte.d.ts +10 -0
- package/dist/app/store/permissionConfirm.d.ts +4 -0
- package/dist/app/store/permissionConfirm.js +27 -0
- package/dist/app/store/storeApp.js +0 -1
- package/dist/app/store/storeShard.svelte.d.ts +8 -1
- package/dist/app/store/storeShard.svelte.js +51 -27
- package/dist/app/store/storeTypes.d.ts +21 -0
- package/dist/app/store/storeTypes.js +33 -0
- package/dist/app/store/storeTypes.test.d.ts +1 -0
- package/dist/app/store/storeTypes.test.js +41 -0
- package/dist/app/store/updatePackage.test.d.ts +1 -0
- package/dist/app/store/updatePackage.test.js +34 -0
- package/dist/app/store/verbs.d.ts +1 -0
- package/dist/app/store/verbs.js +79 -5
- package/dist/app/store/verbs.test.d.ts +1 -0
- package/dist/app/store/verbs.test.js +59 -0
- package/dist/app-appearance/AppAppearanceModal.svelte +174 -0
- package/dist/app-appearance/AppAppearanceModal.svelte.d.ts +8 -0
- package/dist/app-appearance/appearanceShard.svelte.d.ts +2 -0
- package/dist/app-appearance/appearanceShard.svelte.js +61 -0
- package/dist/app-appearance/appearanceState.svelte.d.ts +15 -0
- package/dist/app-appearance/appearanceState.svelte.js +59 -0
- package/dist/app-appearance/appearanceState.test.d.ts +1 -0
- package/dist/app-appearance/appearanceState.test.js +30 -0
- package/dist/app-appearance/index.d.ts +3 -0
- package/dist/app-appearance/index.js +2 -0
- package/dist/app-appearance/types.d.ts +11 -0
- package/dist/app-appearance/types.js +1 -0
- package/dist/apps/types.d.ts +7 -0
- package/dist/assets/iconIds.generated.d.ts +2 -0
- package/dist/assets/iconIds.generated.js +154 -0
- package/dist/host.js +2 -1
- package/dist/overlays/FloatFrame.svelte +18 -1
- package/dist/overlays/float.d.ts +12 -0
- package/dist/overlays/float.js +16 -0
- package/dist/overlays/float.test.js +97 -2
- package/dist/overlays/modal.js +1 -0
- package/dist/overlays/modal.test.js +17 -0
- package/dist/overlays/parentHost.d.ts +1 -0
- package/dist/overlays/parentHost.js +15 -0
- package/dist/overlays/parentHost.test.d.ts +1 -0
- package/dist/overlays/parentHost.test.js +39 -0
- package/dist/overlays/popup.js +1 -0
- package/dist/overlays/popup.test.js +19 -0
- package/dist/primitives/widgets/IconPicker.svelte +115 -0
- package/dist/primitives/widgets/IconPicker.svelte.d.ts +9 -0
- package/dist/primitives/widgets/IconPicker.svelte.test.d.ts +1 -0
- package/dist/primitives/widgets/IconPicker.svelte.test.js +43 -0
- package/dist/projects-shard/ProjectManage.svelte +14 -4
- package/dist/sh3core-shard/ShellHome.svelte +64 -38
- package/dist/sh3core-shard/appActions.d.ts +13 -0
- package/dist/sh3core-shard/appActions.js +181 -0
- package/dist/sh3core-shard/appActions.test.d.ts +1 -0
- package/dist/sh3core-shard/appActions.test.js +25 -0
- package/dist/sh3core-shard/sh3coreShard.svelte.js +2 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/dist/app/store/InstalledView.svelte +0 -301
- package/dist/app/store/InstalledView.svelte.d.ts +0 -3
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { findEnclosingOverlayHost } from './parentHost';
|
|
3
|
+
afterEach(() => {
|
|
4
|
+
document.body.innerHTML = '';
|
|
5
|
+
});
|
|
6
|
+
describe('findEnclosingOverlayHost', () => {
|
|
7
|
+
it('returns the nearest ancestor with data-shell-overlay-host', () => {
|
|
8
|
+
const host = document.createElement('div');
|
|
9
|
+
host.dataset.shellOverlayHost = 'modal';
|
|
10
|
+
const inner = document.createElement('div');
|
|
11
|
+
const anchor = document.createElement('button');
|
|
12
|
+
inner.appendChild(anchor);
|
|
13
|
+
host.appendChild(inner);
|
|
14
|
+
document.body.appendChild(host);
|
|
15
|
+
expect(findEnclosingOverlayHost(anchor)).toBe(host);
|
|
16
|
+
});
|
|
17
|
+
it('returns the anchor itself when it carries the marker', () => {
|
|
18
|
+
const anchor = document.createElement('div');
|
|
19
|
+
anchor.dataset.shellOverlayHost = 'float';
|
|
20
|
+
document.body.appendChild(anchor);
|
|
21
|
+
expect(findEnclosingOverlayHost(anchor)).toBe(anchor);
|
|
22
|
+
});
|
|
23
|
+
it('returns null when no ancestor carries the marker', () => {
|
|
24
|
+
const anchor = document.createElement('button');
|
|
25
|
+
document.body.appendChild(anchor);
|
|
26
|
+
expect(findEnclosingOverlayHost(anchor)).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
it('returns the innermost host when overlay hosts are nested', () => {
|
|
29
|
+
const outer = document.createElement('div');
|
|
30
|
+
outer.dataset.shellOverlayHost = 'modal';
|
|
31
|
+
const inner = document.createElement('div');
|
|
32
|
+
inner.dataset.shellOverlayHost = 'float';
|
|
33
|
+
const anchor = document.createElement('button');
|
|
34
|
+
inner.appendChild(anchor);
|
|
35
|
+
outer.appendChild(inner);
|
|
36
|
+
document.body.appendChild(outer);
|
|
37
|
+
expect(findEnclosingOverlayHost(anchor)).toBe(inner);
|
|
38
|
+
});
|
|
39
|
+
});
|
package/dist/overlays/popup.js
CHANGED
|
@@ -83,6 +83,7 @@ function showPopup(Content, options, props) {
|
|
|
83
83
|
const root = getLayerRoot('popup');
|
|
84
84
|
const host = document.createElement('div');
|
|
85
85
|
host.className = 'sh3-popup-host';
|
|
86
|
+
host.dataset.shellOverlayHost = 'popup';
|
|
86
87
|
host.style.position = 'absolute';
|
|
87
88
|
host.style.inset = '0';
|
|
88
89
|
host.style.pointerEvents = 'none'; // only the frame captures pointer events
|
|
@@ -126,3 +126,22 @@ describe('popup — back-cascade integration', () => {
|
|
|
126
126
|
expect(layerRoot.querySelector('.sh3-popup-host')).toBeNull();
|
|
127
127
|
});
|
|
128
128
|
});
|
|
129
|
+
describe('popup — overlay host marker', () => {
|
|
130
|
+
let layerRoot;
|
|
131
|
+
beforeEach(() => {
|
|
132
|
+
vi.stubGlobal('innerWidth', 2000);
|
|
133
|
+
vi.stubGlobal('innerHeight', 2000);
|
|
134
|
+
layerRoot = makeLayerRoot();
|
|
135
|
+
});
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
__resetPopupManagerForTest();
|
|
138
|
+
teardownLayerRoot(layerRoot);
|
|
139
|
+
vi.unstubAllGlobals();
|
|
140
|
+
});
|
|
141
|
+
it('marks the popup host with data-shell-overlay-host="popup"', () => {
|
|
142
|
+
popupManager.show(DummyFrame, { anchor: { x: 100, y: 100 } }, {});
|
|
143
|
+
const host = layerRoot.querySelector('.sh3-popup-host');
|
|
144
|
+
expect(host).not.toBeNull();
|
|
145
|
+
expect(host.dataset.shellOverlayHost).toBe('popup');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* IconPicker — searchable grid of icon ids from the bundled lucide
|
|
4
|
+
* sprite. The "(none)" tile clears the value. Composes the widget
|
|
5
|
+
* commit-only event contract (CommitOnlyEvents<string | undefined>).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CommitOnlyEvents } from './_contract';
|
|
9
|
+
import { ICON_IDS } from '../../assets/iconIds.generated';
|
|
10
|
+
import iconsUrl from '../../assets/icons.svg';
|
|
11
|
+
|
|
12
|
+
type Props = {
|
|
13
|
+
value?: string | undefined;
|
|
14
|
+
label?: string;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
} & CommitOnlyEvents<string | undefined>;
|
|
17
|
+
|
|
18
|
+
let {
|
|
19
|
+
value = $bindable(undefined),
|
|
20
|
+
label,
|
|
21
|
+
disabled = false,
|
|
22
|
+
onchange,
|
|
23
|
+
}: Props = $props();
|
|
24
|
+
|
|
25
|
+
let query = $state('');
|
|
26
|
+
|
|
27
|
+
const filtered = $derived(
|
|
28
|
+
query.trim() === ''
|
|
29
|
+
? ICON_IDS
|
|
30
|
+
: ICON_IDS.filter((id) => id.includes(query.trim().toLowerCase())),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
function pick(id: string | undefined): void {
|
|
34
|
+
if (disabled) return;
|
|
35
|
+
value = id;
|
|
36
|
+
onchange?.(id);
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<div class="sh3-icon-picker" class:sh3-icon-picker--disabled={disabled}>
|
|
41
|
+
{#if label}<span class="sh3-icon-picker__label">{label}</span>{/if}
|
|
42
|
+
<input
|
|
43
|
+
type="search"
|
|
44
|
+
placeholder="Search icons…"
|
|
45
|
+
bind:value={query}
|
|
46
|
+
{disabled}
|
|
47
|
+
aria-label="Filter icons"
|
|
48
|
+
/>
|
|
49
|
+
<div class="sh3-icon-picker__grid">
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
class="sh3-icon-picker__tile sh3-icon-picker__tile--none"
|
|
53
|
+
class:sh3-icon-picker__tile--selected={value === undefined}
|
|
54
|
+
aria-label="No icon"
|
|
55
|
+
{disabled}
|
|
56
|
+
onclick={() => pick(undefined)}
|
|
57
|
+
>×</button>
|
|
58
|
+
{#each filtered as id (id)}
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
class="sh3-icon-picker__tile"
|
|
62
|
+
class:sh3-icon-picker__tile--selected={value === id}
|
|
63
|
+
aria-label={id}
|
|
64
|
+
title={id}
|
|
65
|
+
{disabled}
|
|
66
|
+
onclick={() => pick(id)}
|
|
67
|
+
>
|
|
68
|
+
<svg viewBox="0 0 24 24"><use href="{iconsUrl}#{id}" /></svg>
|
|
69
|
+
</button>
|
|
70
|
+
{/each}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<style>
|
|
75
|
+
.sh3-icon-picker { display: flex; flex-direction: column; gap: 6px; }
|
|
76
|
+
.sh3-icon-picker__label { font-size: 13px; color: var(--shell-fg-muted); }
|
|
77
|
+
.sh3-icon-picker input[type="search"] {
|
|
78
|
+
background: var(--shell-bg-elevated);
|
|
79
|
+
color: var(--shell-fg);
|
|
80
|
+
border: 1px solid var(--shell-border);
|
|
81
|
+
border-radius: var(--shell-radius-sm, 3px);
|
|
82
|
+
padding: 6px 8px; font: inherit; font-size: 13px;
|
|
83
|
+
}
|
|
84
|
+
.sh3-icon-picker__grid {
|
|
85
|
+
display: grid;
|
|
86
|
+
grid-template-columns: repeat(auto-fill, minmax(36px, 1fr));
|
|
87
|
+
gap: 4px;
|
|
88
|
+
max-height: 220px;
|
|
89
|
+
overflow-y: auto;
|
|
90
|
+
padding: 4px;
|
|
91
|
+
background: var(--shell-bg);
|
|
92
|
+
border: 1px solid var(--shell-border);
|
|
93
|
+
border-radius: var(--shell-radius-sm, 3px);
|
|
94
|
+
}
|
|
95
|
+
.sh3-icon-picker__tile {
|
|
96
|
+
aspect-ratio: 1 / 1;
|
|
97
|
+
display: flex;
|
|
98
|
+
align-items: center;
|
|
99
|
+
justify-content: center;
|
|
100
|
+
background: var(--shell-bg-elevated);
|
|
101
|
+
color: var(--shell-fg);
|
|
102
|
+
border: 1px solid transparent;
|
|
103
|
+
border-radius: var(--shell-radius-sm, 3px);
|
|
104
|
+
cursor: pointer;
|
|
105
|
+
padding: 0; font: inherit;
|
|
106
|
+
}
|
|
107
|
+
.sh3-icon-picker__tile--none { font-size: 16px; color: var(--shell-fg-muted); }
|
|
108
|
+
.sh3-icon-picker__tile:hover { border-color: var(--shell-accent); }
|
|
109
|
+
.sh3-icon-picker__tile--selected {
|
|
110
|
+
outline: 2px solid var(--shell-accent);
|
|
111
|
+
outline-offset: -2px;
|
|
112
|
+
}
|
|
113
|
+
.sh3-icon-picker__tile svg { width: 18px; height: 18px; }
|
|
114
|
+
.sh3-icon-picker--disabled .sh3-icon-picker__tile { cursor: not-allowed; opacity: 0.5; }
|
|
115
|
+
</style>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CommitOnlyEvents } from './_contract';
|
|
2
|
+
type Props = {
|
|
3
|
+
value?: string | undefined;
|
|
4
|
+
label?: string;
|
|
5
|
+
disabled?: boolean;
|
|
6
|
+
} & CommitOnlyEvents<string | undefined>;
|
|
7
|
+
declare const IconPicker: import("svelte").Component<Props, {}, "value">;
|
|
8
|
+
type IconPicker = ReturnType<typeof IconPicker>;
|
|
9
|
+
export default IconPicker;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render, fireEvent } from '@testing-library/svelte';
|
|
3
|
+
import IconPicker from './IconPicker.svelte';
|
|
4
|
+
describe('IconPicker', () => {
|
|
5
|
+
it('renders the (none) tile and at least one icon tile', () => {
|
|
6
|
+
const { container } = render(IconPicker, { props: {} });
|
|
7
|
+
const tiles = container.querySelectorAll('.sh3-icon-picker__tile');
|
|
8
|
+
expect(tiles.length).toBeGreaterThan(1);
|
|
9
|
+
expect(tiles[0].getAttribute('aria-label')).toBe('No icon');
|
|
10
|
+
});
|
|
11
|
+
it('filters tiles by search input', async () => {
|
|
12
|
+
const { container } = render(IconPicker, { props: {} });
|
|
13
|
+
const search = container.querySelector('input[type="search"]');
|
|
14
|
+
await fireEvent.input(search, { target: { value: 'house' } });
|
|
15
|
+
const visible = container.querySelectorAll('.sh3-icon-picker__tile:not([hidden])');
|
|
16
|
+
expect(visible.length).toBeGreaterThan(0);
|
|
17
|
+
expect(visible.length).toBeLessThan(20);
|
|
18
|
+
});
|
|
19
|
+
it('calls onchange with the clicked icon id', async () => {
|
|
20
|
+
let lastValue = '__sentinel__';
|
|
21
|
+
const { container } = render(IconPicker, {
|
|
22
|
+
props: {
|
|
23
|
+
onchange: (v) => { lastValue = v; },
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
const tiles = Array.from(container.querySelectorAll('.sh3-icon-picker__tile'));
|
|
27
|
+
await fireEvent.click(tiles[1]);
|
|
28
|
+
expect(lastValue).toEqual(expect.any(String));
|
|
29
|
+
expect(lastValue).not.toBe('__sentinel__');
|
|
30
|
+
});
|
|
31
|
+
it('calls onchange with undefined when (none) tile is clicked', async () => {
|
|
32
|
+
let lastValue = 'house';
|
|
33
|
+
const { container } = render(IconPicker, {
|
|
34
|
+
props: {
|
|
35
|
+
value: 'house',
|
|
36
|
+
onchange: (v) => { lastValue = v; },
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const noneTile = container.querySelector('.sh3-icon-picker__tile');
|
|
40
|
+
await fireEvent.click(noneTile);
|
|
41
|
+
expect(lastValue).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* "include myself" works without looking up the underlying user id.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { untrack } from 'svelte';
|
|
11
12
|
import { projectsApi, type ProjectRecord } from './projectsApi';
|
|
12
13
|
import { refreshProjects } from './projectsShard.svelte';
|
|
13
14
|
import { getUser } from '../auth/auth.svelte';
|
|
@@ -26,12 +27,21 @@
|
|
|
26
27
|
const isEdit = $derived(project !== null);
|
|
27
28
|
const me = $derived(getUser());
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
// Form fields snapshot the project on mount; the modal is recreated per
|
|
31
|
+
// open, so prop changes after mount aren't expected. untrack defers the
|
|
32
|
+
// reactive read so Svelte 5 doesn't emit state_referenced_locally.
|
|
33
|
+
let name = $state(untrack(() => project?.name ?? ''));
|
|
34
|
+
let description = $state(untrack(() => project?.description ?? ''));
|
|
31
35
|
let members = $state<string[]>(
|
|
32
|
-
|
|
36
|
+
untrack(() => {
|
|
37
|
+
if (project) return [...project.members];
|
|
38
|
+
const userId = getUser()?.id;
|
|
39
|
+
return userId ? [userId] : [];
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
let appAllowlist = $state<string[]>(
|
|
43
|
+
untrack(() => (project ? [...project.appAllowlist] : [])),
|
|
33
44
|
);
|
|
34
|
-
let appAllowlist = $state<string[]>(project ? [...project.appAllowlist] : []);
|
|
35
45
|
let saving = $state(false);
|
|
36
46
|
let error = $state<string | null>(null);
|
|
37
47
|
|
|
@@ -12,6 +12,22 @@
|
|
|
12
12
|
import ProjectsSection from '../projects-shard/ProjectsSection.svelte';
|
|
13
13
|
import { sessionState } from '../projects/session-state.svelte';
|
|
14
14
|
import { projectsState } from '../projects-shard/projectsShard.svelte';
|
|
15
|
+
import { shell } from '../shellRuntime.svelte';
|
|
16
|
+
import { makeSelectionApi } from '../actions/selection.svelte';
|
|
17
|
+
import { getAppearance } from '../app-appearance';
|
|
18
|
+
import iconsUrl from '../assets/icons.svg';
|
|
19
|
+
|
|
20
|
+
const homeSelection = makeSelectionApi('__sh3core__');
|
|
21
|
+
|
|
22
|
+
function openAppContextMenu(event: MouseEvent, appId: string): void {
|
|
23
|
+
event.preventDefault();
|
|
24
|
+
homeSelection.set({ type: 'app', ref: { appId } });
|
|
25
|
+
shell.actions.openContextMenu({
|
|
26
|
+
x: event.clientX,
|
|
27
|
+
y: event.clientY,
|
|
28
|
+
scope: { element: 'app' },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
15
31
|
|
|
16
32
|
let filter = $state('');
|
|
17
33
|
|
|
@@ -71,17 +87,20 @@
|
|
|
71
87
|
<h2 class="shell-home-section-title">Apps</h2>
|
|
72
88
|
<div class="shell-home-grid">
|
|
73
89
|
{#each userApps as manifest (manifest.id)}
|
|
90
|
+
{@const appearance = getAppearance(manifest.id)}
|
|
74
91
|
<button
|
|
75
92
|
type="button"
|
|
76
93
|
class="shell-home-card"
|
|
94
|
+
class:shell-home-card--tinted={appearance?.color}
|
|
95
|
+
style:--card-color={appearance?.color ?? 'transparent'}
|
|
96
|
+
data-sh3-scope="element:app"
|
|
77
97
|
onclick={() => launchApp(manifest.id)}
|
|
78
|
-
|
|
98
|
+
oncontextmenu={(e) => openAppContextMenu(e, manifest.id)}
|
|
79
99
|
>
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
</div>
|
|
100
|
+
<span class="shell-home-card-square">
|
|
101
|
+
<svg class="shell-home-card-icon"><use href="{iconsUrl}#{appearance?.icon ?? manifest.icon ?? 'box'}" /></svg>
|
|
102
|
+
</span>
|
|
103
|
+
<span class="shell-home-card-label">{appearance?.label ?? manifest.label}</span>
|
|
85
104
|
</button>
|
|
86
105
|
{/each}
|
|
87
106
|
</div>
|
|
@@ -93,17 +112,20 @@
|
|
|
93
112
|
<h2 class="shell-home-section-title">Admin</h2>
|
|
94
113
|
<div class="shell-home-grid">
|
|
95
114
|
{#each adminApps as manifest (manifest.id)}
|
|
115
|
+
{@const appearance = getAppearance(manifest.id)}
|
|
96
116
|
<button
|
|
97
117
|
type="button"
|
|
98
118
|
class="shell-home-card"
|
|
119
|
+
class:shell-home-card--tinted={appearance?.color}
|
|
120
|
+
style:--card-color={appearance?.color ?? 'transparent'}
|
|
121
|
+
data-sh3-scope="element:app"
|
|
99
122
|
onclick={() => launchApp(manifest.id)}
|
|
100
|
-
|
|
123
|
+
oncontextmenu={(e) => openAppContextMenu(e, manifest.id)}
|
|
101
124
|
>
|
|
102
|
-
<
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
</div>
|
|
125
|
+
<span class="shell-home-card-square">
|
|
126
|
+
<svg class="shell-home-card-icon"><use href="{iconsUrl}#{appearance?.icon ?? manifest.icon ?? 'box'}" /></svg>
|
|
127
|
+
</span>
|
|
128
|
+
<span class="shell-home-card-label">{appearance?.label ?? manifest.label}</span>
|
|
107
129
|
</button>
|
|
108
130
|
{/each}
|
|
109
131
|
</div>
|
|
@@ -223,26 +245,34 @@
|
|
|
223
245
|
}
|
|
224
246
|
.shell-home-grid {
|
|
225
247
|
display: grid;
|
|
226
|
-
grid-template-columns: repeat(auto-fill, minmax(
|
|
227
|
-
gap:
|
|
248
|
+
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
|
|
249
|
+
gap: 18px 14px;
|
|
228
250
|
}
|
|
229
251
|
.shell-home-card {
|
|
230
|
-
aspect-ratio: 1 / 1;
|
|
231
252
|
display: flex;
|
|
232
253
|
flex-direction: column;
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
padding:
|
|
236
|
-
background:
|
|
237
|
-
border:
|
|
238
|
-
border-radius: var(--shell-radius-md);
|
|
254
|
+
align-items: center;
|
|
255
|
+
gap: 6px;
|
|
256
|
+
padding: 0;
|
|
257
|
+
background: transparent;
|
|
258
|
+
border: none;
|
|
239
259
|
color: inherit;
|
|
240
260
|
font: inherit;
|
|
241
261
|
cursor: pointer;
|
|
262
|
+
}
|
|
263
|
+
.shell-home-card-square {
|
|
264
|
+
width: 64px;
|
|
265
|
+
height: 64px;
|
|
266
|
+
display: flex;
|
|
267
|
+
align-items: center;
|
|
268
|
+
justify-content: center;
|
|
269
|
+
background: var(--shell-grad-bg-elevated, var(--shell-bg-elevated));
|
|
270
|
+
border: 1px solid var(--shell-border);
|
|
271
|
+
border-radius: var(--shell-radius-md);
|
|
242
272
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25), 0 1px 2px rgba(0, 0, 0, 0.15);
|
|
243
273
|
transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease, background 120ms ease;
|
|
244
274
|
}
|
|
245
|
-
.shell-home-card:hover {
|
|
275
|
+
.shell-home-card:hover .shell-home-card-square {
|
|
246
276
|
border-color: var(--shell-accent);
|
|
247
277
|
transform: translateY(-1px);
|
|
248
278
|
box-shadow:
|
|
@@ -252,36 +282,32 @@
|
|
|
252
282
|
}
|
|
253
283
|
.shell-home-card:focus-visible {
|
|
254
284
|
outline: none;
|
|
285
|
+
}
|
|
286
|
+
.shell-home-card:focus-visible .shell-home-card-square {
|
|
255
287
|
border-color: var(--shell-accent);
|
|
256
288
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--shell-accent) 40%, transparent);
|
|
257
289
|
}
|
|
258
|
-
.shell-home-card:active {
|
|
290
|
+
.shell-home-card:active .shell-home-card-square {
|
|
259
291
|
transform: translateY(0);
|
|
260
292
|
}
|
|
261
293
|
.shell-home-card-label {
|
|
262
294
|
font-weight: 600;
|
|
263
|
-
font-size:
|
|
295
|
+
font-size: 11px;
|
|
264
296
|
line-height: 1.2;
|
|
297
|
+
text-align: center;
|
|
265
298
|
overflow: hidden;
|
|
266
299
|
display: -webkit-box;
|
|
267
300
|
-webkit-box-orient: vertical;
|
|
268
301
|
-webkit-line-clamp: 2;
|
|
269
302
|
line-clamp: 2;
|
|
303
|
+
word-break: break-word;
|
|
270
304
|
}
|
|
271
|
-
.shell-home-card-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
font-size: 9px;
|
|
276
|
-
color: var(--shell-fg-subtle);
|
|
277
|
-
min-width: 0;
|
|
278
|
-
}
|
|
279
|
-
.shell-home-card-id {
|
|
280
|
-
overflow: hidden;
|
|
281
|
-
text-overflow: ellipsis;
|
|
282
|
-
white-space: nowrap;
|
|
305
|
+
.shell-home-card-icon {
|
|
306
|
+
width: 28px;
|
|
307
|
+
height: 28px;
|
|
308
|
+
color: var(--shell-fg);
|
|
283
309
|
}
|
|
284
|
-
.shell-home-card-
|
|
285
|
-
|
|
310
|
+
.shell-home-card--tinted .shell-home-card-square {
|
|
311
|
+
background: var(--card-color);
|
|
286
312
|
}
|
|
287
313
|
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ShardContext } from '../shards/types';
|
|
2
|
+
export interface AppActionGate {
|
|
3
|
+
admin: boolean;
|
|
4
|
+
builtin: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function computeAppActionDisabled(g: AppActionGate): boolean;
|
|
7
|
+
export declare function computeAppActionLabelSuffix(g: AppActionGate): string;
|
|
8
|
+
/**
|
|
9
|
+
* Register the three element-scope app actions on the given shard context.
|
|
10
|
+
* Returns a disposer that unregisters all three (the framework expects the
|
|
11
|
+
* shard's activate to keep the disposers alive across deactivate).
|
|
12
|
+
*/
|
|
13
|
+
export declare function registerAppActions(ctx: ShardContext): () => void;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Element-scope actions for app cards. Wired by sh3coreShard.activate.
|
|
3
|
+
* The element scope is { element: 'app' } and activates when right-click
|
|
4
|
+
* on a host with data-sh3-scope="element:app" sets a selection of type
|
|
5
|
+
* 'app' (see ShellHome.svelte).
|
|
6
|
+
*
|
|
7
|
+
* Three actions are registered here:
|
|
8
|
+
* - app.info : info-only row showing "<id> v<version>" (always disabled).
|
|
9
|
+
* - app.checkUpdate : refresh catalog, then prompt update or toast up-to-date.
|
|
10
|
+
* - app.uninstall : open uninstall confirm dialog.
|
|
11
|
+
*
|
|
12
|
+
* The fourth menu item (app.customize) is registered by the app-appearance
|
|
13
|
+
* shard so its lifecycle is bound to that shard's activate/deactivate.
|
|
14
|
+
*
|
|
15
|
+
* Disabled predicate: !isAdmin || isBuiltin. Built-in means the app id is
|
|
16
|
+
* NOT in storeContext.state.ephemeral.installed (those are framework-shipped
|
|
17
|
+
* shards like sh3-store, sh3-shell, projects, etc.).
|
|
18
|
+
*/
|
|
19
|
+
import { listRegisteredApps } from '../api';
|
|
20
|
+
import { isAdmin } from '../auth/auth.svelte';
|
|
21
|
+
import { getSelection } from '../actions/selection.svelte';
|
|
22
|
+
import { storeContext } from '../app/store/storeShard.svelte';
|
|
23
|
+
import { openPermissionConfirmModal } from '../app/store/permissionConfirm';
|
|
24
|
+
import { modalManager } from '../overlays/modal';
|
|
25
|
+
import { toastManager } from '../overlays/toast';
|
|
26
|
+
import AppUpdateAvailableModal from '../app/store/AppUpdateAvailableModal.svelte';
|
|
27
|
+
import UninstallAppDialog from '../app/store/UninstallAppDialog.svelte';
|
|
28
|
+
export function computeAppActionDisabled(g) {
|
|
29
|
+
return !g.admin || g.builtin;
|
|
30
|
+
}
|
|
31
|
+
export function computeAppActionLabelSuffix(g) {
|
|
32
|
+
if (!g.admin)
|
|
33
|
+
return ' · admin only';
|
|
34
|
+
if (g.builtin)
|
|
35
|
+
return ' · built-in';
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
38
|
+
function readSelection() {
|
|
39
|
+
const sel = getSelection();
|
|
40
|
+
if (!sel || sel.type !== 'app')
|
|
41
|
+
return null;
|
|
42
|
+
return sel.ref;
|
|
43
|
+
}
|
|
44
|
+
function findApp(appId) {
|
|
45
|
+
return listRegisteredApps().find((m) => m.id === appId);
|
|
46
|
+
}
|
|
47
|
+
function isBuiltin(appId) {
|
|
48
|
+
return !storeContext.state.ephemeral.installed.some((p) => p.id === appId);
|
|
49
|
+
}
|
|
50
|
+
function gateFor(appId) {
|
|
51
|
+
return { admin: isAdmin(), builtin: isBuiltin(appId) };
|
|
52
|
+
}
|
|
53
|
+
async function runCheckUpdate(_ctx) {
|
|
54
|
+
var _a, _b;
|
|
55
|
+
const ref = readSelection();
|
|
56
|
+
if (!ref)
|
|
57
|
+
return;
|
|
58
|
+
const { appId } = ref;
|
|
59
|
+
const checking = toastManager.notify('Checking for updates…', { duration: Infinity });
|
|
60
|
+
try {
|
|
61
|
+
await storeContext.refreshCatalog();
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
checking.close();
|
|
65
|
+
}
|
|
66
|
+
const entry = storeContext.state.ephemeral.updatable[appId];
|
|
67
|
+
const installed = storeContext.state.ephemeral.installed.find((p) => p.id === appId);
|
|
68
|
+
const label = (_b = (_a = findApp(appId)) === null || _a === void 0 ? void 0 : _a.label) !== null && _b !== void 0 ? _b : appId;
|
|
69
|
+
if (!entry || !installed) {
|
|
70
|
+
toastManager.notify(`${label} is up to date`, { level: 'info', duration: 2500 });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const props = {
|
|
74
|
+
appId,
|
|
75
|
+
appLabel: label,
|
|
76
|
+
fromVersion: installed.version,
|
|
77
|
+
toVersion: entry.latest.version,
|
|
78
|
+
onConfirm: async () => {
|
|
79
|
+
try {
|
|
80
|
+
await storeContext.updatePackage(appId, (added, removed) => openPermissionConfirmModal({ label: installed.id, version: installed.version }, entry.latest.version, added, removed));
|
|
81
|
+
toastManager.notify(`Updated to v${entry.latest.version}`, { level: 'success' });
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
toastManager.notify(`Update failed: ${e.message}`, { level: 'error', duration: 5000 });
|
|
85
|
+
throw e;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
modalManager.open(AppUpdateAvailableModal, props);
|
|
90
|
+
}
|
|
91
|
+
function runUninstall(_ctx) {
|
|
92
|
+
var _a, _b;
|
|
93
|
+
const ref = readSelection();
|
|
94
|
+
if (!ref)
|
|
95
|
+
return;
|
|
96
|
+
const { appId } = ref;
|
|
97
|
+
const installed = storeContext.state.ephemeral.installed.find((p) => p.id === appId);
|
|
98
|
+
if (!installed)
|
|
99
|
+
return;
|
|
100
|
+
const label = (_b = (_a = findApp(appId)) === null || _a === void 0 ? void 0 : _a.label) !== null && _b !== void 0 ? _b : appId;
|
|
101
|
+
const props = {
|
|
102
|
+
appId,
|
|
103
|
+
appLabel: label,
|
|
104
|
+
version: installed.version,
|
|
105
|
+
onConfirm: async () => {
|
|
106
|
+
try {
|
|
107
|
+
await storeContext.uninstallPackage(appId);
|
|
108
|
+
toastManager.notify(`Uninstalled ${label}`, { level: 'success' });
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
toastManager.notify(`Uninstall failed: ${e.message}`, { level: 'error', duration: 5000 });
|
|
112
|
+
throw e;
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
modalManager.open(UninstallAppDialog, props);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Register the three element-scope app actions on the given shard context.
|
|
120
|
+
* Returns a disposer that unregisters all three (the framework expects the
|
|
121
|
+
* shard's activate to keep the disposers alive across deactivate).
|
|
122
|
+
*/
|
|
123
|
+
export function registerAppActions(ctx) {
|
|
124
|
+
const infoLabel = () => {
|
|
125
|
+
const ref = readSelection();
|
|
126
|
+
if (!ref)
|
|
127
|
+
return '';
|
|
128
|
+
const m = findApp(ref.appId);
|
|
129
|
+
if (!m)
|
|
130
|
+
return ref.appId;
|
|
131
|
+
return `${m.id} v${m.version}`;
|
|
132
|
+
};
|
|
133
|
+
const updateLabel = () => {
|
|
134
|
+
const ref = readSelection();
|
|
135
|
+
if (!ref)
|
|
136
|
+
return 'Check for updates';
|
|
137
|
+
return `Check for updates${computeAppActionLabelSuffix(gateFor(ref.appId))}`;
|
|
138
|
+
};
|
|
139
|
+
const uninstallLabel = () => {
|
|
140
|
+
const ref = readSelection();
|
|
141
|
+
if (!ref)
|
|
142
|
+
return 'Uninstall…';
|
|
143
|
+
return `Uninstall…${computeAppActionLabelSuffix(gateFor(ref.appId))}`;
|
|
144
|
+
};
|
|
145
|
+
const isDisabledForCurrent = () => {
|
|
146
|
+
const ref = readSelection();
|
|
147
|
+
if (!ref)
|
|
148
|
+
return true;
|
|
149
|
+
return computeAppActionDisabled(gateFor(ref.appId));
|
|
150
|
+
};
|
|
151
|
+
const actions = [
|
|
152
|
+
{
|
|
153
|
+
id: 'app.info',
|
|
154
|
+
label: infoLabel,
|
|
155
|
+
scope: { element: 'app' },
|
|
156
|
+
contextItem: true,
|
|
157
|
+
group: 'info',
|
|
158
|
+
disabled: true,
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
id: 'app.checkUpdate',
|
|
162
|
+
label: updateLabel,
|
|
163
|
+
scope: { element: 'app' },
|
|
164
|
+
contextItem: true,
|
|
165
|
+
group: 'update',
|
|
166
|
+
disabled: isDisabledForCurrent,
|
|
167
|
+
run: runCheckUpdate,
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: 'app.uninstall',
|
|
171
|
+
label: uninstallLabel,
|
|
172
|
+
scope: { element: 'app' },
|
|
173
|
+
contextItem: true,
|
|
174
|
+
group: 'uninstall',
|
|
175
|
+
disabled: isDisabledForCurrent,
|
|
176
|
+
run: runUninstall,
|
|
177
|
+
},
|
|
178
|
+
];
|
|
179
|
+
const disposers = actions.map((a) => ctx.actions.register(a));
|
|
180
|
+
return () => disposers.forEach((d) => d());
|
|
181
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { computeAppActionDisabled, computeAppActionLabelSuffix } from './appActions';
|
|
3
|
+
describe('computeAppActionDisabled', () => {
|
|
4
|
+
it('disables when not admin', () => {
|
|
5
|
+
expect(computeAppActionDisabled({ admin: false, builtin: false })).toBe(true);
|
|
6
|
+
});
|
|
7
|
+
it('disables when builtin even for admin', () => {
|
|
8
|
+
expect(computeAppActionDisabled({ admin: true, builtin: true })).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
it('enables for admin + non-builtin', () => {
|
|
11
|
+
expect(computeAppActionDisabled({ admin: true, builtin: false })).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
describe('computeAppActionLabelSuffix', () => {
|
|
15
|
+
it('returns admin-only suffix when not admin', () => {
|
|
16
|
+
expect(computeAppActionLabelSuffix({ admin: false, builtin: true })).toBe(' · admin only');
|
|
17
|
+
expect(computeAppActionLabelSuffix({ admin: false, builtin: false })).toBe(' · admin only');
|
|
18
|
+
});
|
|
19
|
+
it('returns built-in suffix when admin + builtin', () => {
|
|
20
|
+
expect(computeAppActionLabelSuffix({ admin: true, builtin: true })).toBe(' · built-in');
|
|
21
|
+
});
|
|
22
|
+
it('returns empty when enabled', () => {
|
|
23
|
+
expect(computeAppActionLabelSuffix({ admin: true, builtin: false })).toBe('');
|
|
24
|
+
});
|
|
25
|
+
});
|