sh3-core 0.5.0 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/Shell.svelte CHANGED
@@ -160,7 +160,7 @@
160
160
  background: transparent;
161
161
  color: var(--shell-fg-muted);
162
162
  border: 1px solid var(--shell-border);
163
- border-radius: 4px;
163
+ border-radius: var(--shell-radius);
164
164
  cursor: pointer;
165
165
  }
166
166
  .shell-tabbar-home-button:hover {
@@ -180,6 +180,6 @@
180
180
  color: #fff;
181
181
  background: var(--shell-accent);
182
182
  padding: 1px 6px;
183
- border-radius: 8px;
183
+ border-radius: var(--shell-radius-lg);
184
184
  }
185
185
  </style>
package/dist/api.d.ts CHANGED
@@ -23,4 +23,5 @@ export declare const capabilities: {
23
23
  readonly hotInstall: boolean;
24
24
  };
25
25
  export type { ServerShard, ServerShardContext } from './server-shard/types';
26
+ export { VERSION } from './version';
26
27
  export { setTokenOverrides, clearTokenOverrides, getTokenOverrides, } from './theme';
package/dist/api.js CHANGED
@@ -44,5 +44,7 @@ export const capabilities = {
44
44
  /** Whether this target supports hot-installing packages via dynamic import from blob URL. */
45
45
  hotInstall: typeof Blob !== 'undefined' && typeof URL.createObjectURL === 'function',
46
46
  };
47
+ // Package version.
48
+ export { VERSION } from './version';
47
49
  // Theme token override API (shell-level theming support).
48
50
  export { setTokenOverrides, clearTokenOverrides, getTokenOverrides, } from './theme';
@@ -31,7 +31,12 @@ export declare function unloadApp(id: string): void;
31
31
  * refcount hold intact, and its view containers stay alive in the pool.
32
32
  * Launching the same app again is a root swap only.
33
33
  *
34
+ * Fires `suspend` hooks on all required shards (in order), then on the
35
+ * app itself. Any hook returning `false` cancels navigation — the user
36
+ * stays in the app. Returns `true` if navigation succeeded, `false` if
37
+ * cancelled.
38
+ *
34
39
  * Writes `null` to `__shell__:last-app` so reloading the page while on
35
40
  * home lands on home, not on the formerly-active app.
36
41
  */
37
- export declare function returnToHome(): void;
42
+ export declare function returnToHome(): Promise<boolean>;
@@ -12,7 +12,7 @@
12
12
  * return-to-home (null). Boot reads it to decide whether to auto-launch.
13
13
  */
14
14
  import { createStateZones } from '../state/zones.svelte';
15
- import { activateShard, deactivateShard, registeredShards, } from '../shards/activate.svelte';
15
+ import { activateShard, deactivateShard, getShardContext, registeredShards, } from '../shards/activate.svelte';
16
16
  import { attachApp, detachApp, switchToApp, switchToHome, } from '../layout/store.svelte';
17
17
  import { activeApp, getRegisteredApp } from './registry.svelte';
18
18
  import { createZoneManager } from '../state/manage';
