@hearthlog/shell 0.0.0-stage → 0.1.0

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 (80) hide show
  1. package/LICENSE +235 -0
  2. package/package.json +51 -3
  3. package/src/app.template.html +184 -0
  4. package/src/components/ai/DecisionReviewSheet.svelte +226 -0
  5. package/src/components/ai/SuggestButton.svelte +23 -0
  6. package/src/components/ai/SuggestionChip.svelte +13 -0
  7. package/src/components/ai/SuggestionMark.svelte +15 -0
  8. package/src/components/calendar/CalendarFeedsDrawer.svelte +414 -0
  9. package/src/components/calendar/CalendarMonthSummary.svelte +122 -0
  10. package/src/components/calendar/calendar-weeks.svelte.ts +27 -0
  11. package/src/components/calendar/source-icons.ts +19 -0
  12. package/src/components/data-table/DataTableValue.svelte +14 -0
  13. package/src/components/data-table/EntityActionsCell.svelte +36 -0
  14. package/src/components/data-table/EntityRefsCell.svelte +58 -0
  15. package/src/components/data-table/EntityValue.svelte +42 -0
  16. package/src/components/data-table/entity-columns.ts +87 -0
  17. package/src/components/data-table/sqlite-columns.ts +42 -0
  18. package/src/components/entities/CalendarSourceFields.svelte +149 -0
  19. package/src/components/history/ChangeRecord.svelte +30 -0
  20. package/src/components/history/ForkComposer.svelte +174 -0
  21. package/src/components/history/HistoryScrubber.svelte +245 -0
  22. package/src/components/history/LogHistory.svelte +334 -0
  23. package/src/components/history/LogMap.svelte +293 -0
  24. package/src/components/history/LogTree.svelte +124 -0
  25. package/src/components/layout/AccountMenu.svelte +111 -0
  26. package/src/components/layout/AppearanceOptions.svelte +78 -0
  27. package/src/components/layout/BrandWordmark.svelte +12 -0
  28. package/src/components/layout/ErrorToast.svelte +34 -0
  29. package/src/components/layout/LogCreateDrawer.svelte +111 -0
  30. package/src/components/layout/LogPanel.svelte +225 -0
  31. package/src/components/layout/LogStatusChip.svelte +137 -0
  32. package/src/components/layout/MobilePageAction.svelte +44 -0
  33. package/src/components/layout/MovedBanner.svelte +51 -0
  34. package/src/components/layout/NavBar.svelte +603 -0
  35. package/src/components/layout/NavFlyout.svelte +132 -0
  36. package/src/components/layout/NavItem.svelte +36 -0
  37. package/src/components/layout/NavMoreDialog.svelte +179 -0
  38. package/src/components/layout/PageDataStatus.svelte +36 -0
  39. package/src/components/layout/PhoneGlance.svelte +96 -0
  40. package/src/components/layout/SidebarResizer.svelte +122 -0
  41. package/src/components/layout/SyncIndicator.svelte +24 -0
  42. package/src/components/layout/feature-icons.ts +24 -0
  43. package/src/components/layout/nav-indicator.ts +128 -0
  44. package/src/components/layout/sidebar-size.ts +31 -0
  45. package/src/components/sync/SyncIssuesDrawer.svelte +149 -0
  46. package/src/components/sync/SyncStateBadge.svelte +17 -0
  47. package/src/pages/calendar/Page.svelte +1341 -0
  48. package/src/pages/calendar/load.ts +77 -0
  49. package/src/pages/developer/DatabaseBrowser.svelte +325 -0
  50. package/src/pages/developer/Event.svelte +51 -0
  51. package/src/pages/developer/EventLogManager.svelte +268 -0
  52. package/src/pages/developer/IndexedDbStats.svelte +93 -0
  53. package/src/pages/developer/LedgerAdmin.svelte +877 -0
  54. package/src/pages/developer/LocalSqliteStats.svelte +56 -0
  55. package/src/pages/developer/Page.svelte +590 -0
  56. package/src/pages/developer/PendingWorkImport.svelte +49 -0
  57. package/src/pages/developer/PerformanceDashboard.svelte +262 -0
  58. package/src/pages/developer/StackInfo.svelte +117 -0
  59. package/src/pages/developer/SyncLog.svelte +188 -0
  60. package/src/pages/developer/load.ts +28 -0
  61. package/src/pages/entities/Page.svelte +1069 -0
  62. package/src/pages/entities/load.ts +18 -0
  63. package/src/pages/entity/Page.svelte +655 -0
  64. package/src/pages/entity/load.ts +79 -0
  65. package/src/pages/error/ErrorPage.svelte +147 -0
  66. package/src/pages/layout/Layout.svelte +597 -0
  67. package/src/pages/layout/load.ts +28 -0
  68. package/src/pages/redirects/history.ts +20 -0
  69. package/src/pages/redirects/start.ts +11 -0
  70. package/src/pages/redirects/today.ts +9 -0
  71. package/src/service-worker/index.ts +108 -0
  72. package/src/service-worker/offline-bundle.ts +116 -0
  73. package/src/service-worker/virtual.d.ts +3 -0
  74. package/src/styles/index.css +11 -0
  75. package/src/styles/shell.css +296 -0
  76. package/static/_headers +4 -0
  77. package/static/favicon.ico +0 -0
  78. package/static/offline.html +44 -0
  79. package/static/replay/sw.js +2 -0
  80. package/README.md +0 -3
