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
@@ -1,10 +1,14 @@
1
1
  /*
2
- * Store shard — framework-shipped shard for browsing and managing
3
- * installed packages.
2
+ * Store shard — framework-shipped shard for browsing and installing
3
+ * packages.
4
4
  *
5
- * Contributes two views:
6
- * - `sh3-store:browse` — searchable/filterable catalog of available packages
7
- * - `sh3-store:installed` — list of installed packages with uninstall
5
+ * Contributes a single view:
6
+ * - `sh3-store:browse` — searchable/filterable catalog of available packages
7
+ *
8
+ * Uninstall and update flows for already-installed packages live on the
9
+ * shell home card's context menu (see `sh3core-shard/appActions.ts`); this
10
+ * shard exposes the underlying operations as verbs (`installVerb`,
11
+ * `uninstallVerb`, `updateVerb`, `appinfoVerb`) and via `storeContext`.
8
12
  *
9
13
  * Uses env state for registries (server-authoritative, admin-writable) and
10
14
  * an ephemeral zone for the live catalog / installed list / loading / error state.
@@ -13,15 +17,15 @@
13
17
  */
14
18
  import { mount, unmount } from 'svelte';
15
19
  import StoreView from './StoreView.svelte';
16
- import InstalledView from './InstalledView.svelte';
17
20
  import { fetchRegistries, fetchBundle, fetchServerBundle, buildPackageMeta } from '../../registry/client';
18
21
  import { installPackage, listInstalledPackages } from '../../registry/installer';
22
+ import { uninstallPackage as installerUninstallPackage } from '../../registry/installer';
19
23
  import { loadBundle, loadMeta, savePackage } from '../../registry/storage';
20
24
  import { loadBundleModule } from '../../registry/loader';
21
25
  import { extractBundlePermissions } from '../../registry/permission-descriptions';
22
- import { serverInstallPackage, fetchServerPackages } from '../../env/client';
26
+ import { serverInstallPackage, fetchServerPackages, serverUninstallPackage } from '../../env/client';
23
27
  import { VERSION } from '../../version';
