sh3-core 0.8.0 → 0.8.2

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 (78) hide show
  1. package/dist/Shell.svelte +19 -0
  2. package/dist/api.d.ts +5 -1
  3. package/dist/api.js +6 -1
  4. package/dist/app/admin/ApiKeysView.svelte +16 -27
  5. package/dist/app/admin/SystemView.svelte +149 -11
  6. package/dist/documents/backends.d.ts +8 -0
  7. package/dist/documents/backends.js +87 -0
  8. package/dist/documents/backends.test.d.ts +1 -0
  9. package/dist/documents/backends.test.js +33 -0
  10. package/dist/documents/browse.d.ts +12 -0
  11. package/dist/documents/browse.js +19 -0
  12. package/dist/documents/browse.test.d.ts +1 -0
  13. package/dist/documents/browse.test.js +41 -0
  14. package/dist/documents/http-backend.d.ts +4 -0
  15. package/dist/documents/http-backend.js +14 -0
  16. package/dist/documents/sync/index.d.ts +1 -2
  17. package/dist/documents/sync/index.js +0 -2
  18. package/dist/documents/sync/observer.d.ts +3 -0
  19. package/dist/documents/sync/observer.js +45 -0
  20. package/dist/documents/sync/registry.d.ts +3 -0
  21. package/dist/documents/sync/registry.js +8 -1
  22. package/dist/documents/sync/registry.test.js +11 -0
  23. package/dist/documents/types.d.ts +18 -0
  24. package/dist/documents/types.js +6 -1
  25. package/dist/keys/ConsentDialog.svelte +176 -0
  26. package/dist/keys/ConsentDialog.svelte.d.ts +3 -0
  27. package/dist/keys/client.d.ts +13 -0
  28. package/dist/keys/client.js +65 -0
  29. package/dist/keys/client.test.d.ts +1 -0
  30. package/dist/keys/client.test.js +44 -0
  31. package/dist/keys/consent.svelte.d.ts +16 -0
  32. package/dist/keys/consent.svelte.js +29 -0
  33. package/dist/keys/consent.test.d.ts +1 -0
  34. package/dist/keys/consent.test.js +53 -0
  35. package/dist/keys/revocation-bus.svelte.d.ts +35 -0
  36. package/dist/keys/revocation-bus.svelte.js +92 -0
  37. package/dist/keys/revocation-bus.test.d.ts +1 -0
  38. package/dist/keys/revocation-bus.test.js +95 -0
  39. package/dist/keys/types.d.ts +32 -0
  40. package/dist/keys/types.js +13 -0
  41. package/dist/layout/inspection.d.ts +17 -0
  42. package/dist/layout/inspection.js +53 -0
  43. package/dist/server-shard/types.d.ts +21 -2
  44. package/dist/server-sync.d.ts +6 -0
  45. package/dist/server-sync.js +634 -0
  46. package/dist/server-sync.js.map +7 -0
  47. package/dist/sh3core-shard/ShellHome.svelte +140 -63
  48. package/dist/sh3core-shard/sh3coreShard.svelte.js +12 -1
  49. package/dist/shards/activate-browse.test.d.ts +1 -0
  50. package/dist/shards/activate-browse.test.js +36 -0
  51. package/dist/shards/activate-on-key-revoked.test.d.ts +1 -0
  52. package/dist/shards/activate-on-key-revoked.test.js +60 -0
  53. package/dist/shards/activate-sync-registry.test.d.ts +1 -0
  54. package/dist/shards/activate-sync-registry.test.js +42 -0
  55. package/dist/shards/activate-tenantid.test.d.ts +1 -0
  56. package/dist/shards/activate-tenantid.test.js +21 -0
  57. package/dist/shards/activate.svelte.d.ts +12 -0
  58. package/dist/shards/activate.svelte.js +55 -3
  59. package/dist/shards/types.d.ts +42 -0
  60. package/dist/shards/types.js +1 -1
  61. package/dist/shell/views/KeysAndPeers.svelte +110 -0
  62. package/dist/shell/views/KeysAndPeers.svelte.d.ts +3 -0
  63. package/dist/shell-shard/Terminal.svelte +0 -11
  64. package/dist/shell-shard/manifest.js +1 -1
  65. package/dist/shell-shard/shellShard.svelte.js +52 -4
  66. package/dist/shell-shard/toolbar/Toolbar.svelte +11 -32
  67. package/dist/shell-shard/toolbar/Toolbar.svelte.d.ts +0 -2
  68. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +29 -62
  69. package/dist/shell-shard/verbs/index.js +3 -1
  70. package/dist/shell-shard/verbs/views.d.ts +2 -0
  71. package/dist/shell-shard/verbs/views.js +103 -2
  72. package/dist/testing.d.ts +3 -0
  73. package/dist/testing.js +77 -0
  74. package/dist/testing.js.map +7 -0
  75. package/dist/verbs/types.d.ts +19 -0
  76. package/dist/version.d.ts +1 -1
  77. package/dist/version.js +1 -1
  78. package/package.json +10 -2