@@ -0,0 +1,877 @@
1
+ <script lang="ts">
2
+ const drawerId = $props.id();
3
+ const drawerFormId0 = drawerId + "-0";
4
+
5
+ import { onMount } from "svelte";
6
+ import CommandConfirmDialog from "@hearthlog/ui/components/commands/CommandConfirmDialog.svelte";
7
+ import CommandDrawer from "@hearthlog/ui/components/commands/CommandDrawer.svelte";
8
+ import EmptyState from "@hearthlog/ui/components/EmptyState.svelte";
9
+ import JsonViewer from "@hearthlog/ui/components/JsonViewer.svelte";
10
+ import Button from "@hearthlog/ui/components/ui/Button.svelte";
11
+ import {
12
+ LedgerAdminClient,
13
+ LedgerAdminError,
14
+ LedgerAdminUnauthorizedError,
15
+ type AdminApplicationExport,
16
+ type AdminAppSummary,
17
+ type AdminAppTokenResponse,
18
+ type AdminLogSummary,
19
+ type AdminSession,
20
+ } from "@hearthlog/runtime/ledger/admin-client.js";
21
+ import IconCheck from "~icons/mdi/check";
22
+ import IconCodeJson from "~icons/mdi/code-json";
23
+ import IconContentCopy from "~icons/mdi/content-copy";
24
+ import IconDatabaseEye from "~icons/mdi/database-eye-outline";
25
+ import IconKeyChange from "~icons/mdi/key-change";
26
+ import IconPlus from "~icons/mdi/plus";
27
+ import IconRefresh from "~icons/mdi/refresh";
28
+ import IconServerSecurity from "~icons/mdi/server-security";
29
+ import IconShieldLock from "~icons/mdi/shield-lock-outline";
30
+
31
+ type ViewState =
32
+ | "authorized"
33
+ | "error"
34
+ | "loading"
35
+ | "unauthorized"
36
+ | "unavailable";
37
+ type PendingAction = {
38
+ app: AdminAppSummary;
39
+ kind: "revoke" | "rotate";
40
+ };
41
+
42
+ const client = new LedgerAdminClient();
43
+
44
+ let viewState = $state<ViewState>("loading");
45
+ let session = $state<AdminSession | null>(null);
46
+ let apps = $state<AdminAppSummary[]>([]);
47
+ let pageError = $state("");
48
+ let refreshing = $state(false);
49
+ let operationAppId = $state<string | null>(null);
50
+
51
+ let drawerOpen = $state(false);
52
+ let createName = $state("");
53
+ let drawerError = $state("");
54
+ let submitting = $state(false);
55
+ let outcomeUnknown = $state(false);
56
+ let tokenResult = $state<AdminAppTokenResponse | null>(null);
57
+ let copied = $state(false);
58
+
59
+ let pendingAction = $state<PendingAction | null>(null);
60
+
61
+ let detailsOpen = $state(false);
62
+ let detailsApp = $state<AdminAppSummary | null>(null);
63
+ let detailsLogs = $state<AdminLogSummary[]>([]);
64
+ let detailsLoading = $state(false);
65
+ let detailsError = $state("");
66
+
67
+ let exportOpen = $state(false);
68
+ let exportApp = $state<AdminAppSummary | null>(null);
69
+ let exportValue = $state<AdminApplicationExport | null>(null);
70
+ let exportLoading = $state(false);
71
+ let exportError = $state("");
72
+
73
+ const activeApps = $derived(apps.filter((app) => app.revokedAt === null).length);
74
+ const revokedApps = $derived(apps.length - activeApps);
75
+ const drawerTitle = $derived(
76
+ tokenResult ? "Save application token" : "Create Ledger application",
77
+ );
78
+ const drawerDescription = $derived(
79
+ tokenResult
80
+ ? "This token is returned once and cannot be recovered later."
81
+ : "Provision an isolated Ledger application and its first credential.",
82
+ );
83
+ const confirmTitle = $derived(
84
+ pendingAction?.kind === "rotate"
85
+ ? "Rotate application token?"
86
+ : "Revoke Ledger application?",
87
+ );
88
+ const confirmMessage = $derived(
89
+ pendingAction?.kind === "rotate"
90
+ ? `Rotate the token for ${pendingAction.app.name}? The current token will stop working immediately.`
91
+ : pendingAction
92
+ ? `Revoke ${pendingAction.app.name}? All of its active credentials and connections will stop working.`
93
+ : "",
94
+ );
95
+
96
+ onMount(() => {
97
+ void initialize();
98
+ return () => {
99
+ clearSensitiveState();
100
+ clearExportState();
101
+ };
102
+ });
103
+
104
+ async function initialize(): Promise<void> {
105
+ viewState = "loading";
106
+ pageError = "";
107
+ session = null;
108
+ apps = [];
109
+ try {
110
+ session = await client.getSession();
111
+ apps = sortApps(await client.listApps());
112
+ viewState = "authorized";
113
+ } catch (error) {
114
+ setViewError(error);
115
+ }
116
+ }
117
+
118
+ async function refreshApps(): Promise<void> {
119
+ if (refreshing) return;
120
+ refreshing = true;
121
+ pageError = "";
122
+ try {
123
+ apps = sortApps(await client.listApps());
124
+ } catch (error) {
125
+ setViewError(error);
126
+ } finally {
127
+ refreshing = false;
128
+ }
129
+ }
130
+
131
+ function openCreateDrawer(): void {
132
+ closeDetailsDrawer();
133
+ closeExportViewer();
134
+ clearSensitiveState();
135
+ createName = "";
136
+ drawerOpen = true;
137
+ }
138
+
139
+ function closeDrawer(): void {
140
+ if (submitting) return;
141
+ drawerOpen = false;
142
+ clearSensitiveState();
143
+ createName = "";
144
+ }
145
+
146
+ async function openDetailsDrawer(app: AdminAppSummary): Promise<void> {
147
+ closeDrawer();
148
+ closeExportViewer();
149
+ detailsApp = app;
150
+ detailsLogs = [];
151
+ detailsError = "";
152
+ detailsLoading = true;
153
+ detailsOpen = true;
154
+ try {
155
+ detailsLogs = await client.listAppLogs(app.id);
156
+ } catch (error) {
157
+ if (error instanceof LedgerAdminUnauthorizedError) {
158
+ closeDetailsDrawer();
159
+ setViewError(error);
160
+ return;
161
+ }
162
+ detailsError = errorMessage(error);
163
+ } finally {
164
+ detailsLoading = false;
165
+ }
166
+ }
167
+
168
+ function closeDetailsDrawer(): void {
169
+ detailsOpen = false;
170
+ detailsApp = null;
171
+ detailsLogs = [];
172
+ detailsError = "";
173
+ detailsLoading = false;
174
+ }
175
+
176
+ async function openExportViewer(app: AdminAppSummary): Promise<void> {
177
+ closeDetailsDrawer();
178
+ exportApp = app;
179
+ exportOpen = true;
180
+ await loadApplicationExport(app);
181
+ }
182
+
183
+ async function loadApplicationExport(app: AdminAppSummary): Promise<void> {
184
+ exportValue = null;
185
+ exportError = "";
186
+ exportLoading = true;
187
+ try {
188
+ exportValue = await client.getAppExport(app.id);
189
+ } catch (error) {
190
+ if (error instanceof LedgerAdminUnauthorizedError) {
191
+ closeExportViewer();
192
+ setViewError(error);
193
+ return;
194
+ }
195
+ exportError = errorMessage(error);
196
+ } finally {
197
+ exportLoading = false;
198
+ }
199
+ }
200
+
201
+ function closeExportViewer(): void {
202
+ exportOpen = false;
203
+ clearExportState();
204
+ }
205
+
206
+ function clearExportState(): void {
207
+ exportApp = null;
208
+ exportValue = null;
209
+ exportLoading = false;
210
+ exportError = "";
211
+ }
212
+
213
+ function rotateFromDetails(app: AdminAppSummary): void {
214
+ if (app.revokedAt !== null) return;
215
+ closeDetailsDrawer();
216
+ requestAction("rotate", app);
217
+ }
218
+
219
+ async function createApplication(event: SubmitEvent): Promise<void> {
220
+ event.preventDefault();
221
+ const name = createName.trim();
222
+ if (!name || submitting || outcomeUnknown) return;
223
+
224
+ submitting = true;
225
+ drawerError = "";
226
+ try {
227
+ const result = await client.createApp(name);
228
+ upsertApp(result.app);
229
+ showToken(result);
230
+ } catch (error) {
231
+ setDrawerError(error);
232
+ if (error instanceof LedgerAdminError && error.outcomeUnknown) {
233
+ await reconcileUnknownOutcome();
234
+ }
235
+ } finally {
236
+ submitting = false;
237
+ }
238
+ }
239
+
240
+ function requestAction(
241
+ kind: PendingAction["kind"],
242
+ app: AdminAppSummary,
243
+ ): void {
244
+ if (app.revokedAt !== null || operationAppId !== null) return;
245
+ pendingAction = { app, kind };
246
+ }
247
+
248
+ async function executeAction(): Promise<void> {
249
+ const action = pendingAction;
250
+ pendingAction = null;
251
+ if (!action || operationAppId !== null) return;
252
+
253
+ operationAppId = action.app.id;
254
+ pageError = "";
255
+ try {
256
+ if (action.kind === "rotate") {
257
+ const result = await client.rotateAppToken(action.app.id);
258
+ upsertApp(result.app);
259
+ showToken(result);
260
+ } else {
261
+ upsertApp(await client.revokeApp(action.app.id));
262
+ }
263
+ } catch (error) {
264
+ setOperationError(error);
265
+ if (error instanceof LedgerAdminError && error.outcomeUnknown) {
266
+ await reconcileUnknownOutcome();
267
+ }
268
+ } finally {
269
+ operationAppId = null;
270
+ }
271
+ }
272
+
273
+ function showToken(result: AdminAppTokenResponse): void {
274
+ closeDetailsDrawer();
275
+ clearSensitiveState();
276
+ tokenResult = result;
277
+ copied = false;
278
+ drawerOpen = true;
279
+ }
280
+
281
+ async function copyToken(): Promise<void> {
282
+ if (!tokenResult) return;
283
+ try {
284
+ await navigator.clipboard.writeText(tokenResult.token);
285
+ copied = true;
286
+ drawerError = "";
287
+ } catch {
288
+ drawerError = "Copy failed. Select and copy the token manually.";
289
+ }
290
+ }
291
+
292
+ async function recoverUnknownOutcome(): Promise<void> {
293
+ closeDrawer();
294
+ await refreshApps();
295
+ }
296
+
297
+ async function reconcileUnknownOutcome(): Promise<void> {
298
+ try {
299
+ apps = sortApps(await client.listApps());
300
+ } catch {
301
+ // Keep the mutation warning visible. Refresh remains available for
302
+ // another read-only reconciliation attempt.
303
+ }
304
+ }
305
+
306
+ function upsertApp(app: AdminAppSummary): void {
307
+ const next = apps.filter((current) => current.id !== app.id);
308
+ next.push(app);
309
+ apps = sortApps(next);
310
+ if (detailsApp?.id === app.id) detailsApp = app;
311
+ }
312
+
313
+ function setViewError(error: unknown): void {
314
+ drawerOpen = false;
315
+ closeDetailsDrawer();
316
+ closeExportViewer();
317
+ pendingAction = null;
318
+ clearSensitiveState();
319
+ session = null;
320
+ apps = [];
321
+ if (error instanceof LedgerAdminUnauthorizedError) {
322
+ viewState = "unauthorized";
323
+ return;
324
+ }
325
+ if (
326
+ error instanceof LedgerAdminError &&
327
+ error.code === "ledger_admin_gateway_unconfigured"
328
+ ) {
329
+ viewState = "unavailable";
330
+ return;
331
+ }
332
+ viewState = "error";
333
+ pageError = errorMessage(error);
334
+ }
335
+
336
+ function setDrawerError(error: unknown): void {
337
+ if (error instanceof LedgerAdminUnauthorizedError) {
338
+ closeDrawer();
339
+ setViewError(error);
340
+ return;
341
+ }
342
+ drawerError = errorMessage(error);
343
+ outcomeUnknown =
344
+ error instanceof LedgerAdminError && error.outcomeUnknown;
345
+ }
346
+
347
+ function setOperationError(error: unknown): void {
348
+ if (error instanceof LedgerAdminUnauthorizedError) {
349
+ setViewError(error);
350
+ return;
351
+ }
352
+ pageError = errorMessage(error);
353
+ if (error instanceof LedgerAdminError && error.outcomeUnknown) {
354
+ pageError += " Refresh the list before trying another action.";
355
+ }
356
+ }
357
+
358
+ function clearSensitiveState(): void {
359
+ tokenResult = null;
360
+ copied = false;
361
+ drawerError = "";
362
+ outcomeUnknown = false;
363
+ }
364
+
365
+ function errorMessage(error: unknown): string {
366
+ return error instanceof Error
367
+ ? error.message
368
+ : "Ledger administrator request failed.";
369
+ }
370
+
371
+ function sortApps(values: AdminAppSummary[]): AdminAppSummary[] {
372
+ return [...values].sort((left, right) =>
373
+ right.createdAt.localeCompare(left.createdAt),
374
+ );
375
+ }
376
+
377
+ function formatDate(value: string | null): string {
378
+ if (!value) return "Never";
379
+ const date = new Date(value);
380
+ if (Number.isNaN(date.getTime())) return value;
381
+ return new Intl.DateTimeFormat(undefined, {
382
+ dateStyle: "medium",
383
+ timeStyle: "short",
384
+ }).format(date);
385
+ }
386
+
387
+ function formatEventCount(value: number): string {
388
+ return new Intl.NumberFormat().format(value);
389
+ }
390
+
391
+ </script>
392
+
393
+ <section class="mt-4 lg:mt-6" data-testid="ledger-admin">
394
+ {#if viewState === "loading"}
395
+ <div class="stor-panel px-5 py-14 text-center">
396
+ <div class="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-[var(--stor-border)] border-t-[var(--stor-accent)]"></div>
397
+ <p class="mt-4 text-sm font-medium text-stone-600">
398
+ Checking Ledger administrator access…
399
+ </p>
400
+ </div>
401
+ {:else if viewState === "unauthorized"}
402
+ <EmptyState
403
+ icon={IconShieldLock}
404
+ title="You don’t have Ledger administrator access"
405
+ description="Ledger application data and administrator actions are limited to the configured administrator."
406
+ />
407
+ {:else if viewState === "unavailable"}
408
+ <EmptyState
409
+ icon={IconServerSecurity}
410
+ title="Ledger admin is not configured"
411
+ description="The Access verifier or server-side Ledger administrator credential is missing."
412
+ />
413
+ {:else if viewState === "error"}
414
+ <EmptyState
415
+ icon={IconServerSecurity}
416
+ title="Ledger admin could not be loaded"
417
+ description={pageError}
418
+ >
419
+ <Button variant="secondary" onclick={initialize}>
420
+ <IconRefresh class="h-4 w-4" />
421
+ Try again
422
+ </Button>
423
+ </EmptyState>
424
+ {:else}
425
+ <div class="space-y-4">
426
+ <div class="stor-panel p-[var(--stor-panel-padding)]">
427
+ <div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
428
+ <div>
429
+ <div class="flex items-center gap-2">
430
+ <IconServerSecurity class="h-5 w-5 text-[var(--stor-accent-strong)]" />
431
+ <h2 class="text-lg font-semibold text-stone-950">
432
+ Ledger applications
433
+ </h2>
434
+ </div>
435
+ <p class="mt-1 text-sm text-[var(--stor-muted-text)]">
436
+ Signed in as <span class="font-medium text-stone-700">{session?.email}</span>
437
+ </p>
438
+ </div>
439
+ <div class="flex gap-2">
440
+ <Button
441
+ variant="secondary"
442
+ size="sm"
443
+ onclick={refreshApps}
444
+ disabled={refreshing || operationAppId !== null}
445
+ >
446
+ <IconRefresh class="h-4 w-4 {refreshing ? 'animate-spin' : ''}" />
447
+ Refresh
448
+ </Button>
449
+ <Button
450
+ size="sm"
451
+ onclick={openCreateDrawer}
452
+ disabled={operationAppId !== null}
453
+ >
454
+ <IconPlus class="h-4 w-4" />
455
+ Create Ledger app
456
+ </Button>
457
+ </div>
458
+ </div>
459
+
460
+ <div class="mt-4 grid grid-cols-3 gap-2">
461
+ <div class="rounded-xl bg-[var(--stor-surface-muted)] px-3 py-2.5">
462
+ <p class="text-[10.5px] font-semibold uppercase tracking-[0.1em] text-[var(--stor-muted-text)]">Total</p>
463
+ <p class="mt-1 text-lg font-semibold text-stone-900">{apps.length}</p>
464
+ </div>
465
+ <div class="rounded-xl bg-emerald-50 px-3 py-2.5">
466
+ <p class="text-[10.5px] font-semibold uppercase tracking-[0.1em] text-emerald-600">Active</p>
467
+ <p class="mt-1 text-lg font-semibold text-emerald-900">{activeApps}</p>
468
+ </div>
469
+ <div class="rounded-xl bg-[var(--stor-surface-muted)] px-3 py-2.5">
470
+ <p class="text-[10.5px] font-semibold uppercase tracking-[0.1em] text-[var(--stor-muted-text)]">Revoked</p>
471
+ <p class="mt-1 text-lg font-semibold text-stone-800">{revokedApps}</p>
472
+ </div>
473
+ </div>
474
+ </div>
475
+
476
+ {#if pageError}
477
+ <div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800" role="alert">
478
+ {pageError}
479
+ </div>
480
+ {/if}
481
+
482
+ {#if apps.length === 0}
483
+ <EmptyState
484
+ icon={IconServerSecurity}
485
+ title="No Ledger applications"
486
+ description="Create the first application to receive its initial token."
487
+ >
488
+ <Button onclick={openCreateDrawer}>
489
+ <IconPlus class="h-4 w-4" />
490
+ Create Ledger app
491
+ </Button>
492
+ </EmptyState>
493
+ {:else}
494
+ <div class="stor-panel hidden overflow-hidden md:block" data-testid="ledger-admin-table">
495
+ <div class="overflow-x-auto">
496
+ <table class="w-full min-w-[58rem] text-left text-sm">
497
+ <thead class="border-b border-[var(--stor-border)] bg-[var(--stor-surface-muted)] text-xs uppercase tracking-[0.08em] text-[var(--stor-muted-text)]">
498
+ <tr>
499
+ <th class="px-4 py-3 font-semibold">Application</th>
500
+ <th class="px-4 py-3 font-semibold">Status</th>
501
+ <th class="px-4 py-3 font-semibold">Created</th>
502
+ <th class="px-4 py-3 font-semibold">Token issued</th>
503
+ <th class="px-4 py-3 text-right font-semibold">Actions</th>
504
+ </tr>
505
+ </thead>
506
+ <tbody class="divide-y divide-stone-100">
507
+ {#each apps as app (app.id)}
508
+ <tr>
509
+ <td class="px-4 py-3">
510
+ <p class="font-semibold text-stone-900">{app.name}</p>
511
+ <code class="mt-0.5 block max-w-64 truncate text-xs text-[var(--stor-muted-text)]" title={app.id}>{app.id}</code>
512
+ </td>
513
+ <td class="px-4 py-3">
514
+ <span class="inline-flex rounded-full px-2.5 py-1 text-xs font-semibold {app.revokedAt ? 'bg-[var(--stor-surface-muted)] text-stone-600' : 'bg-emerald-50 text-emerald-700'}">
515
+ {app.revokedAt ? "Revoked" : "Active"}
516
+ </span>
517
+ </td>
518
+ <td class="whitespace-nowrap px-4 py-3 text-stone-600">{formatDate(app.createdAt)}</td>
519
+ <td class="whitespace-nowrap px-4 py-3 text-stone-600">{formatDate(app.activeTokenCreatedAt)}</td>
520
+ <td class="px-4 py-3">
521
+ <div class="flex justify-end gap-2">
522
+ <Button
523
+ variant="ghost"
524
+ size="sm"
525
+ aria-label={`View logs and data for ${app.name}`}
526
+ onclick={() => void openDetailsDrawer(app)}
527
+ disabled={operationAppId !== null}
528
+ >
529
+ <IconDatabaseEye class="h-4 w-4" />
530
+ View
531
+ </Button>
532
+ <Button
533
+ variant="secondary"
534
+ size="sm"
535
+ aria-label={`Rotate token for ${app.name}`}
536
+ onclick={() => requestAction("rotate", app)}
537
+ disabled={app.revokedAt !== null || operationAppId !== null}
538
+ >
539
+ <IconKeyChange class="h-4 w-4" />
540
+ Rotate
541
+ </Button>
542
+ <Button
543
+ variant="ghost"
544
+ size="sm"
545
+ class="text-red-700 hover:bg-red-50 hover:text-red-800"
546
+ aria-label={`Revoke ${app.name}`}
547
+ onclick={() => requestAction("revoke", app)}
548
+ disabled={app.revokedAt !== null || operationAppId !== null}
549
+ >
550
+ Revoke
551
+ </Button>
552
+ </div>
553
+ </td>
554
+ </tr>
555
+ {/each}
556
+ </tbody>
557
+ </table>
558
+ </div>
559
+ </div>
560
+
561
+ <div class="space-y-3 md:hidden" data-testid="ledger-admin-cards">
562
+ {#each apps as app (app.id)}
563
+ <article class="stor-panel p-4">
564
+ <div class="flex items-start justify-between gap-3">
565
+ <div class="min-w-0">
566
+ <h3 class="truncate font-semibold text-stone-900">{app.name}</h3>
567
+ <code class="mt-1 block truncate text-xs text-[var(--stor-muted-text)]">{app.id}</code>
568
+ </div>
569
+ <span class="shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold {app.revokedAt ? 'bg-[var(--stor-surface-muted)] text-stone-600' : 'bg-emerald-50 text-emerald-700'}">
570
+ {app.revokedAt ? "Revoked" : "Active"}
571
+ </span>
572
+ </div>
573
+ <dl class="mt-4 grid grid-cols-2 gap-3 text-xs">
574
+ <div>
575
+ <dt class="text-[var(--stor-muted-text)]">Created</dt>
576
+ <dd class="mt-1 font-medium text-stone-700">{formatDate(app.createdAt)}</dd>
577
+ </div>
578
+ <div>
579
+ <dt class="text-[var(--stor-muted-text)]">Token issued</dt>
580
+ <dd class="mt-1 font-medium text-stone-700">{formatDate(app.activeTokenCreatedAt)}</dd>
581
+ </div>
582
+ </dl>
583
+ <div class="mt-4 grid grid-cols-2 gap-2">
584
+ <Button
585
+ variant="secondary"
586
+ size="sm"
587
+ class="col-span-2"
588
+ aria-label={`View logs and data for ${app.name}`}
589
+ onclick={() => void openDetailsDrawer(app)}
590
+ disabled={operationAppId !== null}
591
+ >
592
+ <IconDatabaseEye class="h-4 w-4" />
593
+ View logs and data
594
+ </Button>
595
+ <Button
596
+ variant="secondary"
597
+ size="sm"
598
+ aria-label={`Rotate token for ${app.name}`}
599
+ onclick={() => requestAction("rotate", app)}
600
+ disabled={app.revokedAt !== null || operationAppId !== null}
601
+ >
602
+ <IconKeyChange class="h-4 w-4" />
603
+ Rotate
604
+ </Button>
605
+ <Button
606
+ variant="ghost"
607
+ size="sm"
608
+ class="text-red-700 hover:bg-red-50 hover:text-red-800"
609
+ aria-label={`Revoke ${app.name}`}
610
+ onclick={() => requestAction("revoke", app)}
611
+ disabled={app.revokedAt !== null || operationAppId !== null}
612
+ >
613
+ Revoke
614
+ </Button>
615
+ </div>
616
+ </article>
617
+ {/each}
618
+ </div>
619
+ {/if}
620
+ </div>
621
+ {/if}
622
+ </section>
623
+
624
+ <CommandDrawer
625
+ show={drawerOpen}
626
+ title={drawerTitle}
627
+ description={drawerDescription}
628
+ onClose={closeDrawer}
629
+ >
630
+ {#if tokenResult}
631
+ <div class="space-y-5">
632
+ <div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
633
+ Save the token for <strong>{tokenResult.app.name}</strong> now. Closing this drawer permanently removes it from the UI.
634
+ </div>
635
+ <div>
636
+ <label for="ledger-one-time-token" class="text-sm font-semibold text-stone-700">
637
+ One-time application token
638
+ </label>
639
+ <textarea
640
+ id="ledger-one-time-token"
641
+ data-testid="ledger-one-time-token"
642
+ readonly
643
+ rows="7"
644
+ value={tokenResult.token}
645
+ class="mt-2 w-full resize-none rounded-xl border border-[var(--stor-field-border)] bg-white p-3 font-mono text-xs leading-5 text-stone-800 outline-none"
646
+ ></textarea>
647
+ </div>
648
+ {#if drawerError}
649
+ <p class="text-sm text-red-700" role="alert">{drawerError}</p>
650
+ {/if}
651
+ <div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
652
+ <Button variant="secondary" onclick={closeDrawer}>Done</Button>
653
+ <Button onclick={copyToken}>
654
+ {#if copied}
655
+ <IconCheck class="h-4 w-4" />
656
+ Copied
657
+ {:else}
658
+ <IconContentCopy class="h-4 w-4" />
659
+ Copy token
660
+ {/if}
661
+ </Button>
662
+ </div>
663
+ </div>
664
+ {:else}
665
+ <form id={drawerFormId0} class="space-y-5" onsubmit={createApplication}>
666
+ <div>
667
+ <label for="ledger-app-name" class="text-sm font-semibold text-stone-700">
668
+ Application name
669
+ </label>
670
+ <input
671
+ id="ledger-app-name"
672
+ name="name"
673
+ type="text"
674
+ autocomplete="off"
675
+ bind:value={createName}
676
+ disabled={submitting || outcomeUnknown}
677
+ placeholder="e.g. reporting"
678
+ class="mt-2 h-11 w-full rounded-xl border border-[var(--stor-field-border)] bg-white px-3 text-sm outline-none focus:border-[var(--stor-accent)] focus:ring-2 focus:ring-[var(--stor-accent-soft)] disabled:bg-[var(--stor-surface-muted)]"
679
+ />
680
+ </div>
681
+ {#if drawerError}
682
+ <div class="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-800" role="alert">
683
+ {drawerError}
684
+ </div>
685
+ {/if}
686
+ </form>
687
+
688
+ {/if}
689
+ {#snippet footer()}
690
+ {#if !tokenResult}
691
+ <Button
692
+ variant="secondary"
693
+ type="button"
694
+ onclick={outcomeUnknown ? recoverUnknownOutcome : closeDrawer}
695
+ disabled={submitting}
696
+ >
697
+ {outcomeUnknown ? "Close and refresh" : "Cancel"}
698
+ </Button>
699
+ {#if !outcomeUnknown}
700
+ <Button type="submit" form={drawerFormId0} disabled={!createName.trim() || submitting}>
701
+ <IconPlus class="h-4 w-4" />
702
+ {submitting ? "Creating…" : "Create app"}
703
+ </Button>
704
+ {/if}
705
+
706
+ {/if}
707
+ {/snippet}
708
+ </CommandDrawer>
709
+
710
+ <CommandDrawer
711
+ show={detailsOpen}
712
+ title={detailsApp ? `${detailsApp.name} data` : "Application data"}
713
+ description="Inspect Ledger logs, credential status, and the complete logical dataset."
714
+ size="lg"
715
+ onClose={closeDetailsDrawer}
716
+ >
717
+ {#if detailsApp}
718
+ <div class="space-y-5" data-testid="ledger-app-details">
719
+ <div class="rounded-xl bg-[var(--stor-surface-muted)] p-4">
720
+ <div class="flex items-start justify-between gap-3">
721
+ <div class="min-w-0">
722
+ <p class="font-semibold text-stone-900">{detailsApp.name}</p>
723
+ <code class="mt-1 block break-all text-xs text-[var(--stor-muted-text)]">{detailsApp.id}</code>
724
+ </div>
725
+ <span class="shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold {detailsApp.revokedAt ? 'bg-stone-200 text-stone-600' : 'bg-emerald-100 text-emerald-700'}">
726
+ {detailsApp.revokedAt ? "Revoked" : "Active"}
727
+ </span>
728
+ </div>
729
+ <dl class="mt-4 grid grid-cols-2 gap-3 text-xs">
730
+ <div>
731
+ <dt class="text-[var(--stor-muted-text)]">Created</dt>
732
+ <dd class="mt-1 font-medium text-stone-700">{formatDate(detailsApp.createdAt)}</dd>
733
+ </div>
734
+ <div>
735
+ <dt class="text-[var(--stor-muted-text)]">Key issued</dt>
736
+ <dd class="mt-1 font-medium text-stone-700">{formatDate(detailsApp.activeTokenCreatedAt)}</dd>
737
+ </div>
738
+ </dl>
739
+ </div>
740
+
741
+ <div class="rounded-xl border border-amber-200 bg-amber-50 p-4">
742
+ <p class="text-sm font-semibold text-amber-950">Application key</p>
743
+ <div class="mt-2 rounded-lg border border-amber-200 bg-white px-3 py-2 font-mono text-sm tracking-[0.1em] text-[var(--stor-muted-text)]">
744
+ ••••••••••••••••
745
+ </div>
746
+ <p class="mt-2 text-xs leading-5 text-amber-900">
747
+ Ledger shows a key only once when it is created or rotated. The current key cannot be recovered from this workspace.
748
+ </p>
749
+ {#if detailsApp.revokedAt === null}
750
+ <Button
751
+ variant="secondary"
752
+ size="sm"
753
+ class="mt-3"
754
+ onclick={() => rotateFromDetails(detailsApp!)}
755
+ >
756
+ <IconKeyChange class="h-4 w-4" />
757
+ Rotate and reveal new key
758
+ </Button>
759
+ {/if}
760
+ </div>
761
+
762
+ <div>
763
+ <div class="flex items-center justify-between gap-3">
764
+ <div>
765
+ <h3 class="font-semibold text-stone-900">Logs</h3>
766
+ <p class="mt-0.5 text-xs text-[var(--stor-muted-text)]">
767
+ {detailsLoading ? "Loading…" : `${detailsLogs.length} ${detailsLogs.length === 1 ? "log" : "logs"}`}
768
+ </p>
769
+ </div>
770
+ <Button
771
+ size="sm"
772
+ onclick={() => void openExportViewer(detailsApp!)}
773
+ aria-label={`View JSON export for ${detailsApp.name}`}
774
+ >
775
+ <IconCodeJson class="h-4 w-4" />
776
+ View JSON export
777
+ </Button>
778
+ </div>
779
+
780
+ {#if detailsLoading}
781
+ <div class="mt-3 rounded-xl bg-[var(--stor-surface-muted)] px-4 py-8 text-center text-sm text-[var(--stor-muted-text)]">
782
+ Loading application logs…
783
+ </div>
784
+ {:else if detailsError}
785
+ <div class="mt-3 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-800" role="alert">
786
+ {detailsError}
787
+ </div>
788
+ {:else if detailsLogs.length === 0}
789
+ <div class="mt-3 rounded-xl border border-dashed border-[var(--stor-field-border)] px-4 py-8 text-center text-sm text-[var(--stor-muted-text)]">
790
+ This application has no logs.
791
+ </div>
792
+ {:else}
793
+ <div class="mt-3 space-y-2" data-testid="ledger-app-logs">
794
+ {#each detailsLogs as log (log.id)}
795
+ <article class="stor-panel p-3">
796
+ <div class="flex items-start justify-between gap-3">
797
+ <div class="min-w-0">
798
+ <p class="truncate text-sm font-semibold text-stone-900">{log.name}</p>
799
+ <code class="mt-1 block break-all text-[11px] text-[var(--stor-muted-text)]">{log.id}</code>
800
+ </div>
801
+ <span class="shrink-0 rounded-lg bg-[var(--stor-surface-muted)] px-2 py-1 text-xs font-semibold text-stone-700">
802
+ {formatEventCount(log.headVersion)} {log.headVersion === 1 ? "event" : "events"}
803
+ </span>
804
+ </div>
805
+ <dl class="mt-3 grid grid-cols-2 gap-3 text-xs">
806
+ <div>
807
+ <dt class="text-[var(--stor-muted-text)]">Created</dt>
808
+ <dd class="mt-1 text-stone-700">{formatDate(log.createdAt)}</dd>
809
+ </div>
810
+ <div>
811
+ <dt class="text-[var(--stor-muted-text)]">Last updated</dt>
812
+ <dd class="mt-1 text-stone-700">{formatDate(log.lastUpdatedAt)}</dd>
813
+ </div>
814
+ </dl>
815
+ </article>
816
+ {/each}
817
+ </div>
818
+ {/if}
819
+ </div>
820
+ </div>
821
+ {/if}
822
+ </CommandDrawer>
823
+
824
+ <CommandDrawer
825
+ show={exportOpen}
826
+ title={exportApp ? `${exportApp.name} export` : "Ledger export"}
827
+ description="Read-only logical snapshot of the application, its logs, and every exported event."
828
+ size="lg"
829
+ onClose={closeExportViewer}
830
+ >
831
+ <div data-testid="ledger-export-viewer">
832
+ {#if exportLoading}
833
+ <div class="rounded-xl bg-[var(--stor-surface-muted)] px-4 py-12 text-center">
834
+ <div class="mx-auto h-7 w-7 animate-spin rounded-full border-2 border-[var(--stor-border)] border-t-[var(--stor-accent)]"></div>
835
+ <p class="mt-3 text-sm font-medium text-stone-600">
836
+ Preparing JSON export…
837
+ </p>
838
+ </div>
839
+ {:else if exportError}
840
+ <div class="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800" role="alert">
841
+ <p>{exportError}</p>
842
+ {#if exportApp}
843
+ <Button
844
+ variant="secondary"
845
+ size="sm"
846
+ class="mt-3"
847
+ onclick={() => void loadApplicationExport(exportApp!)}
848
+ >
849
+ <IconRefresh class="h-4 w-4" />
850
+ Try again
851
+ </Button>
852
+ {/if}
853
+ </div>
854
+ {:else if exportValue}
855
+ <div class="mb-3 flex flex-wrap items-center justify-between gap-2 text-xs text-[var(--stor-muted-text)]">
856
+ <span>
857
+ {exportValue.logs.length}
858
+ {exportValue.logs.length === 1 ? "log" : "logs"}
859
+ </span>
860
+ <span>Exported {formatDate(exportValue.exportedAt)}</span>
861
+ </div>
862
+ <JsonViewer
863
+ value={exportValue}
864
+ label={`${exportValue.application.name} Ledger export JSON`}
865
+ maxHeight="calc(100dvh - 15rem)"
866
+ />
867
+ {/if}
868
+ </div>
869
+ </CommandDrawer>
870
+
871
+ <CommandConfirmDialog
872
+ show={pendingAction !== null}
873
+ title={confirmTitle}
874
+ message={confirmMessage}
875
+ onConfirm={executeAction}
876
+ onCancel={() => (pendingAction = null)}
877
+ />