24
- import { installVerb, uninstallVerb, appinfoVerb } from './verbs';
28
+ import { installVerb, uninstallVerb, appinfoVerb, updateVerb } from './verbs';
25
29
  /**
26
30
  * Compare two semver-like version strings.
27
31
  * Returns true only if `available` is strictly greater than `installed`.
@@ -43,6 +47,20 @@ function isNewerVersion(available, installed) {
43
47
  }
44
48
  return false;
45
49
  }
50
+ /**
51
+ * Pick a version entry from a resolved package. When `requested` is
52
+ * undefined returns `latest`; when set, finds the matching entry by
53
+ * exact version string. Throws if the requested version is absent.
54
+ */
55
+ export function pickVersion(pkg, requested) {
56
+ if (requested === undefined)
57
+ return pkg.latest;
58
+ const found = pkg.entry.versions.find((v) => v.version === requested);
59
+ if (!found) {
60
+ throw new Error(`version ${requested} not in catalog for ${pkg.entry.id}`);
61
+ }
62
+ return found;
63
+ }
46
64
  /**
47
65
  * Compute added and removed permissions between two manifest snapshots.
48
66
  * Order within each array follows the input order of `newPerms` (for added)
@@ -68,7 +86,6 @@ export const storeShard = {
68
86
  version: VERSION,
69
87
  views: [
70
88
  { id: 'sh3-store:browse', label: 'Store' },
71
- { id: 'sh3-store:installed', label: 'Installed' },
72
89
  ],
73
90
  },
74
91
  activate(ctx) {
@@ -149,21 +166,32 @@ export const storeShard = {
149
166
  const registries = env.registries.filter((r) => r !== url);
150
167
  await ctx.envUpdate({ registries });
151
168
  }
152
- async function updatePackage(id, confirmPermissionChange) {
169
+ async function updatePackage(id, confirmPermissionChange, version) {
153
170
  var _a, _b, _c, _d;
154
- const catalogEntry = state.ephemeral.updatable[id];
171
+ // Source the catalog entry. Without an explicit version we use the
172
+ // updatable map (which encodes the "newer than installed" check); with a
173
+ // version we look up the package in the full catalog so downgrades and
174
+ // same-version reinstalls work.
175
+ let catalogEntry;
176
+ if (version === undefined) {
177
+ catalogEntry = state.ephemeral.updatable[id];
178
+ }
179
+ else {
180
+ catalogEntry = state.ephemeral.catalog.find((p) => p.entry.id === id);
181
+ }
155
182
  if (!catalogEntry)
156
183
  return;
157
184
  const installedRecord = state.ephemeral.installed.find((p) => p.id === id);
158
185
  if (!installedRecord)
159
186
  return;
187
+ const picked = pickVersion(catalogEntry, version);
160
188
  // 1. Fetch new bundle(s).
161
- const bundle = await fetchBundle(catalogEntry.latest, catalogEntry.sourceRegistry);
189
+ const bundle = await fetchBundle(picked, catalogEntry.sourceRegistry);
162
190
  let serverBundle;
163
- if (catalogEntry.latest.serverBundleUrl) {
164
- serverBundle = await fetchServerBundle(catalogEntry.latest, catalogEntry.sourceRegistry);
191
+ if (picked.serverBundleUrl) {
192
+ serverBundle = await fetchServerBundle(picked, catalogEntry.sourceRegistry);
165
193
  }
166
- const meta = buildPackageMeta(catalogEntry, catalogEntry.latest);
194
+ const meta = buildPackageMeta(catalogEntry, picked);
167
195
  // 2. Load the module once for permission extraction and install reuse.
168
196
  const loaded = await loadBundleModule(bundle);
169
197
  const newPerms = extractBundlePermissions(loaded);
@@ -203,7 +231,7 @@ export const storeShard = {
203
231
  contractVersion: meta.contractVersion,
204
232
  sourceRegistry: meta.sourceRegistry,
205
233
  installedAt: new Date().toISOString(),
206
- requiredShards: (_b = (_a = catalogEntry.latest.requires) === null || _a === void 0 ? void 0 : _a.map((r) => r.id)) !== null && _b !== void 0 ? _b : [],
234
+ requiredShards: (_b = (_a = picked.requires) === null || _a === void 0 ? void 0 : _a.map((r) => r.id)) !== null && _b !== void 0 ? _b : [],
207
235
  };
208
236
  const serverResult = await serverInstallPackage(manifest, bundle, serverBundle);
209
237
  if (!serverResult.ok) {
@@ -231,6 +259,11 @@ export const storeShard = {
231
259
  }
232
260
  await refreshInstalled();
233
261
  }
262
+ async function uninstallPackage(id) {
263
+ await serverUninstallPackage(id);
264
+ await installerUninstallPackage(id);
265
+ await refreshInstalled();
266
+ }
234
267
  storeContext = {
235
268
  env,
236
269
  state,
@@ -238,6 +271,7 @@ export const storeShard = {
238
271
  refreshCatalog,
239
272
  refreshInstalled,
240
273
  updatePackage,
274
+ uninstallPackage,
241
275
  addRegistry,
242
276
  removeRegistry,
243
277
  };
@@ -252,22 +286,12 @@ export const storeShard = {
252
286
  };
253
287
  },
254
288
  };
255
- const installedFactory = {
256
- mount(container, _context) {
257
- const instance = mount(InstalledView, { target: container });
258
- return {
259
- unmount() {
260
- unmount(instance);
261
- },
262
- };
263
- },
264
- };
265
289
  ctx.registerView('sh3-store:browse', browseFactory);
266
- ctx.registerView('sh3-store:installed', installedFactory);
267
290
  // Store verbs — registered as sh3-store:install, sh3-store:uninstall, sh3-store:appinfo
268
291
  ctx.registerVerb(installVerb);
269
292
  ctx.registerVerb(uninstallVerb);
270
293
  ctx.registerVerb(appinfoVerb);
294
+ ctx.registerVerb(updateVerb);
271
295
  // refreshInstalled can run immediately (hits server, no env needed).
272
296
  refreshInstalled();
273
297
  },
@@ -0,0 +1,21 @@
1
+ /** Internal package types as carried by the registry index. */
2
+ export type PackageType = 'shard' | 'app' | 'combo';
3
+ /** User-visible package types — the registry triple collapsed to a pair. */
4
+ export type DisplayPackageType = 'shard' | 'app';
5
+ /** Type-filter values exposed by the browse-view dropdown. */
6
+ export type PackageTypeFilter = 'all' | DisplayPackageType;
7
+ /**
8
+ * Collapse the internal triple to the user-visible pair. Combo packages
9
+ * fold into `app`.
10
+ */
11
+ export declare function displayPackageType(type: PackageType): DisplayPackageType;
12
+ /**
13
+ * Title-cased label for the type chip. Returns `"Shard"` or `"App"` —
14
+ * never `"Combo"`.
15
+ */
16
+ export declare function displayPackageTypeLabel(type: PackageType): 'Shard' | 'App';
17
+ /**
18
+ * True if a package of `type` should be visible under the chosen filter.
19
+ * Combos pass under both `"all"` and `"app"` (never under `"shard"`).
20
+ */
21
+ export declare function packageMatchesTypeFilter(type: PackageType, filter: PackageTypeFilter): boolean;
@@ -0,0 +1,33 @@
1
+ /*
2
+ * Pure helpers that translate the registry's internal package-type triple
3
+ * (`shard | app | combo`) into the two-value vocabulary the store UI shows
4
+ * the user (`Shard | App`). `combo` is an internal distinction — a package
5
+ * that ships both a shard and an app surface — and from a user's standpoint
6
+ * a combo *is* an app, so it collapses to "App" everywhere user-facing.
7
+ *
8
+ * Kept as a separate module (rather than inline in StoreView) so the mapping
9
+ * is unit-testable without mounting Svelte components.
10
+ */
11
+ /**
12
+ * Collapse the internal triple to the user-visible pair. Combo packages
13
+ * fold into `app`.
14
+ */
15
+ export function displayPackageType(type) {
16
+ return type === 'shard' ? 'shard' : 'app';
17
+ }
18
+ /**
19
+ * Title-cased label for the type chip. Returns `"Shard"` or `"App"` —
20
+ * never `"Combo"`.
21
+ */
22
+ export function displayPackageTypeLabel(type) {
23
+ return displayPackageType(type) === 'shard' ? 'Shard' : 'App';
24
+ }
25
+ /**
26
+ * True if a package of `type` should be visible under the chosen filter.
27
+ * Combos pass under both `"all"` and `"app"` (never under `"shard"`).
28
+ */
29
+ export function packageMatchesTypeFilter(type, filter) {
30
+ if (filter === 'all')
31
+ return true;
32
+ return displayPackageType(type) === filter;
33
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { displayPackageType, displayPackageTypeLabel, packageMatchesTypeFilter, } from './storeTypes';
3
+ describe('displayPackageType', () => {
4
+ it('returns "shard" for shard packages', () => {
5
+ expect(displayPackageType('shard')).toBe('shard');
6
+ });
7
+ it('returns "app" for app packages', () => {
8
+ expect(displayPackageType('app')).toBe('app');
9
+ });
10
+ it('collapses combo packages to "app"', () => {
11
+ expect(displayPackageType('combo')).toBe('app');
12
+ });
13
+ });
14
+ describe('displayPackageTypeLabel', () => {
15
+ it('returns "Shard" for shard packages', () => {
16
+ expect(displayPackageTypeLabel('shard')).toBe('Shard');
17
+ });
18
+ it('returns "App" for app packages', () => {
19
+ expect(displayPackageTypeLabel('app')).toBe('App');
20
+ });
21
+ it('returns "App" for combo packages — the user-visible vocabulary never includes "combo"', () => {
22
+ expect(displayPackageTypeLabel('combo')).toBe('App');
23
+ });
24
+ });
25
+ describe('packageMatchesTypeFilter', () => {
26
+ it('matches every type when filter is "all"', () => {
27
+ expect(packageMatchesTypeFilter('shard', 'all')).toBe(true);
28
+ expect(packageMatchesTypeFilter('app', 'all')).toBe(true);
29
+ expect(packageMatchesTypeFilter('combo', 'all')).toBe(true);
30
+ });
31
+ it('matches only shards when filter is "shard"', () => {
32
+ expect(packageMatchesTypeFilter('shard', 'shard')).toBe(true);
33
+ expect(packageMatchesTypeFilter('app', 'shard')).toBe(false);
34
+ expect(packageMatchesTypeFilter('combo', 'shard')).toBe(false);
35
+ });
36
+ it('matches both apps and combos when filter is "app"', () => {
37
+ expect(packageMatchesTypeFilter('shard', 'app')).toBe(false);
38
+ expect(packageMatchesTypeFilter('app', 'app')).toBe(true);
39
+ expect(packageMatchesTypeFilter('combo', 'app')).toBe(true);
40
+ });
41
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ /*
2
+ * updatePackage() — version-aware unit tests. We exercise the version
3
+ * resolution branch only; the full flow (server install + permission diff)
4
+ * is covered indirectly through the home-card update-action tests.
5
+ */
6
+ import { describe, it, expect } from 'vitest';
7
+ import { pickVersion } from './storeShard.svelte';
8
+ function mkPkg(id, versions) {
9
+ return {
10
+ sourceRegistry: 'https://example.test/registry.json',
11
+ entry: {
12
+ id,
13
+ label: id,
14
+ author: { name: 'tester' },
15
+ description: '',
16
+ versions: versions.map((v) => ({ version: v, bundleUrl: `${id}-${v}.js`, integrity: 'sha256-x' })),
17
+ },
18
+ latest: { version: versions[0], bundleUrl: `${id}-${versions[0]}.js`, integrity: 'sha256-x' },
19
+ };
20
+ }
21
+ describe('pickVersion', () => {
22
+ it('returns latest when no version requested', () => {
23
+ const pkg = mkPkg('foo', ['2.0.0', '1.0.0']);
24
+ expect(pickVersion(pkg, undefined).version).toBe('2.0.0');
25
+ });
26
+ it('returns the matching entry when version exists', () => {
27
+ const pkg = mkPkg('foo', ['2.0.0', '1.0.0']);
28
+ expect(pickVersion(pkg, '1.0.0').version).toBe('1.0.0');
29
+ });
30
+ it('throws when version is not in catalog', () => {
31
+ const pkg = mkPkg('foo', ['2.0.0']);
32
+ expect(() => pickVersion(pkg, '9.9.9')).toThrow(/version 9\.9\.9 not in catalog for foo/);
33
+ });
34
+ });
@@ -1,4 +1,5 @@
1
1
  import type { Verb } from '../../verbs/types';
2
2
  export declare const installVerb: Verb;
3
3
  export declare const uninstallVerb: Verb;
4
+ export declare const updateVerb: Verb;
4
5
  export declare const appinfoVerb: Verb;
@@ -7,8 +7,7 @@
7
7
  import { storeContext } from './storeShard.svelte';
8
8
  import { fetchBundle, fetchServerBundle, buildPackageMeta } from '../../registry/client';
9
9
  import { installPackage } from '../../registry/installer';
10
- import { serverInstallPackage, serverUninstallPackage } from '../../env/client';
11
- import { uninstallPackage } from '../../registry/installer';
10
+ import { serverInstallPackage } from '../../env/client';
12
11
  function findInCatalog(id) {
13
12
  return storeContext.state.ephemeral.catalog.find((p) => p.entry.id === id);
14
13
  }
@@ -146,9 +145,7 @@ export const uninstallVerb = {
146
145
  ts: Date.now(),
147
146
  });
148
147
  try {
149
- await serverUninstallPackage(id);
150
- await uninstallPackage(id);
151
- await storeContext.refreshInstalled();
148
+ await storeContext.uninstallPackage(id);
152
149
  ctx.scrollback.push({
153
150
  kind: 'status',
154
151
  text: `uninstalled ${id}`,
@@ -166,6 +163,83 @@ export const uninstallVerb = {
166
163
  }
167
164
  },
168
165
  };
166
+ export const updateVerb = {
167
+ name: 'update',
168
+ summary: 'Update an installed package (sh3-store:update <id> [version]). When ' +
169
+ 'version is omitted, bumps to latest; with a version, installs that ' +
170
+ 'exact version (downgrade or same-version reinstall allowed).',
171
+ async run(ctx, args) {
172
+ var _a, _b;
173
+ const id = args[0];
174
+ const version = args[1];
175
+ if (!id) {
176
+ ctx.scrollback.push({
177
+ kind: 'status',
178
+ text: 'usage: sh3-store:update <package-id> [version]',
179
+ level: 'warn',
180
+ ts: Date.now(),
181
+ });
182
+ return;
183
+ }
184
+ const installed = findInstalled(id);
185
+ if (!installed) {
186
+ ctx.scrollback.push({
187
+ kind: 'status',
188
+ text: `package "${id}" is not installed`,
189
+ level: 'error',
190
+ ts: Date.now(),
191
+ });
192
+ return;
193
+ }
194
+ const fromVersion = installed.version;
195
+ const action = version && version === fromVersion
196
+ ? 'reinstalling'
197
+ : version
198
+ ? `updating ${id} from v${fromVersion} to v${version}`
199
+ : `updating ${id} from v${fromVersion} to latest`;
200
+ ctx.scrollback.push({
201
+ kind: 'status',
202
+ text: action.startsWith('reinstalling')
203
+ ? `reinstalling ${id} v${version}…`
204
+ : `${action}…`,
205
+ level: 'info',
206
+ ts: Date.now(),
207
+ });
208
+ // Verb path can't surface a modal. If the new bundle changes permissions,
209
+ // auto-reject and tell the user to use the UI flow.
210
+ const confirmReject = async (added, removed) => {
211
+ ctx.scrollback.push({
212
+ kind: 'status',
213
+ text: `permission change required (added: ${added.join(', ') || 'none'}; ` +
214
+ `removed: ${removed.join(', ') || 'none'}) — open the store and use ` +
215
+ `the Update button to review and apply.`,
216
+ level: 'warn',
217
+ ts: Date.now(),
218
+ });
219
+ return false;
220
+ };
221
+ try {
222
+ await storeContext.updatePackage(id, confirmReject, version);
223
+ const finalVersion = (_b = version !== null && version !== void 0 ? version : (_a = findInstalled(id)) === null || _a === void 0 ? void 0 : _a.version) !== null && _b !== void 0 ? _b : '?';
224
+ ctx.scrollback.push({
225
+ kind: 'status',
226
+ text: version && version === fromVersion
227
+ ? `reinstalled ${id} v${finalVersion}`
228
+ : `updated ${id} v${finalVersion}`,
229
+ level: 'info',
230
+ ts: Date.now(),
231
+ });
232
+ }
233
+ catch (err) {
234
+ ctx.scrollback.push({
235
+ kind: 'status',
236
+ text: `update failed: ${err instanceof Error ? err.message : String(err)}`,
237
+ level: 'error',
238
+ ts: Date.now(),
239
+ });
240
+ }
241
+ },
242
+ };
169
243
  export const appinfoVerb = {
170
244
  name: 'appinfo',
171
245
  summary: 'Show info about a package (installed status, version, catalog details).',
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ const { mockStoreContext } = vi.hoisted(() => ({
3
+ mockStoreContext: {
4
+ state: { ephemeral: { installed: [] } },
5
+ updatePackage: vi.fn(),
6
+ },
7
+ }));
8
+ vi.mock('./storeShard.svelte', () => ({
9
+ storeContext: mockStoreContext,
10
+ }));
11
+ import { updateVerb } from './verbs';
12
+ function mkCtx() {
13
+ const lines = [];
14
+ return {
15
+ ctx: {
16
+ scrollback: { push: (e) => lines.push(e) },
17
+ },
18
+ lines,
19
+ };
20
+ }
21
+ describe('updateVerb', () => {
22
+ beforeEach(() => {
23
+ mockStoreContext.state = { ephemeral: { installed: [] } };
24
+ mockStoreContext.updatePackage = vi.fn();
25
+ });
26
+ it('warns when no id is given', async () => {
27
+ var _a;
28
+ const { ctx, lines } = mkCtx();
29
+ await updateVerb.run(ctx, []);
30
+ expect((_a = lines.at(-1)) === null || _a === void 0 ? void 0 : _a.text).toMatch(/usage: sh3-store:update/);
31
+ });
32
+ it('delegates to storeContext.updatePackage when version omitted', async () => {
33
+ const fake = vi.fn().mockResolvedValue(undefined);
34
+ mockStoreContext.state = { ephemeral: { installed: [{ id: 'foo', version: '1.0.0' }] } };
35
+ mockStoreContext.updatePackage = fake;
36
+ const { ctx, lines } = mkCtx();
37
+ await updateVerb.run(ctx, ['foo']);
38
+ expect(fake).toHaveBeenCalledWith('foo', expect.any(Function), undefined);
39
+ expect(lines.some((l) => /updated foo/.test(l.text))).toBe(true);
40
+ });
41
+ it('passes version through when provided', async () => {
42
+ const fake = vi.fn().mockResolvedValue(undefined);
43
+ mockStoreContext.state = { ephemeral: { installed: [{ id: 'foo', version: '2.0.0' }] } };
44
+ mockStoreContext.updatePackage = fake;
45
+ const { ctx } = mkCtx();
46
+ await updateVerb.run(ctx, ['foo', '1.0.0']);
47
+ expect(fake).toHaveBeenCalledWith('foo', expect.any(Function), '1.0.0');
48
+ });
49
+ it('reports failure as error scrollback line', async () => {
50
+ const fake = vi.fn().mockRejectedValue(new Error('boom'));
51
+ mockStoreContext.state = { ephemeral: { installed: [{ id: 'foo', version: '1.0.0' }] } };
52
+ mockStoreContext.updatePackage = fake;
53
+ const { ctx, lines } = mkCtx();
54
+ await updateVerb.run(ctx, ['foo']);
55
+ const last = lines.at(-1);
56
+ expect(last.level).toBe('error');
57
+ expect(last.text).toMatch(/update failed: boom/);
58
+ });
59
+ });
@@ -0,0 +1,174 @@
1
+ <script lang="ts">
2
+ /*
3
+ * Customize — pick an icon and color override for a single app.
4
+ * Reads the existing override on mount via untrack (Svelte 5 idiom for
5
+ * one-shot prop reads), so editing the form doesn't subscribe to the
6
+ * shard's reactive state. Save/Reset/Cancel are mutually exclusive.
7
+ */
8
+
9
+ import { untrack } from 'svelte';
10
+ import IconPicker from '../primitives/widgets/IconPicker.svelte';
11
+ import ColorSwatch from '../primitives/widgets/ColorSwatch.svelte';
12
+ import iconsUrl from '../assets/icons.svg';
13
+ import { listRegisteredApps } from '../api';
14
+ import { getAppearance, setAppearance } from './appearanceState.svelte';
15
+
16
+ interface Props {
17
+ appId: string;
18
+ appLabel: string;
19
+ close: () => void;
20
+ }
21
+
22
+ let { appId, appLabel, close }: Props = $props();
23
+
24
+ const initial = untrack(() => getAppearance(appId));
25
+ const manifestIcon = untrack(
26
+ () => listRegisteredApps().find((m) => m.id === appId)?.icon,
27
+ );
28
+
29
+ let icon = $state<string | undefined>(initial?.icon);
30
+ let color = $state<string | undefined>(initial?.color);
31
+ let label = $state<string>(initial?.label ?? '');
32
+ let pickerOpen = $state<boolean>(initial?.icon !== undefined);
33
+
34
+ const hasOverride = $derived(initial !== undefined);
35
+ const effectiveLabel = $derived(label.trim() === '' ? appLabel : label.trim());
36
+ const effectiveIcon = $derived(icon ?? manifestIcon ?? 'box');
37
+
38
+ function save() {
39
+ const trimmed = label.trim();
40
+ setAppearance(appId, {
41
+ icon,
42
+ color,
43
+ label: trimmed === '' ? undefined : trimmed,
44
+ });
45
+ close();
46
+ }
47
+
48
+ function reset() {
49
+ setAppearance(appId, undefined);
50
+ close();
51
+ }
52
+ </script>
53
+
54
+ <div class="app-appearance">
55
+ <h2>Customize {appLabel}</h2>
56
+
57
+ <div class="preview">
58
+ <div
59
+ class="preview-card"
60
+ class:preview-card--tinted={color !== undefined}
61
+ style:--card-color={color ?? 'transparent'}
62
+ >
63
+ <span class="preview-card-square">
64
+ <svg class="preview-card-icon"><use href="{iconsUrl}#{effectiveIcon}" /></svg>
65
+ </span>
66
+ <span class="preview-card-label">{effectiveLabel}</span>
67
+ </div>
68
+ </div>
69
+
70
+ <label class="row"><span>Name <em>(empty = default)</em></span>
71
+ <input
72
+ type="text"
73
+ bind:value={label}
74
+ placeholder={appLabel}
75
+ class="name-input"
76
+ />
77
+ </label>
78
+
79
+ <div class="row">
80
+ <span>Icon</span>
81
+ {#if !pickerOpen}
82
+ <button type="button" class="link" onclick={() => (pickerOpen = true)}>
83
+ Change icon
84
+ </button>
85
+ {:else}
86
+ <IconPicker bind:value={icon} />
87
+ <button type="button" class="link link--align-right" onclick={() => (pickerOpen = false)}>
88
+ Hide picker
89
+ </button>
90
+ {/if}
91
+ </div>
92
+
93
+ <label class="row"><span>Color</span>
94
+ <ColorSwatch value={color ?? '#888888'} onchange={(v) => (color = v)} />
95
+ </label>
96
+
97
+ <div class="actions">
98
+ <button type="button" class="primary" onclick={save}>Save</button>
99
+ <button type="button" onclick={reset} disabled={!hasOverride}>Reset</button>
100
+ <button type="button" onclick={close}>Cancel</button>
101
+ </div>
102
+ </div>
103
+
104
+ <style>
105
+ .app-appearance {
106
+ padding: 16px 20px;
107
+ max-width: 460px;
108
+ color: var(--shell-fg);
109
+ background: var(--shell-bg);
110
+ font: inherit;
111
+ }
112
+ h2 { margin: 0 0 12px; font-size: 16px; }
113
+ .row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; font-size: 13px; }
114
+ .row span { color: var(--shell-fg-muted); }
115
+ .row span em { font-style: italic; opacity: 0.7; }
116
+ .name-input {
117
+ background: var(--shell-bg-elevated);
118
+ color: var(--shell-fg);
119
+ border: 1px solid var(--shell-border);
120
+ border-radius: var(--shell-radius-sm, 3px);
121
+ padding: 6px 8px; font: inherit; font-size: 13px;
122
+ }
123
+ .link {
124
+ align-self: flex-start;
125
+ background: transparent;
126
+ border: none;
127
+ padding: 0;
128
+ color: var(--shell-accent);
129
+ font: inherit;
130
+ font-size: 13px;
131
+ cursor: pointer;
132
+ text-decoration: underline;
133
+ }
134
+ .link--align-right { align-self: flex-end; margin-top: 4px; }
135
+ .link:hover { color: var(--shell-fg); }
136
+ .preview { display: flex; justify-content: center; margin-bottom: 16px; }
137
+ .preview-card {
138
+ display: flex; flex-direction: column;
139
+ align-items: center; gap: 6px;
140
+ max-width: 160px;
141
+ }
142
+ .preview-card-square {
143
+ width: 64px; height: 64px;
144
+ display: flex; align-items: center; justify-content: center;
145
+ background: var(--shell-grad-bg-elevated, var(--shell-bg-elevated));
146
+ border: 1px solid var(--shell-border);
147
+ border-radius: var(--shell-radius-md);
148
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25), 0 1px 2px rgba(0, 0, 0, 0.15);
149
+ }
150
+ .preview-card--tinted .preview-card-square { background: var(--card-color); }
151
+ .preview-card-icon { width: 28px; height: 28px; color: var(--shell-fg); }
152
+ .preview-card-label {
153
+ font-weight: 600; font-size: 11px; line-height: 1.2;
154
+ text-align: center; word-break: break-word;
155
+ overflow: hidden;
156
+ display: -webkit-box;
157
+ -webkit-box-orient: vertical;
158
+ -webkit-line-clamp: 2;
159
+ line-clamp: 2;
160
+ }
161
+ .preview-card-icon { width: 24px; height: 24px; color: var(--shell-fg); }
162
+ .preview-card-label { font-weight: 600; padding: 0 4px; line-height: 1.2; }
163
+ .actions { display: flex; gap: 8px; margin-top: 16px; }
164
+ .actions button {
165
+ background: var(--shell-bg-elevated);
166
+ color: var(--shell-fg);
167
+ border: 1px solid var(--shell-border);
168
+ border-radius: var(--shell-radius-sm, 3px);
169
+ padding: 6px 14px; font: inherit; cursor: pointer;
170
+ }
171
+ .actions button.primary { background: var(--shell-accent); color: #fff; border-color: var(--shell-accent); }
172
+ .actions button:hover { border-color: var(--shell-accent); }
173
+ .actions button:disabled { opacity: 0.5; cursor: not-allowed; }
174
+ </style>
@@ -0,0 +1,8 @@
1
+ interface Props {
2
+ appId: string;
3
+ appLabel: string;
4
+ close: () => void;
5
+ }
6
+ declare const AppAppearanceModal: import("svelte").Component<Props, {}, "">;
7
+ type AppAppearanceModal = ReturnType<typeof AppAppearanceModal>;
8
+ export default AppAppearanceModal;
@@ -0,0 +1,2 @@
1
+ import type { Shard } from '../shards/types';
2
+ export declare const appearanceShard: Shard;