@@ -69,7 +69,7 @@ function getOrCreateAppContext(appId) {
69
69
  * @throws If the app is not registered or a required shard is not registered.
70
70
  */
71
71
  export async function launchApp(id) {
72
- var _a;
72
+ var _a, _b, _c;
73
73
  const app = getRegisteredApp(id);
74
74
  if (!app) {
75
75
  throw new Error(`Cannot launch app "${id}": not registered`);
@@ -82,6 +82,14 @@ export async function launchApp(id) {
82
82
  unloadApp(activeApp.id);
83
83
  }
84
84
  else if (activeApp.id === id) {
85
+ // Re-entering the same app from Home — fire resume hooks.
86
+ for (const shardId of app.manifest.requiredShards) {
87
+ const shard = registeredShards.get(shardId);
88
+ const shardCtx = getShardContext(shardId);
89
+ if (shard && shardCtx)
90
+ void ((_a = shard.resume) === null || _a === void 0 ? void 0 : _a.call(shard, shardCtx));
91
+ }
92
+ void ((_b = app.resume) === null || _b === void 0 ? void 0 : _b.call(app, getOrCreateAppContext(id)));
85
93
  switchToApp();
86
94
  writeLastApp(id);
87
95
  return;
@@ -99,7 +107,7 @@ export async function launchApp(id) {
99
107
  // Attach the layout (creates the workspace-zone proxy with version
100
108
  // gate) and run the app's optional activate hook.
101
109
  attachApp(app);
102
- void ((_a = app.activate) === null || _a === void 0 ? void 0 : _a.call(app, getOrCreateAppContext(id)));
110
+ void ((_c = app.activate) === null || _c === void 0 ? void 0 : _c.call(app, getOrCreateAppContext(id)));
103
111
  activeApp.id = id;
104
112
  switchToApp();
105
113
  writeLastApp(id);
@@ -151,10 +159,26 @@ export function unloadApp(id) {
151
159
  * refcount hold intact, and its view containers stay alive in the pool.
152
160
  * Launching the same app again is a root swap only.
153
161
  *
162
+ * Fires `suspend` hooks on all required shards (in order), then on the
163
+ * app itself. Any hook returning `false` cancels navigation — the user
164
+ * stays in the app. Returns `true` if navigation succeeded, `false` if
165
+ * cancelled.
166
+ *
154
167
  * Writes `null` to `__shell__:last-app` so reloading the page while on
155
168
  * home lands on home, not on the formerly-active app.
156
169
  */
157
- export function returnToHome() {
170
+ export async function returnToHome() {
171
+ const app = activeApp.id ? getRegisteredApp(activeApp.id) : null;
172
+ if (app) {
173
+ for (const shardId of app.manifest.requiredShards) {
174
+ const shard = registeredShards.get(shardId);
175
+ if ((shard === null || shard === void 0 ? void 0 : shard.suspend) && (await shard.suspend()) === false)
176
+ return false;
177
+ }
178
+ if (app.suspend && (await app.suspend()) === false)
179
+ return false;
180
+ }
158
181
  switchToHome();
159
182
  writeLastApp(null);
183
+ return true;
160
184
  }
@@ -14,8 +14,11 @@ export declare const activeApp: {
14
14
  id: string | null;
15
15
  };
16
16
  /**
17
- * Register an app with the framework. Must be called before `launchApp`.
18
- * Throws if an app with the same id is already registered.
17
+ * Register (or re-register) an app with the framework.
18
+ *
19
+ * If an app with the same id already exists it is silently replaced,
20
+ * which is the expected path during package updates — the new bundle is
21
+ * loaded and re-registered without requiring a full page reload.
19
22
  *
20
23
  * @param app - The app module to register.
21
24
  */
@@ -21,17 +21,16 @@ export const registeredApps = $state(new Map());
21
21
  */
22
22
  export const activeApp = $state({ id: null });
23
23
  /**
24
- * Register an app with the framework. Must be called before `launchApp`.
25
- * Throws if an app with the same id is already registered.
24
+ * Register (or re-register) an app with the framework.
25
+ *
26
+ * If an app with the same id already exists it is silently replaced,
27
+ * which is the expected path during package updates — the new bundle is
28
+ * loaded and re-registered without requiring a full page reload.
26
29
  *
27
30
  * @param app - The app module to register.
28
31
  */
29
32
  export function registerApp(app) {
30
- const id = app.manifest.id;
31
- if (registeredApps.has(id)) {
32
- throw new Error(`App "${id}" is already registered`);
33
- }
34
- registeredApps.set(id, app);
33
+ registeredApps.set(app.manifest.id, app);
35
34
  }
36
35
  /**
37
36
  * Reactive snapshot of all registered app manifests. Shell home iterates
@@ -77,4 +77,17 @@ export interface App {
77
77
  activate?(ctx: AppContext): void | Promise<void>;
78
78
  /** Optional hook called before the app's shards are deactivated and the layout is detached. */
79
79
  deactivate?(): void | Promise<void>;
80
+ /**
81
+ * Called when the user navigates to Home while the app is active. The
82
+ * app's shards, state, and pooled views remain alive. Return `false`
83
+ * (sync or async) to cancel the navigation — useful for "unsaved
84
+ * changes" confirmation modals.
85
+ */
86
+ suspend?(): void | false | Promise<void | false>;
87
+ /**
88
+ * Called when the app is re-entered from Home (root swap back to app).
89
+ * Does NOT fire on first launch — that is `activate`. Receives the
90
+ * same `AppContext` that `activate` received.
91
+ */
92
+ resume?(ctx: AppContext): void | Promise<void>;
80
93
  }
@@ -11,6 +11,7 @@
11
11
  * Reads through the public sh3 API surface only.
12
12
  */
13
13
 
14
+ import { onMount } from 'svelte';
14
15
  import {
15
16
  listRegisteredApps,
16
17
  getActiveApp,
@@ -25,11 +26,30 @@
25
26
  const regShards = $derived(Array.from(registeredShards.values()));
26
27
  const actShards = $derived(Array.from(activeShards.keys()));
27
28
 
29
+ let serverVersion = $state<string | null>(null);
30
+
31
+ onMount(async () => {
32
+ try {
33
+ const res = await fetch('/api/version');
34
+ if (res.ok) {
35
+ const data = await res.json();
36
+ serverVersion = data.version;
37
+ }
38
+ } catch {
39
+ // Server unreachable — leave null.
40
+ }
41
+ });
42
+
28
43
  </script>
29
44
 
30
45
  <div class="diagnostic">
31
46
  <h2>Diagnostic</h2>
32
47
 
48
+ <section>
49
+ <h3>Server version</h3>
50
+ <p>{serverVersion ?? '—'}</p>
51
+ </section>
52
+
33
53
  <section>
34
54
  <h3>Active app</h3>
35
55
  <p>{active ? `${active.label} (${active.id})` : 'none'}</p>
@@ -73,7 +73,7 @@
73
73
  background: var(--shell-accent-muted);
74
74
  color: var(--shell-fg);
75
75
  border: 1px solid var(--shell-border-strong);
76
- border-radius: 3px;
76
+ border-radius: var(--shell-radius-sm);
77
77
  cursor: pointer;
78
78
  }
79
79
  button:hover { background: var(--shell-accent); }
@@ -49,7 +49,7 @@
49
49
  background: var(--shell-grad-bg-elevated, var(--shell-bg-elevated));
50
50
  color: var(--shell-fg);
51
51
  border: 1px solid var(--shell-accent);
52
- border-radius: 3px;
52
+ border-radius: var(--shell-radius-sm);
53
53
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
54
54
  font-size: 12px;
55
55
  font-family: var(--shell-font-ui);
@@ -76,7 +76,7 @@
76
76
  background: var(--shell-grad-bg-elevated, var(--shell-bg-elevated));
77
77
  color: var(--shell-fg);
78
78
  border: 1px solid var(--shell-border-strong);
79
- border-radius: 4px;
79
+ border-radius: var(--shell-radius);
80
80
  min-width: 320px;
81
81
  max-width: min(640px, 90vw);
82
82
  max-height: 90vh;
@@ -76,7 +76,7 @@
76
76
  background: var(--shell-grad-bg-elevated, var(--shell-bg-elevated));
77
77
  color: var(--shell-fg);
78
78
  border: 1px solid var(--shell-border-strong);
79
- border-radius: 3px;
79
+ border-radius: var(--shell-radius-sm);
80
80
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
81
81
  min-width: 120px;
82
82
  outline: none;
@@ -48,7 +48,7 @@
48
48
  color: var(--shell-fg);
49
49
  border: 1px solid var(--shell-border-strong);
50
50
  border-left-width: 3px;
51
- border-radius: 3px;
51
+ border-radius: var(--shell-radius-sm);
52
52
  box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
53
53
  font-size: 12px;
54
54
  min-width: 220px;
@@ -265,7 +265,7 @@
265
265
  font-size: 10px;
266
266
  line-height: 1;
267
267
  padding: 2px;
268
- border-radius: 3px;
268
+ border-radius: var(--shell-radius-sm);
269
269
  color: var(--shell-fg-muted);
270
270
  cursor: pointer;
271
271
  flex-shrink: 0;
@@ -97,8 +97,6 @@ export async function installPackage(bundle, meta) {
97
97
  registerApp(app);
98
98
  }
99
99
  catch (err) {
100
- // Registration failure (e.g. duplicate id) -- the package is persisted
101
- // but not usable until reload. Log and continue.
102
100
  console.warn(`[sh3] Package "${meta.id}" installed but registration failed (will retry on next boot):`, err instanceof Error ? err.message : err);
103
101
  hotLoaded = false;
104
102
  }
@@ -1,4 +1,4 @@
1
- import type { Shard } from './types';
1
+ import type { Shard, ShardContext } from './types';
2
2
  /**
3
3
  * Reactive registry of every shard known to the host. Keys are shard ids.
4
4
  * Populated once at boot by the glob-discovery loop in main.ts (through
@@ -10,12 +10,14 @@ import type { Shard } from './types';
10
10
  export declare const registeredShards: Map<string, Shard>;
11
11
  export declare const activeShards: Map<string, Shard>;
12
12
  /**
13
- * Register a shard with the framework so it can later be activated. Records
14
- * the shard in `registeredShards` but does not run `activate` — that happens
15
- * at app launch (or immediately for self-starting shards).
13
+ * Register (or re-register) a shard with the framework so it can later be
14
+ * activated. Records the shard in `registeredShards` but does not run
15
+ * `activate` — that happens at app launch (or for self-starting shards).
16
16
  *
17
- * @param shard - The shard module to register. `shard.manifest.id` must be unique.
18
- * @throws If a shard with the same id is already registered.
17
+ * If a shard with the same id already exists it is silently replaced,
18
+ * which is the expected path during package updates. If the old shard was
19
+ * active it is deactivated first so the new version can be cleanly
20
+ * activated on next launch.
19
21
  */
20
22
  export declare function registerShard(shard: Shard): void;
21
23
  /**
@@ -43,3 +45,8 @@ export declare function deactivateShard(id: string): void;
43
45
  * @param id - The `ShardManifest.id` to check.
44
46
  */
45
47
  export declare function isActive(id: string): boolean;
48
+ /**
49
+ * Return the ShardContext for an active shard, or undefined if not active.
50
+ * Used by lifecycle.ts to pass context to `shard.resume()`.
51
+ */
52
+ export declare function getShardContext(id: string): ShardContext | undefined;
@@ -39,17 +39,19 @@ export const registeredShards = $state(new Map());
39
39
  const active = new Map();
40
40
  export const activeShards = $state(new Map());
41
41
  /**
42
- * Register a shard with the framework so it can later be activated. Records
43
- * the shard in `registeredShards` but does not run `activate` — that happens
44
- * at app launch (or immediately for self-starting shards).
42
+ * Register (or re-register) a shard with the framework so it can later be
43
+ * activated. Records the shard in `registeredShards` but does not run
44
+ * `activate` — that happens at app launch (or for self-starting shards).
45
45
  *
46
- * @param shard - The shard module to register. `shard.manifest.id` must be unique.
47
- * @throws If a shard with the same id is already registered.
46
+ * If a shard with the same id already exists it is silently replaced,
47
+ * which is the expected path during package updates. If the old shard was
48
+ * active it is deactivated first so the new version can be cleanly
49
+ * activated on next launch.
48
50
  */
49
51
  export function registerShard(shard) {
50
52
  const id = shard.manifest.id;
51
- if (registeredShards.has(id)) {
52
- throw new Error(`Shard "${id}" is already registered`);
53
+ if (registeredShards.has(id) && activeShards.has(id)) {
54
+ deactivateShard(id);
53
55
  }
54
56
  registeredShards.set(id, shard);
55
57
  }
@@ -73,7 +75,7 @@ export async function activateShard(id) {
73
75
  // and is now being required by an app). Idempotent — no error.
74
76
  return;
75
77
  }
76
- const entry = { shard, viewIds: new Set(), cleanupFns: [] };
78
+ const entry = { shard, ctx: undefined, viewIds: new Set(), cleanupFns: [] };
77
79
  // envState holds the reactive env data for this shard.
78
80
  // Must be declared with $state at variable declaration time (Svelte 5 rule).
79
81
  const envState = $state({
@@ -122,6 +124,7 @@ export async function activateShard(id) {
122
124
  ? createZoneManager()
123
125
  : undefined,
124
126
  };
127
+ entry.ctx = ctx;
125
128
  active.set(id, entry);
126
129
  activeShards.set(id, shard);
127
130
  await shard.activate(ctx);
@@ -173,3 +176,11 @@ export function deactivateShard(id) {
173
176
  export function isActive(id) {
174
177
  return active.has(id);
175
178
  }
179
+ /**
180
+ * Return the ShardContext for an active shard, or undefined if not active.
181
+ * Used by lifecycle.ts to pass context to `shard.resume()`.
182
+ */
183
+ export function getShardContext(id) {
184
+ var _a;
185
+ return (_a = active.get(id)) === null || _a === void 0 ? void 0 : _a.ctx;
186
+ }
@@ -193,4 +193,15 @@ export interface Shard {
193
193
  autostart?(ctx: ShardContext): void | Promise<void>;
194
194
  /** Optional cleanup hook called when the shard is deactivated. Release timers, subscriptions, and external resources here. */
195
195
  deactivate?(): void | Promise<void>;
196
+ /**
197
+ * Called when the owning app is suspended (Home button). The shard
198
+ * remains active; its views and state are preserved. Return `false`
199
+ * (sync or async) to cancel the navigation.
200
+ */
201
+ suspend?(): void | false | Promise<void | false>;
202
+ /**
203
+ * Called when the owning app resumes from Home. Receives the same
204
+ * `ShardContext` that `activate` received.
205
+ */
206
+ resume?(ctx: ShardContext): void | Promise<void>;
196
207
  }
@@ -6,7 +6,7 @@
6
6
  * 3. Elevate prompt — shown when not elevated
7
7
  */
8
8
 
9
- import { listRegisteredApps, launchApp, isAdmin } from '../api';
9
+ import { listRegisteredApps, launchApp, isAdmin, VERSION } from '../api';
10
10
  import { elevate, deescalate } from '../auth/index';
11
11
 
12
12
  const apps = $derived(listRegisteredApps());
@@ -38,6 +38,7 @@
38
38
  <div class="shell-home">
39
39
  <header class="shell-home-header">
40
40
  <h1>SH3</h1>
41
+ <span class="shell-home-version">v{VERSION}</span>
41
42
  <span class="shell-home-alpha">alpha</span>
42
43
  {#if elevated}
43
44
  <button type="button" class="shell-home-deescalate" onclick={handleDeescalate}>
@@ -153,6 +154,11 @@
153
154
  color: var(--shell-accent);
154
155
  letter-spacing: 2px;
155
156
  }
157
+ .shell-home-version {
158
+ font-size: 13px;
159
+ color: var(--shell-fg-subtle);
160
+ letter-spacing: 0.04em;
161
+ }
156
162
  .shell-home-alpha {
157
163
  font-size: 11px;
158
164
  font-weight: 700;
@@ -197,7 +203,7 @@
197
203
  padding: 14px 18px;
198
204
  background: var(--shell-grad-bg-elevated, var(--shell-bg-elevated));
199
205
  border: 1px solid var(--shell-border);
200
- border-radius: 6px;
206
+ border-radius: var(--shell-radius-md);
201
207
  }
202
208
  .shell-home-entry-label {
203
209
  grid-column: 1;
@@ -217,7 +223,7 @@
217
223
  background: var(--shell-accent);
218
224
  color: var(--shell-bg);
219
225
  border: none;
220
- border-radius: 4px;
226
+ border-radius: var(--shell-radius);
221
227
  font-weight: 600;
222
228
  cursor: pointer;
223
229
  }
@@ -229,7 +235,7 @@
229
235
  background: transparent;
230
236
  color: var(--shell-fg-subtle);
231
237
  border: 1px solid var(--shell-border);
232
- border-radius: 4px;
238
+ border-radius: var(--shell-radius);
233
239
  cursor: pointer;
234
240
  font-size: 12px;
235
241
  }
@@ -252,7 +258,7 @@
252
258
  background: var(--shell-grad-bg-elevated, var(--shell-bg-elevated));
253
259
  color: var(--shell-fg);
254
260
  border: 1px solid var(--shell-border);
255
- border-radius: 4px;
261
+ border-radius: var(--shell-radius);
256
262
  font-family: monospace;
257
263
  font-size: 13px;
258
264
  }
@@ -264,7 +270,7 @@
264
270
  background: var(--shell-accent);
265
271
  color: var(--shell-bg);
266
272
  border: none;
267
- border-radius: 4px;
273
+ border-radius: var(--shell-radius);
268
274
  font-weight: 600;
269
275
  cursor: pointer;
270
276
  white-space: nowrap;
@@ -279,6 +285,6 @@
279
285
  font-size: 12px;
280
286
  color: var(--shell-error, #d32f2f);
281
287
  background: color-mix(in srgb, var(--shell-error, #d32f2f) 10%, transparent);
282
- border-radius: 4px;
288
+ border-radius: var(--shell-radius);
283
289
  }
284
290
  </style>
@@ -14,6 +14,8 @@
14
14
  const ctx = storeContext;
15
15
 
16
16
  let uninstallingIds = $state<Set<string>>(new Set());
17
+ let updatingIds = $state<Set<string>>(new Set());
18
+ let updateError = $state<string | null>(null);
17
19
 
18
20
  async function handleUninstall(id: string) {
19
21
  if (uninstallingIds.has(id)) return;
@@ -32,6 +34,23 @@
32
34
  }
33
35
  }
34
36
 
37
+ async function handleUpdate(id: string) {
38
+ if (updatingIds.has(id)) return;
39
+
40
+ updatingIds = new Set([...updatingIds, id]);
41
+ updateError = null;
42
+
43
+ try {
44
+ await ctx.updatePackage(id);
45
+ } catch (err) {
46
+ updateError = err instanceof Error ? err.message : String(err);
47
+ } finally {
48
+ const next = new Set(updatingIds);
49
+ next.delete(id);
50
+ updatingIds = next;
51
+ }
52
+ }
53
+
35
54
  function handleRefresh() {
36
55
  ctx.refreshInstalled();
37
56
  }
@@ -55,6 +74,10 @@
55
74
  <button class="installed-refresh" onclick={handleRefresh}>Refresh</button>
56
75
  </header>
57
76
 
77
+ {#if updateError}
78
+ <div class="installed-error">{updateError}</div>
79
+ {/if}
80
+
58
81
  {#if ctx.state.ephemeral.installed.length === 0}
59
82
  <div class="installed-empty">No packages installed.</div>
60
83
  {:else}
@@ -75,6 +98,17 @@
75
98
  <span>Installed: {formatDate(pkg.installedAt)}</span>
76
99
  </div>
77
100
  <div class="installed-item-actions">
101
+ {#if pkg.id in ctx.state.ephemeral.updatable}
102
+ {@const target = ctx.state.ephemeral.updatable[pkg.id]}
103
+ {@const updating = updatingIds.has(pkg.id)}
104
+ <button
105
+ class="installed-update-btn"
106
+ onclick={() => handleUpdate(pkg.id)}
107
+ disabled={updating || uninstalling}
108
+ >
109
+ {updating ? 'Updating...' : `Update -> ${target.latest.version}`}
110
+ </button>
111
+ {/if}
78
112
  <button
79
113
  class="installed-uninstall-btn"
80
114
  onclick={() => handleUninstall(pkg.id)}
@@ -91,7 +125,7 @@
91
125
 
92
126
  <style>
93
127
  .installed-view {
94
- font-family: var(--shell-font, system-ui, sans-serif);
128
+ font-family: var(--shell-font-ui);
95
129
  color: var(--shell-fg, #e0e0e0);
96
130
  background: var(--shell-bg, #1e1e1e);
97
131
  padding: 16px;
@@ -115,7 +149,7 @@
115
149
  background: var(--shell-accent, #007acc);
116
150
  color: #fff;
117
151
  border: none;
118
- border-radius: 4px;
152
+ border-radius: var(--shell-radius);
119
153
  cursor: pointer;
120
154
  font-family: inherit;
121
155
  font-size: 0.875rem;
@@ -137,7 +171,7 @@
137
171
  .installed-item {
138
172
  background: var(--shell-input-bg, #2a2a2a);
139
173
  border: 1px solid var(--shell-border, #444);
140
- border-radius: 6px;
174
+ border-radius: var(--shell-radius-md);
141
175
  padding: 12px 14px;
142
176
  display: flex;
143
177
  flex-direction: column;
@@ -155,7 +189,7 @@
155
189
  .installed-item-badge {
156
190
  font-size: 0.6875rem;
157
191
  padding: 1px 6px;
158
- border-radius: 3px;
192
+ border-radius: var(--shell-radius-sm);
159
193
  text-transform: uppercase;
160
194
  font-weight: 600;
161
195
  letter-spacing: 0.04em;
@@ -182,13 +216,14 @@
182
216
  .installed-item-actions {
183
217
  display: flex;
184
218
  justify-content: flex-end;
219
+ gap: 8px;
185
220
  }
186
221
  .installed-uninstall-btn {
187
222
  padding: 4px 12px;
188
223
  background: transparent;
189
224
  color: var(--shell-error, #d32f2f);
190
225
  border: 1px solid var(--shell-error, #d32f2f);
191
- border-radius: 4px;
226
+ border-radius: var(--shell-radius);
192
227
  cursor: pointer;
193
228
  font-family: inherit;
194
229
  font-size: 0.8125rem;
@@ -200,4 +235,30 @@
200
235
  opacity: 0.6;
201
236
  cursor: not-allowed;
202
237
  }
238
+ .installed-update-btn {
239
+ padding: 4px 12px;
240
+ background: var(--shell-warning, #ff9800);
241
+ color: #fff;
242
+ border: none;
243
+ border-radius: var(--shell-radius);
244
+ cursor: pointer;
245
+ font-family: inherit;
246
+ font-size: 0.8125rem;
247
+ }
248
+ .installed-update-btn:hover:not(:disabled) {
249
+ filter: brightness(1.1);
250
+ }
251
+ .installed-update-btn:disabled {
252
+ opacity: 0.6;
253
+ cursor: not-allowed;
254
+ }
255
+ .installed-error {
256
+ padding: 8px 12px;
257
+ margin-bottom: 12px;
258
+ background: color-mix(in srgb, var(--shell-error, #d32f2f) 15%, transparent);
259
+ color: var(--shell-error, #d32f2f);
260
+ border: 1px solid var(--shell-error, #d32f2f);
261
+ border-radius: var(--shell-radius);
262
+ font-size: 0.8125rem;
263
+ }
203
264
  </style>
@@ -17,6 +17,7 @@
17
17
  let search = $state('');
18
18
  let typeFilter = $state<'all' | 'shard' | 'app'>('all');
19
19
  let installingIds = $state<Set<string>>(new Set());
20
+ let updatingIds = $state<Set<string>>(new Set());
20
21
  let installError = $state<string | null>(null);
21
22
  let newRegistryUrl = $state('');
22
23
 
@@ -66,6 +67,31 @@
66
67
  return String(pkg.latest.contractVersion) !== String(contract.version);
67
68
  }
68
69
 
70
+ function hasUpdate(id: string): boolean {
71
+ return id in ctx.state.ephemeral.updatable;
72
+ }
73
+
74
+ function installedVersion(id: string): string {
75
+ return ctx.state.ephemeral.installed.find((p: InstalledPackage) => p.id === id)?.version ?? '';
76
+ }
77
+
78
+ async function handleUpdate(id: string) {
79
+ if (updatingIds.has(id)) return;
80
+
81
+ updatingIds = new Set([...updatingIds, id]);
82
+ installError = null;
83
+
84
+ try {
85
+ await ctx.updatePackage(id);
86
+ } catch (err) {
87
+ installError = err instanceof Error ? err.message : String(err);
88
+ } finally {
89
+ const next = new Set(updatingIds);
90
+ next.delete(id);
91
+ updatingIds = next;
92
+ }
93
+ }
94
+
69
95
  async function handleInstall(pkg: ResolvedPackage) {
70
96
  const id = pkg.entry.id;
71
97
  if (installingIds.has(id)) return;
@@ -181,6 +207,8 @@
181
207
  {@const installed = isInstalled(pkg.entry.id)}
182
208
  {@const mismatch = hasContractMismatch(pkg)}
183
209
  {@const installing = installingIds.has(pkg.entry.id)}
210
+ {@const updatable = hasUpdate(pkg.entry.id)}
211
+ {@const updating = updatingIds.has(pkg.entry.id)}
184
212
  <div class="store-card">
185
213
  <div class="store-card-header">
186
214
  <div class="store-card-icon">
@@ -208,7 +236,15 @@
208
236
  </div>
209
237
  {/if}
210
238
  <div class="store-card-actions">
211
- {#if installed}
239
+ {#if installed && updatable}
240
+ <button
241
+ class="store-update-btn"
242
+ onclick={() => handleUpdate(pkg.entry.id)}
243
+ disabled={updating}
244
+ >
245
+ {updating ? 'Updating...' : `Update ${installedVersion(pkg.entry.id)} -> ${pkg.latest.version}`}
246
+ </button>
247
+ {:else if installed}
212
248
  <span class="store-installed-label">Installed</span>
213
249
  {:else}
214
250
  <button
@@ -237,7 +273,7 @@
237
273
 
238
274
  <style>
239
275
  .store-view {
240
- font-family: var(--shell-font, system-ui, sans-serif);
276
+ font-family: var(--shell-font-ui);
241
277
  color: var(--shell-fg, #e0e0e0);
242
278
  background: var(--shell-bg, #1e1e1e);
243
279
  padding: 16px;
@@ -265,7 +301,7 @@
265
301
  background: var(--shell-input-bg, #2a2a2a);
266
302
  color: var(--shell-fg, #e0e0e0);
267
303
  border: 1px solid var(--shell-border, #444);
268
- border-radius: 4px;
304
+ border-radius: var(--shell-radius);
269
305
  font-family: inherit;
270
306
  font-size: 0.875rem;
271
307
  }
@@ -277,7 +313,7 @@
277
313
  background: var(--shell-input-bg, #2a2a2a);
278
314
  color: var(--shell-fg, #e0e0e0);
279
315
  border: 1px solid var(--shell-border, #444);
280
- border-radius: 4px;
316
+ border-radius: var(--shell-radius);
281
317
  font-family: inherit;
282
318
  font-size: 0.875rem;
283
319
  }
@@ -286,7 +322,7 @@
286
322
  background: var(--shell-accent, #007acc);
287
323
  color: #fff;
288
324
  border: none;
289
- border-radius: 4px;
325
+ border-radius: var(--shell-radius);
290
326
  cursor: pointer;
291
327
  font-family: inherit;
292
328
  font-size: 0.875rem;
@@ -301,7 +337,7 @@
301
337
  background: color-mix(in srgb, var(--shell-error, #d32f2f) 15%, transparent);
302
338
  color: var(--shell-error, #d32f2f);
303
339
  border: 1px solid var(--shell-error, #d32f2f);
304
- border-radius: 4px;
340
+ border-radius: var(--shell-radius);
305
341
  font-size: 0.8125rem;
306
342
  }
307
343
  .store-grid {
@@ -312,7 +348,7 @@
312
348
  .store-card {
313
349
  background: var(--shell-input-bg, #2a2a2a);
314
350
  border: 1px solid var(--shell-border, #444);
315
- border-radius: 6px;
351
+ border-radius: var(--shell-radius-md);
316
352
  padding: 14px;
317
353
  display: flex;
318
354
  flex-direction: column;
@@ -337,7 +373,7 @@
337
373
  .store-icon-img {
338
374
  width: 36px;
339
375
  height: 36px;
340
- border-radius: 4px;
376
+ border-radius: var(--shell-radius);
341
377
  object-fit: cover;
342
378
  }
343
379
  .store-icon-placeholder {
@@ -348,7 +384,7 @@
348
384
  justify-content: center;
349
385
  background: var(--shell-accent, #007acc);
350
386
  color: #fff;
351
- border-radius: 4px;
387
+ border-radius: var(--shell-radius);
352
388
  font-weight: 700;
353
389
  font-size: 1rem;
354
390
  }
@@ -365,7 +401,7 @@
365
401
  .store-card-badge {
366
402
  font-size: 0.6875rem;
367
403
  padding: 1px 6px;
368
- border-radius: 3px;
404
+ border-radius: var(--shell-radius-sm);
369
405
  text-transform: uppercase;
370
406
  font-weight: 600;
371
407
  letter-spacing: 0.04em;
@@ -397,7 +433,7 @@
397
433
  color: var(--shell-warning, #ff9800);
398
434
  padding: 4px 8px;
399
435
  background: color-mix(in srgb, var(--shell-warning, #ff9800) 10%, transparent);
400
- border-radius: 3px;
436
+ border-radius: var(--shell-radius-sm);
401
437
  }
402
438
  .store-card-actions {
403
439
  margin-top: auto;
@@ -409,7 +445,7 @@
409
445
  background: var(--shell-accent, #007acc);
410
446
  color: #fff;
411
447
  border: none;
412
- border-radius: 4px;
448
+ border-radius: var(--shell-radius);
413
449
  cursor: pointer;
414
450
  font-family: inherit;
415
451
  font-size: 0.8125rem;
@@ -423,6 +459,23 @@
423
459
  color: var(--shell-success, #4caf50);
424
460
  font-weight: 600;
425
461
  }
462
+ .store-update-btn {
463
+ padding: 5px 14px;
464
+ background: var(--shell-warning, #ff9800);
465
+ color: #fff;
466
+ border: none;
467
+ border-radius: var(--shell-radius);
468
+ cursor: pointer;
469
+ font-family: inherit;
470
+ font-size: 0.8125rem;
471
+ }
472
+ .store-update-btn:hover:not(:disabled) {
473
+ filter: brightness(1.1);
474
+ }
475
+ .store-update-btn:disabled {
476
+ opacity: 0.6;
477
+ cursor: not-allowed;
478
+ }
426
479
  .store-empty {
427
480
  text-align: center;
428
481
  padding: 32px 16px;
@@ -442,7 +495,7 @@
442
495
  padding: 4px 8px;
443
496
  background: var(--shell-input-bg, #2a2a2a);
444
497
  border: 1px solid var(--shell-border, #444);
445
- border-radius: 4px;
498
+ border-radius: var(--shell-radius);
446
499
  font-size: 0.8125rem;
447
500
  }
448
501
  .store-registry-url {
@@ -456,7 +509,7 @@
456
509
  background: transparent;
457
510
  color: var(--shell-error, #d32f2f);
458
511
  border: 1px solid var(--shell-error, #d32f2f);
459
- border-radius: 3px;
512
+ border-radius: var(--shell-radius-sm);
460
513
  cursor: pointer;
461
514
  font-size: 0.75rem;
462
515
  flex-shrink: 0;
@@ -473,7 +526,7 @@
473
526
  background: var(--shell-input-bg, #2a2a2a);
474
527
  color: var(--shell-fg, #e0e0e0);
475
528
  border: 1px solid var(--shell-border, #444);
476
- border-radius: 4px;
529
+ border-radius: var(--shell-radius);
477
530
  font-family: inherit;
478
531
  font-size: 0.8125rem;
479
532
  }
@@ -485,7 +538,7 @@
485
538
  background: var(--shell-accent, #007acc);
486
539
  color: #fff;
487
540
  border: none;
488
- border-radius: 4px;
541
+ border-radius: var(--shell-radius);
489
542
  cursor: pointer;
490
543
  font-family: inherit;
491
544
  font-size: 0.8125rem;
@@ -10,7 +10,7 @@ export const storeApp = {
10
10
  manifest: {
11
11
  id: 'sh3-store-app',
12
12
  label: 'Package Store',
13
- version: '0.1.0',
13
+ version: '0.2.1',
14
14
  requiredShards: ['sh3-store'],
15
15
  layoutVersion: 1,
16
16
  admin: true,
@@ -13,6 +13,7 @@ interface StoreZoneSchema {
13
13
  ephemeral: {
14
14
  catalog: ResolvedPackage[];
15
15
  installed: InstalledPackage[];
16
+ updatable: Record<string, ResolvedPackage>;
16
17
  loading: boolean;
17
18
  error: string | null;
18
19
  };
@@ -24,6 +25,7 @@ export interface StoreContext {
24
25
  isAdmin: boolean;
25
26
  refreshCatalog(): Promise<void>;
26
27
  refreshInstalled(): Promise<void>;
28
+ updatePackage(id: string): Promise<void>;
27
29
  addRegistry(url: string): Promise<void>;
28
30
  removeRegistry(url: string): Promise<void>;
29
31
  }
@@ -14,9 +14,31 @@
14
14
  import { mount, unmount } from 'svelte';
15
15
  import StoreView from './StoreView.svelte';
16
16
  import InstalledView from './InstalledView.svelte';
17
- import { fetchRegistries } from '../registry/client';
18
- import { listInstalledPackages } from '../registry/installer';
19
- import { fetchServerPackages } from '../env/client';
17
+ import { fetchRegistries, fetchBundle, buildPackageMeta } from '../registry/client';
18
+ import { installPackage, listInstalledPackages } from '../registry/installer';
19
+ import { loadBundle, savePackage } from '../registry/storage';
20
+ import { serverInstallPackage, fetchServerPackages } from '../env/client';
21
+ /**
22
+ * Compare two semver-like version strings.
23
+ * Returns true only if `available` is strictly greater than `installed`.
24
+ * Compares major.minor.patch left-to-right as integers.
25
+ * Non-numeric segments are treated as 0.
26
+ */
27
+ function isNewerVersion(available, installed) {
28
+ var _a, _b;
29
+ const a = available.split('.').map((s) => parseInt(s, 10) || 0);
30
+ const b = installed.split('.').map((s) => parseInt(s, 10) || 0);
31
+ const len = Math.max(a.length, b.length);
32
+ for (let i = 0; i < len; i++) {
33
+ const av = (_a = a[i]) !== null && _a !== void 0 ? _a : 0;
34
+ const bv = (_b = b[i]) !== null && _b !== void 0 ? _b : 0;
35
+ if (av > bv)
36
+ return true;
37
+ if (av < bv)
38
+ return false;
39
+ }
40
+ return false;
41
+ }
20
42
  /**
21
43
  * Module-level context set during activate(). Imported by the Svelte
22
44
  * view components so they can read/write store state and trigger refreshes.
@@ -26,7 +48,7 @@ export const storeShard = {
26
48
  manifest: {
27
49
  id: 'sh3-store',
28
50
  label: 'Package Store',
29
- version: '0.1.0',
51
+ version: '0.2.1',
30
52
  views: [
31
53
  { id: 'sh3-store:browse', label: 'Store' },
32
54
  { id: 'sh3-store:installed', label: 'Installed' },
@@ -38,16 +60,28 @@ export const storeShard = {
38
60
  ephemeral: {
39
61
  catalog: [],
40
62
  installed: [],
63
+ updatable: {},
41
64
  loading: false,
42
65
  error: null,
43
66
  },
44
67
  });
68
+ function recomputeUpdatable() {
69
+ const result = {};
70
+ for (const pkg of state.ephemeral.installed) {
71
+ const catalogEntry = state.ephemeral.catalog.find((c) => c.entry.id === pkg.id);
72
+ if (catalogEntry && isNewerVersion(catalogEntry.latest.version, pkg.version)) {
73
+ result[pkg.id] = catalogEntry;
74
+ }
75
+ }
76
+ state.ephemeral.updatable = result;
77
+ }
45
78
  async function refreshCatalog() {
46
79
  state.ephemeral.loading = true;
47
80
  state.ephemeral.error = null;
48
81
  try {
49
82
  const results = await fetchRegistries(env.registries);
50
83
  state.ephemeral.catalog = results;
84
+ recomputeUpdatable();
51
85
  }
52
86
  catch (err) {
53
87
  state.ephemeral.error =
@@ -71,6 +105,7 @@ export const storeShard = {
71
105
  installedAt: (_c = p.installedAt) !== null && _c !== void 0 ? _c : '',
72
106
  });
73
107
  });
108
+ recomputeUpdatable();
74
109
  }
75
110
  catch (err) {
76
111
  console.warn('[sh3-store] Failed to list installed packages:', err instanceof Error ? err.message : err);
@@ -78,6 +113,7 @@ export const storeShard = {
78
113
  try {
79
114
  const packages = await listInstalledPackages();
80
115
  state.ephemeral.installed = packages;
116
+ recomputeUpdatable();
81
117
  }
82
118
  catch (_a) {
83
119
  // Nothing to show.
@@ -95,12 +131,57 @@ export const storeShard = {
95
131
  const registries = env.registries.filter((r) => r !== url);
96
132
  await ctx.envUpdate({ registries });
97
133
  }
134
+ async function updatePackage(id) {
135
+ var _a, _b;
136
+ const catalogEntry = state.ephemeral.updatable[id];
137
+ if (!catalogEntry)
138
+ return;
139
+ const installedRecord = state.ephemeral.installed.find((p) => p.id === id);
140
+ if (!installedRecord)
141
+ return;
142
+ // 1. Fetch new bundle.
143
+ const bundle = await fetchBundle(catalogEntry.latest, catalogEntry.sourceRegistry);
144
+ const meta = buildPackageMeta(catalogEntry, catalogEntry.latest);
145
+ // 2. Snapshot current state for rollback.
146
+ const oldBundle = await loadBundle(id);
147
+ const oldRecord = Object.assign({}, installedRecord);
148
+ // 3. Push to server.
149
+ const manifest = {
150
+ id: meta.id,
151
+ type: meta.type,
152
+ label: catalogEntry.entry.label,
153
+ version: meta.version,
154
+ contractVersion: meta.contractVersion,
155
+ sourceRegistry: meta.sourceRegistry,
156
+ installedAt: new Date().toISOString(),
157
+ };
158
+ const serverResult = await serverInstallPackage(manifest, bundle);
159
+ if (!serverResult.ok) {
160
+ throw new Error((_a = serverResult.error) !== null && _a !== void 0 ? _a : 'Server update failed');
161
+ }
162
+ // 4. Install locally (overwrites IndexedDB + re-registers).
163
+ const result = await installPackage(bundle, meta);
164
+ if (!result.success) {
165
+ // Rollback: restore old bundle and metadata.
166
+ if (oldBundle) {
167
+ try {
168
+ await savePackage(id, oldBundle, oldRecord);
169
+ }
170
+ catch (rollbackErr) {
171
+ console.warn(`[sh3-store] Rollback failed for "${id}":`, rollbackErr instanceof Error ? rollbackErr.message : rollbackErr);
172
+ }
173
+ }
174
+ throw new Error((_b = result.error) !== null && _b !== void 0 ? _b : 'Local install failed during update');
175
+ }
176
+ await refreshInstalled();
177
+ }
98
178
  storeContext = {
99
179
  env,
100
180
  state,
101
181
  get isAdmin() { return ctx.isAdmin; },
102
182
  refreshCatalog,
103
183
  refreshInstalled,
184
+ updatePackage,
104
185
  addRegistry,
105
186
  removeRegistry,
106
187
  };
@@ -127,10 +208,11 @@ export const storeShard = {
127
208
  };
128
209
  ctx.registerView('sh3-store:browse', browseFactory);
129
210
  ctx.registerView('sh3-store:installed', installedFactory);
211
+ // refreshInstalled can run immediately (hits server, no env needed).
212
+ refreshInstalled();
130
213
  },
131
214
  autostart() {
132
- // Intentionally empty. Defining autostart puts the store shard on
133
- // the self-starting path at boot so its views are available from
134
- // the launcher without requiring an app to declare it.
215
+ // Runs after env hydration, so registries are populated.
216
+ storeContext.refreshCatalog();
135
217
  },
136
218
  };
package/dist/tokens.css CHANGED
@@ -32,12 +32,26 @@
32
32
  --shell-accent: #6ea8fe;
33
33
  --shell-accent-muted: #3a5580;
34
34
 
35
+ /* Inputs */
36
+ --shell-input-bg: #2a2a2a;
37
+
38
+ /* Semantic */
39
+ --shell-error: #f87171;
40
+ --shell-warning: #fbbf24;
41
+ --shell-success: #34d399;
42
+
35
43
  /* Typography */
36
44
  --shell-font-ui: system-ui, -apple-system, "Segoe UI", sans-serif;
37
45
  --shell-font-mono: ui-monospace, "Cascadia Code", "Consolas", monospace;
38
46
  --shell-font-size: 13px;
39
47
  --shell-line: 1.45;
40
48
 
49
+ /* Radius */
50
+ --shell-radius-sm: 3px;
51
+ --shell-radius: 4px;
52
+ --shell-radius-md: 6px;
53
+ --shell-radius-lg: 8px;
54
+
41
55
  /* Spacing */
42
56
  --shell-pad-xs: 2px;
43
57
  --shell-pad-sm: 4px;
@@ -0,0 +1,2 @@
1
+ /** sh3-core package version. Keep in sync with package.json. */
2
+ export declare const VERSION = "0.5.4";
@@ -0,0 +1,2 @@
1
+ /** sh3-core package version. Keep in sync with package.json. */
2
+ export const VERSION = '0.5.4';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh3-core",
3
- "version": "0.5.0",
3
+ "version": "0.5.4",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"