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.
- 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/InstalledView.svelte +8 -54
- 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 +28 -0
- package/dist/app/store/storeShard.svelte.d.ts +8 -1
- package/dist/app/store/storeShard.svelte.js +42 -9
- 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 +56 -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/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
|
@@ -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
|
-
|
|
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(
|
|
187
|
+
const bundle = await fetchBundle(picked, catalogEntry.sourceRegistry);
|
|
162
188
|
let serverBundle;
|
|
163
|
-
if (
|
|
164
|
-
serverBundle = await fetchServerBundle(
|
|
189
|
+
if (picked.serverBundleUrl) {
|
|
190
|
+
serverBundle = await fetchServerBundle(picked, catalogEntry.sourceRegistry);
|
|
165
191
|
}
|
|
166
|
-
const meta = buildPackageMeta(catalogEntry,
|
|
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 =
|
|
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
|
+
});
|
package/dist/app/store/verbs.js
CHANGED
|
@@ -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
|
|
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
|
|
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,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 {};
|