@@ -24,8 +24,14 @@ import { isAdmin as checkIsAdmin } from '../auth/index';
24
24
  import { createZoneManager } from '../state/manage';
25
25
  import { PERMISSION_STATE_MANAGE } from '../state/types';
26
26
  import { PERMISSION_DOCUMENTS_SYNC } from '../documents/sync/types';
27
+ import { PERMISSION_DOCUMENTS_BROWSE } from '../documents/types';
28
+ import { createBrowseCapability } from '../documents/browse';
27
29
  import { getSyncBundle } from '../documents/sync/singleton';
28
30
  import { createSyncHandle } from '../documents/sync/handle';
31
+ import { createSyncRegistryAccessor } from '../documents/sync/observer';
32
+ import { createShardKeysApi } from '../keys/client';
33
+ import { PERMISSION_KEYS_MINT } from '../keys/types';
34
+ import { subscribe } from '../keys/revocation-bus.svelte';
29
35
  /**
30
36
  * Reactive registry of every shard known to the host. Keys are shard ids.
31
37
  * Populated once at boot by the glob-discovery loop in main.ts (through
@@ -68,7 +74,7 @@ export function registerShard(shard) {
68
74
  * @throws If the shard is not registered, or if a manifest view has no factory after activation.
69
75
  */
70
76
  export async function activateShard(id) {
71
- var _a, _b, _c;
77
+ var _a, _b, _c, _d, _e, _f, _g;
72
78
  const shard = registeredShards.get(id);
73
79
  if (!shard) {
74
80
  throw new Error(`Cannot activate shard "${id}": not registered`);
@@ -128,10 +134,19 @@ export async function activateShard(id) {
128
134
  get isAdmin() {
129
135
  return checkIsAdmin();
130
136
  },
137
+ get tenantId() {
138
+ return getTenantId();
139
+ },
131
140
  zones: ((_a = shard.manifest.permissions) === null || _a === void 0 ? void 0 : _a.includes(PERMISSION_STATE_MANAGE))
132
141
  ? createZoneManager()
133
142
  : undefined,
134
- sync: ((_b = shard.manifest.permissions) === null || _b === void 0 ? void 0 : _b.includes(PERMISSION_DOCUMENTS_SYNC))
143
+ browse: ((_b = shard.manifest.permissions) === null || _b === void 0 ? void 0 : _b.includes(PERMISSION_DOCUMENTS_BROWSE))
144
+ ? createBrowseCapability(getTenantId(), getDocumentBackend())
145
+ : undefined,
146
+ syncRegistry: ((_c = shard.manifest.permissions) === null || _c === void 0 ? void 0 : _c.includes(PERMISSION_DOCUMENTS_BROWSE))
147
+ ? createSyncRegistryAccessor(getDocumentBackend(), getTenantId())
148
+ : undefined,
149
+ sync: ((_d = shard.manifest.permissions) === null || _d === void 0 ? void 0 : _d.includes(PERMISSION_DOCUMENTS_SYNC))
135
150
  ? () => {
136
151
  const backend = getDocumentBackend();
137
152
  const tenantId = getTenantId();
@@ -149,8 +164,27 @@ export async function activateShard(id) {
149
164
  };
150
165
  }
151
166
  : undefined,
167
+ keys: ((_e = shard.manifest.permissions) === null || _e === void 0 ? void 0 : _e.includes(PERMISSION_KEYS_MINT))
168
+ ? createShardKeysApi({
169
+ shardId: id,
170
+ shardPermissions: (_f = shard.manifest.permissions) !== null && _f !== void 0 ? _f : [],
171
+ })
172
+ : undefined,
152
173
  };
153
174
  entry.ctx = ctx;
175
+ // Wire onKeyRevoked hook: subscribe to the revocation bus for this shard.
176
+ // Only shards that declare the hook incur the subscription overhead.
177
+ if (shard.onKeyRevoked) {
178
+ const off = subscribe(id, async (keyId) => {
179
+ try {
180
+ await shard.onKeyRevoked(keyId);
181
+ }
182
+ catch (err) {
183
+ console.error(`[sh3] onKeyRevoked failed in "${id}":`, err);
184
+ }
185
+ });
186
+ entry.cleanupFns.push(async () => off());
187
+ }
154
188
  active.set(id, entry);
155
189
  activeShards.set(id, shard);
156
190
  await shard.activate(ctx);
@@ -170,7 +204,7 @@ export async function activateShard(id) {
170
204
  console.warn(`[sh3] Failed to hydrate env state for shard "${id}":`, err instanceof Error ? err.message : err);
171
205
  }
172
206
  }
173
- void ((_c = shard.autostart) === null || _c === void 0 ? void 0 : _c.call(shard, ctx));
207
+ void ((_g = shard.autostart) === null || _g === void 0 ? void 0 : _g.call(shard, ctx));
174
208
  }
