sh3-core 0.13.2 → 0.13.3

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 (60) 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/InstalledView.svelte +8 -54
  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 +28 -0
  23. package/dist/app/store/storeShard.svelte.d.ts +8 -1
  24. package/dist/app/store/storeShard.svelte.js +42 -9
  25. package/dist/app/store/updatePackage.test.d.ts +1 -0
  26. package/dist/app/store/updatePackage.test.js +34 -0
  27. package/dist/app/store/verbs.d.ts +1 -0
  28. package/dist/app/store/verbs.js +79 -5
  29. package/dist/app/store/verbs.test.d.ts +1 -0
  30. package/dist/app/store/verbs.test.js +56 -0
  31. package/dist/app-appearance/AppAppearanceModal.svelte +174 -0
  32. package/dist/app-appearance/AppAppearanceModal.svelte.d.ts +8 -0
  33. package/dist/app-appearance/appearanceShard.svelte.d.ts +2 -0
  34. package/dist/app-appearance/appearanceShard.svelte.js +61 -0
  35. package/dist/app-appearance/appearanceState.svelte.d.ts +15 -0
  36. package/dist/app-appearance/appearanceState.svelte.js +59 -0
  37. package/dist/app-appearance/appearanceState.test.d.ts +1 -0
  38. package/dist/app-appearance/appearanceState.test.js +30 -0
  39. package/dist/app-appearance/index.d.ts +3 -0
  40. package/dist/app-appearance/index.js +2 -0
  41. package/dist/app-appearance/types.d.ts +11 -0
  42. package/dist/app-appearance/types.js +1 -0
  43. package/dist/apps/types.d.ts +7 -0
  44. package/dist/assets/iconIds.generated.d.ts +2 -0
  45. package/dist/assets/iconIds.generated.js +154 -0
  46. package/dist/host.js +2 -1
  47. package/dist/primitives/widgets/IconPicker.svelte +115 -0
  48. package/dist/primitives/widgets/IconPicker.svelte.d.ts +9 -0
  49. package/dist/primitives/widgets/IconPicker.svelte.test.d.ts +1 -0
  50. package/dist/primitives/widgets/IconPicker.svelte.test.js +43 -0
  51. package/dist/projects-shard/ProjectManage.svelte +14 -4
  52. package/dist/sh3core-shard/ShellHome.svelte +64 -38
  53. package/dist/sh3core-shard/appActions.d.ts +13 -0
  54. package/dist/sh3core-shard/appActions.js +181 -0
  55. package/dist/sh3core-shard/appActions.test.d.ts +1 -0
  56. package/dist/sh3core-shard/appActions.test.js +25 -0
  57. package/dist/sh3core-shard/sh3coreShard.svelte.js +2 -0
  58. package/dist/version.d.ts +1 -1
  59. package/dist/version.js +1 -1
  60. package/package.json +2 -2
@@ -16,12 +16,13 @@ import StoreView from './StoreView.svelte';
16
16
  import InstalledView from './InstalledView.svelte';
17
17
  import { fetchRegistries, fetchBundle, fetchServerBundle, buildPackageMeta } from '../../registry/client';
18
18
  import { installPackage, listInstalledPackages } from '../../registry/installer';
19
+ import { uninstallPackage as installerUninstallPackage } from '../../registry/installer';
19
20
  import { loadBundle, loadMeta, savePackage } from '../../registry/storage';
20
21
  import { loadBundleModule } from '../../registry/loader';
21
22
  import { extractBundlePermissions } from '../../registry/permission-descriptions';
22
- import { serverInstallPackage, fetchServerPackages } from '../../env/client';
23
+ import { serverInstallPackage, fetchServerPackages, serverUninstallPackage } from '../../env/client';
23
24
  import { VERSION } from '../../version';
