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.
Files changed (79) hide show
  1. package/dist/actions/MenuButton.svelte +2 -1
  2. package/dist/actions/contextMenuModel.d.ts +1 -1
  3. package/dist/actions/contextMenuModel.js +2 -1
  4. package/dist/actions/dispatcher.svelte.d.ts +1 -1
  5. package/dist/actions/dispatcher.svelte.js +2 -1
  6. package/dist/actions/listActive.d.ts +1 -1
  7. package/dist/actions/listActive.js +2 -1
  8. package/dist/actions/listeners.d.ts +1 -1
  9. package/dist/actions/listeners.js +6 -5
  10. package/dist/actions/menuBarModel.js +3 -2
  11. package/dist/actions/paletteModel.js +2 -1
  12. package/dist/actions/resolveLabel.test.d.ts +1 -0
  13. package/dist/actions/resolveLabel.test.js +14 -0
  14. package/dist/actions/types.d.ts +12 -1
  15. package/dist/actions/types.js +7 -1
  16. package/dist/app/store/AppUpdateAvailableModal.svelte +87 -0
  17. package/dist/app/store/AppUpdateAvailableModal.svelte.d.ts +11 -0
  18. package/dist/app/store/StoreView.svelte +15 -4
  19. package/dist/app/store/UninstallAppDialog.svelte +86 -0
  20. package/dist/app/store/UninstallAppDialog.svelte.d.ts +10 -0
  21. package/dist/app/store/permissionConfirm.d.ts +4 -0
  22. package/dist/app/store/permissionConfirm.js +27 -0
  23. package/dist/app/store/storeApp.js +0 -1
  24. package/dist/app/store/storeShard.svelte.d.ts +8 -1
  25. package/dist/app/store/storeShard.svelte.js +51 -27
  26. package/dist/app/store/storeTypes.d.ts +21 -0
  27. package/dist/app/store/storeTypes.js +33 -0
  28. package/dist/app/store/storeTypes.test.d.ts +1 -0
  29. package/dist/app/store/storeTypes.test.js +41 -0
  30. package/dist/app/store/updatePackage.test.d.ts +1 -0
  31. package/dist/app/store/updatePackage.test.js +34 -0
  32. package/dist/app/store/verbs.d.ts +1 -0
  33. package/dist/app/store/verbs.js +79 -5
  34. package/dist/app/store/verbs.test.d.ts +1 -0
  35. package/dist/app/store/verbs.test.js +59 -0
  36. package/dist/app-appearance/AppAppearanceModal.svelte +174 -0
  37. package/dist/app-appearance/AppAppearanceModal.svelte.d.ts +8 -0
  38. package/dist/app-appearance/appearanceShard.svelte.d.ts +2 -0
  39. package/dist/app-appearance/appearanceShard.svelte.js +61 -0
  40. package/dist/app-appearance/appearanceState.svelte.d.ts +15 -0
  41. package/dist/app-appearance/appearanceState.svelte.js +59 -0
  42. package/dist/app-appearance/appearanceState.test.d.ts +1 -0
  43. package/dist/app-appearance/appearanceState.test.js +30 -0
  44. package/dist/app-appearance/index.d.ts +3 -0
  45. package/dist/app-appearance/index.js +2 -0
  46. package/dist/app-appearance/types.d.ts +11 -0
  47. package/dist/app-appearance/types.js +1 -0
  48. package/dist/apps/types.d.ts +7 -0
  49. package/dist/assets/iconIds.generated.d.ts +2 -0
  50. package/dist/assets/iconIds.generated.js +154 -0
  51. package/dist/host.js +2 -1
  52. package/dist/overlays/FloatFrame.svelte +18 -1
  53. package/dist/overlays/float.d.ts +12 -0
  54. package/dist/overlays/float.js +16 -0
  55. package/dist/overlays/float.test.js +97 -2
  56. package/dist/overlays/modal.js +1 -0
  57. package/dist/overlays/modal.test.js +17 -0
  58. package/dist/overlays/parentHost.d.ts +1 -0
  59. package/dist/overlays/parentHost.js +15 -0
  60. package/dist/overlays/parentHost.test.d.ts +1 -0
  61. package/dist/overlays/parentHost.test.js +39 -0
  62. package/dist/overlays/popup.js +1 -0
  63. package/dist/overlays/popup.test.js +19 -0
  64. package/dist/primitives/widgets/IconPicker.svelte +115 -0
  65. package/dist/primitives/widgets/IconPicker.svelte.d.ts +9 -0
  66. package/dist/primitives/widgets/IconPicker.svelte.test.d.ts +1 -0
  67. package/dist/primitives/widgets/IconPicker.svelte.test.js +43 -0
  68. package/dist/projects-shard/ProjectManage.svelte +14 -4
  69. package/dist/sh3core-shard/ShellHome.svelte +64 -38
  70. package/dist/sh3core-shard/appActions.d.ts +13 -0
  71. package/dist/sh3core-shard/appActions.js +181 -0
  72. package/dist/sh3core-shard/appActions.test.d.ts +1 -0
  73. package/dist/sh3core-shard/appActions.test.js +25 -0
  74. package/dist/sh3core-shard/sh3coreShard.svelte.js +2 -0
  75. package/dist/version.d.ts +1 -1
  76. package/dist/version.js +1 -1
  77. package/package.json +2 -2
  78. package/dist/app/store/InstalledView.svelte +0 -301
  79. 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
+ });
@@ -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,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
- let name = $state(project?.name ?? '');
30
- let description = $state(project?.description ?? '');
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
- project ? [...project.members] : (me?.id ? [me.id] : []),
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
- title="Launch {manifest.label}"
98
+ oncontextmenu={(e) => openAppContextMenu(e, manifest.id)}
79
99
  >
80
- <div class="shell-home-card-label">{manifest.label}</div>
81
- <div class="shell-home-card-meta">
82
- <span class="shell-home-card-id">{manifest.id}</span>
83
- <span class="shell-home-card-version">v{manifest.version}</span>
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
- title="Launch {manifest.label}"
123
+ oncontextmenu={(e) => openAppContextMenu(e, manifest.id)}
101
124
  >
102
- <div class="shell-home-card-label">{manifest.label}</div>
103
- <div class="shell-home-card-meta">
104
- <span class="shell-home-card-id">{manifest.id}</span>
105
- <span class="shell-home-card-version">v{manifest.version}</span>
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(80px, 1fr));
227
- gap: 10px;
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
- justify-content: space-between;
234
- text-align: left;
235
- padding: 10px;
236
- background: var(--shell-grad-bg-elevated, var(--shell-bg-elevated));
237
- border: 1px solid var(--shell-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: 12px;
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-meta {
272
- display: flex;
273
- flex-direction: column;
274
- gap: 1px;
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-version {
285
- color: var(--shell-fg-muted);
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
+ });