175
209
  /**
176
210
  * Deactivate an active shard. Calls `shard.deactivate`, flushes and disposes
@@ -212,6 +246,24 @@ export function getShardContext(id) {
212
246
  var _a;
213
247
  return (_a = active.get(id)) === null || _a === void 0 ? void 0 : _a.ctx;
214
248
  }
249
+ /**
250
+ * Enumerate every view declared as `standalone` across the currently
251
+ * active shards. Intended for the `views --standalone` verb and any
252
+ * launcher UI that wants to surface "summonable" primitives. Only
253
+ * pulls from `activeShards` — registered-but-inactive shards aren't
254
+ * ready to mount.
255
+ */
256
+ export function listStandaloneViews() {
257
+ const out = [];
258
+ for (const shard of activeShards.values()) {
259
+ for (const view of shard.manifest.views) {
260
+ if (view.standalone) {
261
+ out.push({ shardId: shard.manifest.id, viewId: view.id, label: view.label });
262
+ }
263
+ }
264
+ }
265
+ return out;
266
+ }
215
267
  /**
216
268
  * Test-only reset. Tears down any active shard entries (without running
217
269
  * deactivate hooks — tests should run deactivate explicitly if they care)
@@ -1,9 +1,13 @@
1
1
  import type { StateZones } from '../state/zones.svelte';
2
2
  import type { ZoneSchema, ZoneManager } from '../state/types';
3
3
  import type { DocumentHandle, DocumentHandleOptions } from '../documents/types';
4
+ import type { BrowseCapability } from '../documents/browse';
4
5
  import type { SyncHandle } from '../documents/sync/types';
6
+ import type { SyncRegistry } from '../documents/sync/registry';
5
7
  import type { EnvState } from '../env/types';
6
8
  import type { Verb } from '../verbs/types';
9
+ import type { ShardContextKeys } from '../keys/types';
10
+ export { PERMISSION_KEYS_MINT, type ShardContextKeys, type ApiKeyPublic, type MintOpts, ScopeEscalationError, ConsentDeniedError } from '../keys/types';
7
11
  /**
8
12
  * The object returned by `ViewFactory.mount`. The framework calls
9
13
  * `unmount()` when the slot goes away, and `onResize(w, h)` whenever the
@@ -83,6 +87,14 @@ export interface ViewDeclaration {
83
87
  label: string;
84
88
  /** Optional icon hint (reserved; not yet rendered in phase 8). */
85
89
  icon?: string;
90
+ /**
91
+ * When true, this view is a standalone primitive — summonable from
92
+ * anywhere without requiring an owning app. Standalone views are
93
+ * enumerable via the framework's view directory and can be popped
94
+ * out into a float or docked into the active layout by verbs like
95
+ * `popout` and `dock`. Defaults to false.
96
+ */
97
+ standalone?: boolean;
86
98
  }
87
99
  /**
88
100
  * Static description of a shard as observed by the framework and consumers
@@ -120,6 +132,8 @@ export interface ShardManifest {
120
132
  * Currently recognized:
121
133
  * - 'state:manage' — cross-shard zone access.
122
134
  * - 'documents:sync' — cross-shard document sync API.
135
+ * - 'documents:browse' — tenant-wide document observation and sync
136
+ * registry visibility (observer-class shards, e.g. file-explorer).
123
137
  */
124
138
  permissions?: string[];
125
139
  }