24
- import { installVerb, uninstallVerb, appinfoVerb } from './verbs';
25
+ import { installVerb, uninstallVerb, appinfoVerb, updateVerb } from './verbs';
25
26
  /**
26
27
  * Compare two semver-like version strings.
27
28
  * Returns true only if `available` is strictly greater than `installed`.
@@ -43,6 +44,20 @@ function isNewerVersion(available, installed) {
43
44
  }
44
45
  return false;
45
46
  }
47
+ /**
48
+ * Pick a version entry from a resolved package. When `requested` is
49
+ * undefined returns `latest`; when set, finds the matching entry by
50
+ * exact version string. Throws if the requested version is absent.
51
+ */
52
+ export function pickVersion(pkg, requested) {
53
+ if (requested === undefined)
54
+ return pkg.latest;
55
+ const found = pkg.entry.versions.find((v) => v.version === requested);
56
+ if (!found) {
57
+ throw new Error(`version ${requested} not in catalog for ${pkg.entry.id}`);
58
+ }
59
+ return found;
60
+ }
46
61
  /**
47
62
  * Compute added and removed permissions between two manifest snapshots.
48
63
  * Order within each array follows the input order of `newPerms` (for added)
@@ -149,21 +164,32 @@ export const storeShard = {
149
164
  const registries = env.registries.filter((r) => r !== url);
150
165
  await ctx.envUpdate({ registries });
151
166
  }
152
- async function updatePackage(id, confirmPermissionChange) {
167
+ async function updatePackage(id, confirmPermissionChange, version) {
153
168
  var _a, _b, _c, _d;
154
- const catalogEntry = state.ephemeral.updatable[id];
169
+ // Source the catalog entry. Without an explicit version we use the
170
+ // updatable map (which encodes the "newer than installed" check); with a
171
+ // version we look up the package in the full catalog so downgrades and
172
+ // same-version reinstalls work.
173
+ let catalogEntry;
174
+ if (version === undefined) {
175
+ catalogEntry = state.ephemeral.updatable[id];
176
+ }
177
+ else {
178
+ catalogEntry = state.ephemeral.catalog.find((p) => p.entry.id === id);
179
+ }
155
180
  if (!catalogEntry)
156
181
  return;
157
182
  const installedRecord = state.ephemeral.installed.find((p) => p.id === id);
158
183
  if (!installedRecord)
159
184
  return;
185
+ const picked = pickVersion(catalogEntry, version);
160
186
  // 1. Fetch new bundle(s).
161
- const bundle = await fetchBundle(catalogEntry.latest, catalogEntry.sourceRegistry);
187
+ const bundle = await fetchBundle(picked, catalogEntry.sourceRegistry);
162
188
  let serverBundle;
163
- if (catalogEntry.latest.serverBundleUrl) {
164
- serverBundle = await fetchServerBundle(catalogEntry.latest, catalogEntry.sourceRegistry);
189
+ if (picked.serverBundleUrl) {
190
+ serverBundle = await fetchServerBundle(picked, catalogEntry.sourceRegistry);
165
191
  }
166
- const meta = buildPackageMeta(catalogEntry, catalogEntry.latest);
192
+ const meta = buildPackageMeta(catalogEntry, picked);
167
193
  // 2. Load the module once for permission extraction and install reuse.
168
194
  const loaded = await loadBundleModule(bundle);
169
195
  const newPerms = extractBundlePermissions(loaded);
@@ -203,7 +229,7 @@ export const storeShard = {
203
229
  contractVersion: meta.contractVersion,
204
230
  sourceRegistry: meta.sourceRegistry,
205
231
  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 : [],
232
+ requiredShards: (_b = (_a = picked.requires) === null || _a === void 0 ? void 0 : _a.map((r) => r.id)) !== null && _b !== void 0 ? _b : [],
207
233
  };
208
234
  const serverResult = await serverInstallPackage(manifest, bundle, serverBundle);
209
235
  if (!serverResult.ok) {
@@ -231,6 +257,11 @@ export const storeShard = {
231
257
  }
232
258
  await refreshInstalled();
233
259
  }
260
+ async function uninstallPackage(id) {
261
+ await serverUninstallPackage(id);
262
+ await installerUninstallPackage(id);
263
+ await refreshInstalled();
264
+ }
234
265
  storeContext = {
235
266
  env,
236
267
  state,
@@ -238,6 +269,7 @@ export const storeShard = {
238
269
  refreshCatalog,
239
270
  refreshInstalled,
240
271
  updatePackage,
272
+ uninstallPackage,
241
273
  addRegistry,
242
274
  removeRegistry,
243
275
  };
@@ -268,6 +300,7 @@ export const storeShard = {
268
300
  ctx.registerVerb(installVerb);
269
301
  ctx.registerVerb(uninstallVerb);
270
302
  ctx.registerVerb(appinfoVerb);
303
+ ctx.registerVerb(updateVerb);
271
304
  // refreshInstalled can run immediately (hits server, no env needed).
272
305
  refreshInstalled();
273
306
  },
@@ -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 InstalledView component 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,56 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { updateVerb } from './verbs';
3
+ import * as shard from './storeShard.svelte';
4
+ function mkCtx() {
5
+ const lines = [];
6
+ return {
7
+ ctx: {
8
+ scrollback: { push: (e) => lines.push(e) },
9
+ },
10
+ lines,
11
+ };
12
+ }
13
+ describe('updateVerb', () => {
14
+ it('warns when no id is given', async () => {
15
+ var _a;
16
+ const { ctx, lines } = mkCtx();
17
+ await updateVerb.run(ctx, []);
18
+ expect((_a = lines.at(-1)) === null || _a === void 0 ? void 0 : _a.text).toMatch(/usage: sh3-store:update/);
19
+ });
20
+ it('delegates to storeContext.updatePackage when version omitted', async () => {
21
+ const fake = vi.fn().mockResolvedValue(undefined);
22
+ // @ts-expect-error — module-level singleton is not readonly at runtime
23
+ shard.storeContext = {
24
+ state: { ephemeral: { installed: [{ id: 'foo', version: '1.0.0' }] } },
25
+ updatePackage: fake,
26
+ };
27
+ const { ctx, lines } = mkCtx();
28
+ await updateVerb.run(ctx, ['foo']);
29
+ expect(fake).toHaveBeenCalledWith('foo', expect.any(Function), undefined);
30
+ expect(lines.some((l) => /updated foo/.test(l.text))).toBe(true);
31
+ });
32
+ it('passes version through when provided', async () => {
33
+ const fake = vi.fn().mockResolvedValue(undefined);
34
+ // @ts-expect-error
35
+ shard.storeContext = {
36
+ state: { ephemeral: { installed: [{ id: 'foo', version: '2.0.0' }] } },
37
+ updatePackage: fake,
38
+ };
39
+ const { ctx } = mkCtx();
40
+ await updateVerb.run(ctx, ['foo', '1.0.0']);
41
+ expect(fake).toHaveBeenCalledWith('foo', expect.any(Function), '1.0.0');
42
+ });
43
+ it('reports failure as error scrollback line', async () => {
44
+ const fake = vi.fn().mockRejectedValue(new Error('boom'));
45
+ // @ts-expect-error
46
+ shard.storeContext = {
47
+ state: { ephemeral: { installed: [{ id: 'foo', version: '1.0.0' }] } },
48
+ updatePackage: fake,
49
+ };
50
+ const { ctx, lines } = mkCtx();
51
+ await updateVerb.run(ctx, ['foo']);
52
+ const last = lines.at(-1);
53
+ expect(last.level).toBe('error');
54
+ expect(last.text).toMatch(/update failed: boom/);
55
+ });
56
+ });
@@ -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;
@@ -0,0 +1,61 @@
1
+ /*
2
+ * `__app-appearance__` shard — owns the user-zone for per-app overrides
3
+ * and registers the `app.customize` element-scope action. The state
4
+ * itself lives in appearanceState.svelte.ts (so unit tests don't have
5
+ * to boot a real ShardContext). This file binds the zone on activate
6
+ * and unbinds on deactivate, and contributes the action.
7
+ */
8
+ import { VERSION } from '../version';
9
+ import { listRegisteredApps } from '../api';
10
+ import { getSelection } from '../actions/selection.svelte';
11
+ import { modalManager } from '../overlays/modal';
12
+ import AppAppearanceModal from './AppAppearanceModal.svelte';
13
+ import { __bindZone, __unbindZone, } from './appearanceState.svelte';
14
+ function readSelection() {
15
+ const sel = getSelection();
16
+ if (!sel || sel.type !== 'app')
17
+ return null;
18
+ return sel.ref;
19
+ }
20
+ function runCustomize(_ctx) {
21
+ var _a;
22
+ const ref = readSelection();
23
+ if (!ref)
24
+ return;
25
+ const m = listRegisteredApps().find((x) => x.id === ref.appId);
26
+ const props = {
27
+ appId: ref.appId,
28
+ appLabel: (_a = m === null || m === void 0 ? void 0 : m.label) !== null && _a !== void 0 ? _a : ref.appId,
29
+ };
30
+ modalManager.open(AppAppearanceModal, props);
31
+ }
32
+ export const appearanceShard = {
33
+ manifest: {
34
+ id: '__app-appearance__',
35
+ label: 'App Appearance',
36
+ version: VERSION,
37
+ views: [],
38
+ },
39
+ activate(ctx) {
40
+ const zone = ctx.state({
41
+ user: { overrides: {} },
42
+ });
43
+ __bindZone(zone);
44
+ const customize = {
45
+ id: 'app.customize',
46
+ label: 'Customize…',
47
+ scope: { element: 'app' },
48
+ contextItem: true,
49
+ group: 'appearance',
50
+ run: runCustomize,
51
+ };
52
+ ctx.actions.register(customize);
53
+ },
54
+ autostart() {
55
+ // Self-start so the `app.customize` action is registered before the
56
+ // user right-clicks a home card. No imperative work required.
57
+ },
58
+ deactivate() {
59
+ __unbindZone();
60
+ },
61
+ };
@@ -0,0 +1,15 @@
1
+ import type { StateZones } from '../state/zones.svelte';
2
+ import type { AppAppearance } from './types';
3
+ export interface AppearanceZoneSchema {
4
+ user: {
5
+ overrides: Record<string, AppAppearance>;
6
+ };
7
+ }
8
+ /** Bind the shard's zone to this module. Called by the shard's activate. */
9
+ export declare function __bindZone(s: StateZones<AppearanceZoneSchema>): void;
10
+ /** Unbind the zone (deactivate). */
11
+ export declare function __unbindZone(): void;
12
+ export declare function getAppearance(appId: string): AppAppearance | undefined;
13
+ export declare function setAppearance(appId: string, value: AppAppearance | undefined): void;
14
+ /** Test-only: replace the bound zone with a memory shim. */
15
+ export declare function __resetForTests(): void;
@@ -0,0 +1,59 @@
1
+ /*
2
+ * Per-user-per-browser visual overrides for apps. The store + helpers
3
+ * live separately from the shard so the state can be unit-tested without
4
+ * booting the shard system, and so the AppAppearanceModal can import
5
+ * get/set without creating an import cycle through the shard's modal
6
+ * import.
7
+ *
8
+ * EXPLICITLY TEMPORARY. A future ADR is expected to add icon/color
9
+ * fields to the app manifest itself.
10
+ */
11
+ let zoneState = null;
12
+ /** Bind the shard's zone to this module. Called by the shard's activate. */
13
+ export function __bindZone(s) {
14
+ zoneState = s;
15
+ }
16
+ /** Unbind the zone (deactivate). */
17
+ export function __unbindZone() {
18
+ zoneState = null;
19
+ }
20
+ function isEmpty(v) {
21
+ return v.icon === undefined && v.color === undefined && v.label === undefined;
22
+ }
23
+ export function getAppearance(appId) {
24
+ const map = zoneState === null || zoneState === void 0 ? void 0 : zoneState.user.overrides;
25
+ if (!map)
26
+ return undefined;
27
+ const v = map[appId];
28
+ if (!v)
29
+ return undefined;
30
+ if (isEmpty(v))
31
+ return undefined;
32
+ return v;
33
+ }
34
+ export function setAppearance(appId, value) {
35
+ if (!zoneState)
36
+ return;
37
+ const map = zoneState.user.overrides;
38
+ const next = Object.assign({}, map);
39
+ if (value === undefined || isEmpty(value)) {
40
+ delete next[appId];
41
+ }
42
+ else {
43
+ next[appId] = value;
44
+ }
45
+ zoneState.user.overrides = next;
46
+ }
47
+ /** Test-only: replace the bound zone with a memory shim. */
48
+ export function __resetForTests() {
49
+ zoneState = {
50
+ ephemeral: {},
51
+ session: {},
52
+ workspace: {},
53
+ user: { overrides: {} },
54
+ };
55
+ }
56
+ // Initialise the memory shim at module load so tests can call set/get
57
+ // without first invoking activate. Production replaces this via
58
+ // __bindZone() inside appearanceShard.activate().
59
+ __resetForTests();
@@ -0,0 +1 @@
1
+ export {};