@@ -188,6 +202,12 @@ export interface ShardContext {
188
202
  envUpdate<T extends Record<string, unknown>>(patch: Partial<T>): Promise<void>;
189
203
  /** Whether the current session has admin privileges. */
190
204
  isAdmin: boolean;
205
+ /**
206
+ * The active tenant id. Always present. Exposed for logging / diagnostics.
207
+ * Scopes never carry tenantId; the engine rebinds per-session. Do not
208
+ * serialize into persistent storage.
209
+ */
210
+ tenantId: string;
191
211
  /**
192
212
  * Cross-shard zone management API. Only present when the shard's
193
213
  * manifest declares the `'state:manage'` permission. Check with
@@ -200,6 +220,26 @@ export interface ShardContext {
200
220
  * `if (ctx.sync)` before use.
201
221
  */
202
222
  sync?: () => SyncHandle;
223
+ /**
224
+ * Tenant-wide document browse API. Read-only enumeration and change
225
+ * subscription across every shard's documents for the active tenant.
226
+ * Only present when the shard's manifest declares the
227
+ * `'documents:browse'` permission. Writes still flow through the
228
+ * owning shard's own `ctx.documents()` handle.
229
+ */
230
+ browse?: BrowseCapability;
231
+ /**
232
+ * Sync registry observer. Read-only list/revoke/conflict enumeration
233
+ * for explorer-class shards. Only present when the shard declares
234
+ * `'documents:browse'`. Granting still happens exclusively via
235
+ * `<SyncGrantPicker />`.
236
+ */
237
+ syncRegistry?: () => SyncRegistry;
238
+ /**
239
+ * Mint/list/revoke keys minted by this shard. Only available when the
240
+ * manifest declares the `keys:mint` permission.
241
+ */
242
+ keys?: ShardContextKeys;
203
243
  }
204
244
  /**
205
245
  * A shard module. Shards are the fundamental unit of contribution in SH3.
@@ -241,6 +281,8 @@ export interface Shard {
241
281
  * `ShardContext` that `activate` received.
242
282
  */
243
283
  resume?(ctx: ShardContext): void | Promise<void>;
284
+ /** Fires when a key minted by this shard is revoked from any source. */
285
+ onKeyRevoked?(id: string): void | Promise<void>;
244
286
  }
245
287
  /**
246
288
  * Source-level shape of a shard as written by external package authors.
@@ -17,4 +17,4 @@
17
17
  * activation events. They'll slot into `ShardContext` as new `register*`
18
18
  * methods without disturbing the phase-4 shape.
19
19
  */
20
- export {};
20
+ export { PERMISSION_KEYS_MINT, ScopeEscalationError, ConsentDeniedError } from '../keys/types';
@@ -0,0 +1,110 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Keys & Peers view — lists every API key minted for the current user's
4
+ * tenant (admin keys excluded) and allows individual revocation.
5
+ *
6
+ * Reachable from settings. No create-key button; keys are only minted
7
+ * in-shard via the consent flow.
8
+ */
9
+
10
+ import type { ApiKeyPublic } from '../../keys/types';
11
+ import { subscribe as subscribeBus } from '../../keys/revocation-bus.svelte';
12
+
13
+ let rows = $state<ApiKeyPublic[]>([]);
14
+ let loadError = $state<string | null>(null);
15
+ let loading = $state(true);
16
+ let confirmingId = $state<string | null>(null);
17
+
18
+ async function refresh(): Promise<void> {
19
+ loadError = null;
20
+ loading = true;
21
+ try {
22
+ const res = await fetch('/api/keys', { credentials: 'include' });
23
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
24
+ rows = await res.json();
25
+ } catch (err) {
26
+ loadError = err instanceof Error ? err.message : String(err);
27
+ } finally {
28
+ loading = false;
29
+ }
30
+ }
31
+
32
+ async function revoke(id: string): Promise<void> {
33
+ confirmingId = null;
34
+ const res = await fetch(`/api/keys/${encodeURIComponent(id)}`, {
35
+ method: 'DELETE',
36
+ credentials: 'include',
37
+ });
38
+ if (res.ok || res.status === 404) {
39
+ rows = rows.filter((r) => r.id !== id);
40
+ }
41
+ }
42
+
43
+ function formatDate(iso: string): string {
44
+ return new Date(iso).toLocaleDateString(undefined, {
45
+ year: 'numeric', month: 'short', day: 'numeric',
46
+ });
47
+ }
48
+
49
+ $effect(() => { refresh(); });
50
+
51
+ // Refresh whenever any key is revoked (by this or another tab/shard).
52
+ $effect(() => subscribeBus('*', () => { void refresh(); }));
53
+ </script>
54
+
55
+ <div class="keys-peers">
56
+ <div class="keys-peers-header">
57
+ <h2>Keys &amp; Peers</h2>
58
+ </div>
59
+
60
+ {#if loading}
61
+ <p class="keys-peers-muted">Loading...</p>
62
+ {:else if loadError}
63
+ <p class="keys-peers-error">Failed to load: {loadError}</p>
64
+ {:else if rows.length === 0}
65
+ <p class="keys-peers-muted">No keys yet. Shards will ask your permission before creating one.</p>
66
+ {:else}
67
+ <ul class="keys-peers-list">
68
+ {#each rows as row (row.id)}
69
+ <li class="keys-peers-item">
70
+ <div class="keys-peers-info">
71
+ <span class="keys-peers-label">{row.label}</span>
72
+ <span class="keys-peers-meta">
73
+ Minted by: {row.mintedByShardId ?? 'system'}{row.connectorId ? ` \u00B7 Connector: ${row.connectorId}` : ''}
74
+ </span>
75
+ <span class="keys-peers-meta">
76
+ Scopes: {row.scopes.join(', ')}
77
+ </span>
78
+ <span class="keys-peers-meta">
79
+ Created: {formatDate(row.createdAt)} &middot; Expires: {row.expiresAt ? formatDate(row.expiresAt) : 'never'}
80
+ </span>
81
+ </div>
82
+ <div class="keys-peers-actions">
83
+ {#if confirmingId === row.id}
84
+ <button type="button" class="keys-peers-btn-danger" onclick={() => revoke(row.id)}>Confirm</button>
85
+ <button type="button" class="keys-peers-btn-secondary" onclick={() => { confirmingId = null; }}>Cancel</button>
86
+ {:else}
87
+ <button type="button" class="keys-peers-btn-danger" onclick={() => { confirmingId = row.id; }}>Revoke</button>
88
+ {/if}
89
+ </div>
90
+ </li>
91
+ {/each}
92
+ </ul>
93
+ {/if}
94
+ </div>
95
+
96
+ <style>
97
+ .keys-peers { padding: 24px; font-family: system-ui, sans-serif; color: var(--shell-fg); }
98
+ .keys-peers-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
99
+ .keys-peers-header h2 { margin: 0; font-size: 18px; }
100
+ .keys-peers-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
101
+ .keys-peers-item { display: flex; justify-content: space-between; align-items: flex-start; padding: 12px 16px; background: var(--shell-bg-elevated, #252540); border: 1px solid var(--shell-border, #3a3a5c); border-radius: var(--shell-radius, 6px); gap: 12px; }
102
+ .keys-peers-info { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
103
+ .keys-peers-label { font-weight: 600; }
104
+ .keys-peers-meta { font-size: 11px; color: var(--shell-fg-subtle); }
105
+ .keys-peers-actions { display: flex; gap: 6px; flex-shrink: 0; align-items: flex-start; padding-top: 2px; }
106
+ .keys-peers-btn-danger { background: transparent; color: var(--shell-error, #d32f2f); border: 1px solid var(--shell-error, #d32f2f); font-size: 12px; padding: 4px 10px; border-radius: var(--shell-radius, 6px); cursor: pointer; }
107
+ .keys-peers-btn-secondary { background: transparent; color: var(--shell-fg-subtle); border: 1px solid var(--shell-border, #3a3a5c); font-size: 12px; padding: 4px 10px; border-radius: var(--shell-radius, 6px); cursor: pointer; }
108
+ .keys-peers-muted { color: var(--shell-fg-muted); font-style: italic; }
109
+ .keys-peers-error { color: var(--shell-error, #d32f2f); font-size: 13px; }
110
+ </style>
@@ -0,0 +1,3 @@
1
+ declare const KeysAndPeers: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type KeysAndPeers = ReturnType<typeof KeysAndPeers>;
3
+ export default KeysAndPeers;
@@ -88,15 +88,6 @@
88
88
  toolbarRegistry.register({ id: 'focus-lock', order: 20, visible: (ctx) => ctx.mode.id === 'user', component: FocusLockSlot });
89
89
  toolbarRegistry.register({ id: 'target-shard', order: 30, visible: (ctx) => ctx.mode.id === 'user', component: TargetShardSlot });
90
90
 
91
- let toolbarExpanded = $state((() => {
92
- try { return localStorage.getItem('sh3.shell.toolbarExpanded') !== '0'; } catch { return true; }
93
- })());
94
-
95
- function toggleToolbar() {
96
- toolbarExpanded = !toolbarExpanded;
97
- try { localStorage.setItem('sh3.shell.toolbarExpanded', toolbarExpanded ? '1' : '0'); } catch {}
98
- }
99
-
100
91
  /** Walk the layout tree and return the viewId of the active tab in the first
101
92
  * TabsNode found (breadth-first). Returns null if the layout contains no
102
93
  * tabs node with a populated active tab. */
@@ -205,8 +196,6 @@
205
196
  <Toolbar
206
197
  registry={toolbarRegistry}
207
198
  ctx={{ mode, role }}
208
- expanded={toolbarExpanded}
209
- onToggle={toggleToolbar}
210
199
  slotProps={{
211
200
  mode: { mode, role, registry: modeRegistry, onSelect: setMode },
212
201
  'focus-lock': { locked: focusLocked, onToggle: () => (focusLocked = !focusLocked) },
@@ -3,7 +3,7 @@ export const manifest = {
3
3
  id: 'shell',
4
4
  label: 'Shell',
5
5
  version: VERSION,
6
- views: [{ id: 'shell:terminal', label: 'Shell' }],
6
+ views: [{ id: 'shell:terminal', label: 'Shell', standalone: true }],
7
7
  // serverBundle intentionally omitted — this shard is a framework built-in
8
8
  // and is statically mounted at sh3-server boot. The existing contract in
9
9
  // sh3-core/src/shards/types.ts documents that framework-shipped shards do
@@ -19,7 +19,9 @@ import { registerV1Verbs } from './verbs';
19
19
  import { listRegisteredApps, getActiveApp } from '../apps/registry.svelte';
20
20
  import { launchApp } from '../apps/lifecycle';
21
21
  import { registeredShards } from '../shards/activate.svelte';
22
- import { inspectActiveLayout, focusView, closeTab } from '../layout/inspection';
22
+ import { inspectActiveLayout, focusView, closeTab, popoutView, dockFloat, dockIntoActiveLayout } from '../layout/inspection';
23
+ import { floatManager } from '../overlays/float';
24
+ import { listStandaloneViews } from '../shards/activate.svelte';
23
25
  import { getUser, isAdmin } from '../auth/index';
24
26
  /** Walk a layout tree and collect all tab entries (slotId + viewId + label). */
25
27
  function collectTabEntries(node) {
@@ -76,16 +78,62 @@ function makeShellApi(_ctx) {
76
78
  return [];
77
79
  }
78
80
  },
79
- // → layout/inspection: focusView(viewId) returns boolean
81
+ // → layout/inspection: focusView(viewId). Falls back to dockIntoActiveLayout
82
+ // for standalone views that aren't mounted yet — this is the single
83
+ // "summon" entry point wired behind the `open` verb.
80
84
  openViewInCurrentLayout(viewId) {
81
85
  try {
82
- const found = focusView(viewId);
83
- return found ? { ok: true } : { ok: false, error: `view "${viewId}" not found in current layout` };
86
+ if (focusView(viewId))
87
+ return { ok: true };
88
+ const standalone = listStandaloneViews().find((v) => v.viewId === viewId);
89
+ if (standalone) {
90
+ const slotId = `standalone:${viewId}:${Date.now()}`;
91
+ const ok = dockIntoActiveLayout({ slotId, viewId, label: standalone.label });
92
+ return ok ? { ok: true } : { ok: false, error: `could not dock "${viewId}" — no available slot` };
93
+ }
94
+ return { ok: false, error: `view "${viewId}" not found in current layout` };
84
95
  }
85
96
  catch (err) {
86
97
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
87
98
  }
88
99
  },
100
+ // → shards/activate.svelte: listStandaloneViews() walks activeShards
101
+ listStandaloneViews() {
102
+ return listStandaloneViews();
103
+ },
104
+ // → layout/inspection: popoutView(slotId) returns floatId | null
105
+ popoutSlot(slotId) {
106
+ try {
107
+ const floatId = popoutView(slotId);
108
+ return floatId ? { ok: true, floatId } : { ok: false, error: `slot "${slotId}" not found in docked tree` };
109
+ }
110
+ catch (err) {
111
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
112
+ }
113
+ },
114
+ // → layout/inspection: dockFloat(floatId) returns boolean
115
+ dockFloat(floatId) {
116
+ try {
117
+ const ok = dockFloat(floatId);
118
+ return ok ? { ok: true } : { ok: false, error: `float "${floatId}" not found or has no dockable content` };
119
+ }
120
+ catch (err) {
121
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
122
+ }
123
+ },
124
+ // → overlays/float: floatManager.list() returns FloatEntry[]
125
+ listFloats() {
126
+ return floatManager.list().map((f) => {
127
+ var _a, _b, _c, _d, _e;
128
+ const tabs = f.content.type === 'tabs' ? f.content : null;
129
+ const active = tabs ? (_b = tabs.tabs[(_a = tabs.activeTab) !== null && _a !== void 0 ? _a : 0]) !== null && _b !== void 0 ? _b : tabs.tabs[0] : null;
130
+ return {
131
+ floatId: f.id,
132
+ viewId: (_c = active === null || active === void 0 ? void 0 : active.viewId) !== null && _c !== void 0 ? _c : null,
133
+ label: (_e = (_d = f.title) !== null && _d !== void 0 ? _d : active === null || active === void 0 ? void 0 : active.label) !== null && _e !== void 0 ? _e : f.id,
134
+ };
135
+ });
136
+ },
89
137
  // → layout/inspection: closeTab(slotId) is async (guarded close).
90
138
  // Fire-and-forget; the tab disappears asynchronously. ShellApi stays sync.
91
139
  closeSlot(slotId) {
@@ -4,28 +4,21 @@
4
4
  interface Props {
5
5
  registry: ToolbarSlotRegistry;
6
6
  ctx: ShellSlotCtx;
7
- expanded: boolean;
8
- onToggle: () => void;
9
7
  slotProps?: Record<string, Record<string, unknown>>;
10
8
  }
11
9
 
12
- let { registry, ctx, expanded, onToggle, slotProps = {} }: Props = $props();
10
+ let { registry, ctx, slotProps = {} }: Props = $props();
13
11
 
14
12
  let slots = $derived(registry.list(ctx));
15
13
  </script>
16
14
 
17
- <div class="toolbar" class:collapsed={!expanded}>
18
- <button class="toolbar-toggle" onclick={onToggle} title={expanded ? 'Collapse toolbar' : 'Expand toolbar'}>
19
- {expanded ? '▲' : '▼'}
20
- </button>
21
- {#if expanded}
22
- <div class="toolbar-slots">
23
- {#each slots as s (s.id)}
24
- {@const Slot = s.component}
25
- <Slot {...(slotProps[s.id] ?? {})} />
26
- {/each}
27
- </div>
28
- {/if}
15
+ <div class="toolbar">
16
+ <div class="toolbar-slots">
17
+ {#each slots as s (s.id)}
18
+ {@const Slot = s.component}
19
+ <Slot {...(slotProps[s.id] ?? {})} />
20
+ {/each}
21
+ </div>
29
22
  </div>
30
23
 
31
24
  <style>
@@ -33,30 +26,16 @@
33
26
  display: flex;
34
27
  align-items: center;
35
28
  gap: 6px;
36
- padding: 2px 6px;
37
- background: var(--shell-toolbar-bg, #1a1a1a);
29
+ padding: 4px 6px;
30
+ background: var(--shell-bg-elevated, var(--shell-bg, #1a1a1a));
38
31
  border-bottom: 1px solid var(--shell-border, #333);
39
32
  flex-shrink: 0;
40
- min-height: 24px;
41
- }
42
-
43
- .toolbar-toggle {
44
- background: none;
45
- border: none;
46
- color: var(--shell-fg-dim, #888);
47
- cursor: pointer;
48
- font-size: 0.7em;
49
- padding: 0 2px;
50
- line-height: 1;
51
- }
52
-
53
- .toolbar-toggle:hover {
54
- color: var(--shell-fg, #ddd);
55
33
  }
56
34
 
57
35
  .toolbar-slots {
58
36
  display: flex;
59
37
  align-items: center;
60
38
  gap: 8px;
39
+ flex-wrap: wrap;
61
40
  }
62
41
  </style>
@@ -2,8 +2,6 @@ import type { ToolbarSlotRegistry, ShellSlotCtx } from './slots';
2
2
  interface Props {
3
3
  registry: ToolbarSlotRegistry;
4
4
  ctx: ShellSlotCtx;
5
- expanded: boolean;
6
- onToggle: () => void;
7
5
  slotProps?: Record<string, Record<string, unknown>>;
8
6
  }
9
7
  declare const Toolbar: import("svelte").Component<Props, {}, "">;
@@ -11,92 +11,59 @@
11
11
 
12
12
  let { mode, role, registry, onSelect }: Props = $props();
13
13
 
14
- let open = $state(false);
15
-
16
- function select(id: string) {
17
- open = false;
18
- onSelect(id);
19
- }
14
+ let modes = $derived(registry.list(role));
20
15
  </script>
21
16
 
22
17
  {#if role === 'admin'}
23
- <div class="mode-slot">
24
- <button class="mode-btn" onclick={() => (open = !open)}>
25
- {mode.label} ▾
26
- </button>
27
- {#if open}
28
- <ul class="mode-menu" role="menu">
29
- {#each registry.list(role) as m (m.id)}
30
- <li role="menuitem">
31
- <button
32
- class="mode-option"
33
- class:active={m.id === mode.id}
34
- onclick={() => select(m.id)}
35
- >
36
- {m.label}
37
- </button>
38
- </li>
39
- {/each}
40
- </ul>
41
- {/if}
18
+ <div class="mode-bar" role="toolbar" aria-label="Shell mode">
19
+ {#each modes as m (m.id)}
20
+ <button
21
+ type="button"
22
+ class="mode-btn"
23
+ class:active={m.id === mode.id}
24
+ aria-pressed={m.id === mode.id}
25
+ onclick={() => onSelect(m.id)}
26
+ >
27
+ {m.label}
28
+ </button>
29
+ {/each}
42
30
  </div>
43
31
  {:else}
44
32
  <span class="mode-label">{mode.label}</span>
45
33
  {/if}
46
34
 
47
35
  <style>
48
- .mode-slot {
49
- position: relative;
50
- display: inline-block;
36
+ .mode-bar {
37
+ display: inline-flex;
38
+ gap: 2px;
39
+ padding: 1px;
40
+ border: 1px solid var(--shell-border, #444);
41
+ border-radius: 3px;
51
42
  }
52
43
 
53
44
  .mode-btn {
54
45
  background: none;
55
- border: 1px solid var(--shell-border, #444);
56
- color: var(--shell-fg, #ddd);
57
- padding: 2px 6px;
58
- border-radius: 3px;
46
+ border: none;
47
+ color: var(--shell-fg-dim, var(--shell-fg-muted, #888));
48
+ padding: 2px 8px;
49
+ border-radius: 2px;
59
50
  cursor: pointer;
60
51
  font-size: 0.85em;
52
+ line-height: 1.4;
61
53
  }
62
54
 
63
55
  .mode-btn:hover {
64
- background: var(--shell-hover, #222);
65
- }
66
-
67
- .mode-menu {
68
- position: absolute;
69
- top: 100%;
70
- left: 0;
71
- margin: 2px 0 0;
72
- padding: 0;
73
- list-style: none;
74
- background: var(--shell-bg, #111);
75
- border: 1px solid var(--shell-border, #444);
76
- border-radius: 3px;
77
- z-index: 100;
78
- min-width: 100%;
79
- }
80
-
81
- .mode-option {
82
- display: block;
83
- width: 100%;
84
- background: none;
85
- border: none;
56
+ background: var(--shell-hover, color-mix(in srgb, var(--shell-fg, #ddd) 10%, transparent));
86
57
  color: var(--shell-fg, #ddd);
87
- padding: 4px 10px;
88
- text-align: left;
89
- cursor: pointer;
90
- font-size: 0.85em;
91
58
  }
92
59
 
93
- .mode-option:hover,
94
- .mode-option.active {
95
- background: var(--shell-hover, #222);
60
+ .mode-btn.active {
61
+ background: var(--shell-accent, #7c7cf0);
62
+ color: var(--shell-bg, #1a1a2e);
96
63
  }
97
64
 
98
65
  .mode-label {
99
66
  font-size: 0.85em;
100
- color: var(--shell-fg-dim, #888);
67
+ color: var(--shell-fg-dim, var(--shell-fg-muted, #888));
101
68
  }
102
69
  </style>
@@ -7,7 +7,7 @@ import { clearVerb } from './clear';
7
7
  import { historyVerb } from './history';
8
8
  import { appsVerb, appVerb } from './apps';
9
9
  import { shardsVerb } from './shards';
10
- import { viewsVerb, openVerb, closeVerb } from './views';
10
+ import { viewsVerb, openVerb, closeVerb, popoutVerb, dockVerb } from './views';
11
11
  import { zonesVerb, zoneVerb } from './zones';
12
12
  import { pwdVerb, cdVerb, whoamiVerb } from './session';
13
13
  import { envVerb } from './env';
@@ -23,6 +23,8 @@ export function registerV1Verbs(ctx) {
23
23
  ctx.registerVerb(viewsVerb);
24
24
  ctx.registerVerb(openVerb);
25
25
  ctx.registerVerb(closeVerb);
26
+ ctx.registerVerb(popoutVerb);
27
+ ctx.registerVerb(dockVerb);
26
28
  ctx.registerVerb(zonesVerb);
27
29
  ctx.registerVerb(zoneVerb);
28
30
  ctx.registerVerb(pwdVerb);
@@ -1,4 +1,6 @@
1
1
  import type { Verb } from '../../verbs/types';
2
2
  export declare const viewsVerb: Verb;
3
3
  export declare const openVerb: Verb;
4
+ export declare const popoutVerb: Verb;
5
+ export declare const dockVerb: Verb;
4
6
  export declare const closeVerb: Verb;