@hearthlog/shell 0.0.0-stage → 0.1.1

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,597 @@
1
+ <script lang="ts">
2
+ import { onMount, tick, untrack } from "svelte";
3
+ import { MediaQuery } from "svelte/reactivity";
4
+ import { page } from "$app/state";
5
+ import { invalidate, beforeNavigate } from "$app/navigation";
6
+ import { eventLogFilename } from "@hearthlog/runtime/store/event-log.svelte.js";
7
+ import {
8
+ syncStatus,
9
+ loadStoredLogSnapshot,
10
+ stopRealtime,
11
+ handleOnline,
12
+ handleOffline,
13
+ getSyncPhase,
14
+ runSyncCoordinator,
15
+ } from "@hearthlog/runtime/store/sync.svelte.js";
16
+ import { localStoreStatus } from "@hearthlog/runtime/store/local-store.svelte.js";
17
+ import { logs } from "@hearthlog/runtime/store/logs.svelte.js";
18
+ import NavBar from "@hearthlog/shell/components/layout/NavBar.svelte";
19
+ import { SIDEBAR_WIDTH_KEY, readSidebarWidth } from "@hearthlog/shell/components/layout/sidebar-size.js";
20
+ import LogCreateDrawer from "@hearthlog/shell/components/layout/LogCreateDrawer.svelte";
21
+ import NotificationCenter from "@hearthlog/ui/components/NotificationCenter.svelte";
22
+ import MovedBanner from "@hearthlog/shell/components/layout/MovedBanner.svelte";
23
+ import { notify } from "@hearthlog/ui/state/notifications.svelte.js";
24
+ import { recordTiming, measurementMetadata, beginNavigationTiming } from "@hearthlog/runtime/utils/perf.js";
25
+ import { workspaceReadiness, workspaceIsReady } from "@hearthlog/ui/state/readiness.js";
26
+ import { waitForWorkspace } from "@hearthlog/runtime/runtime/workspace.js";
27
+ import { waitForPageUpdates } from "@hearthlog/runtime/runtime/page-data.svelte.js";
28
+ import ErrorPage from "../error/ErrorPage.svelte";
29
+ import { offlineReadiness } from "@hearthlog/runtime/store/offline-readiness.svelte.js";
30
+ import { featureForPath, ENTITIES_PATH } from "@hearthlog/contracts/features/registry";
31
+ import { attention } from "@hearthlog/runtime/store/attention.svelte.js";
32
+ import { tenant } from "@hearthlog/contracts/tenant/config";
33
+ import { createAndActivateLog, prepareForkFiles } from "@hearthlog/runtime/log-management.js";
34
+ import { SYNC_INVALIDATION_TARGETS } from "@hearthlog/runtime/store/sync-invalidation.js";
35
+ import { beginReadTargetTransition, failReadTargetTransition } from "@hearthlog/runtime/runtime/read-coordinator.js";
36
+ import {
37
+ isLocalDevelopmentHostname,
38
+ loadCurrentUserEmail,
39
+ LOCAL_USER_EMAIL,
40
+ } from "@hearthlog/runtime/access/user-session.js";
41
+ import { accessSessionState } from "@hearthlog/runtime/store/sync.svelte.js";
42
+ import { retryAccessReauthentication } from "@hearthlog/runtime/access/session.js";
43
+ import { initializeAppearance } from "@hearthlog/runtime/theme/appearance.svelte.js";
44
+ import { rememberSection } from '@hearthlog/runtime/runtime/start-page.js';
45
+ import { startWorkspaceClock } from '@hearthlog/runtime/runtime/clock.js';
46
+ import { startNativeShell } from '@hearthlog/runtime/native/shell.svelte.js';
47
+ import { MODULE_ROUTES } from '#generated/modules';
48
+ import { matchesPattern } from '@hearthlog/contracts/features/route-patterns';
49
+
50
+ type SyncIssuesDrawerComponent =
51
+ typeof import("@hearthlog/shell/components/sync/SyncIssuesDrawer.svelte").default;
52
+
53
+ /** `startup` is the tenant's composed modules: the host imports them and passes them in. */
54
+ const { data, children, startup }: { data: any; children: import("svelte").Snippet; startup: { attentionContributions: Parameters<typeof attention.configure>[0]; startModules: () => (() => void) | void } } = $props();
55
+
56
+ let startupSlow = $state(false);
57
+ $effect(() => {
58
+ if ($workspaceReadiness.ready || $workspaceReadiness.error) { startupSlow = false; return; }
59
+ const timer = setTimeout(() => startupSlow = true, 8000);
60
+ return () => clearTimeout(timer);
61
+ });
62
+
63
+ let logsRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
64
+ let hasRecordedFirstContentfulPaintBaseline = false;
65
+ let hasRecordedShellInteractiveBaseline = false;
66
+ let hasRecordedFirstSyncCompleteBaseline = false;
67
+ let lastNotifiedSyncError = "";
68
+ // The sidebar is a rail below 1024px, where tablets start. From there it opens
69
+ // expanded from 1280px, and a choice made with the toggle always wins.
70
+ const expandable = new MediaQuery("min-width: 1024px");
71
+ const roomy = new MediaQuery("min-width: 1280px");
72
+ let sidebarChoice = $state<boolean | null>((() => {
73
+ try {
74
+ const stored = localStorage.getItem("stor:sidebar-collapsed");
75
+ return stored === null ? null : stored === "true";
76
+ } catch { return null; }
77
+ })());
78
+ const sidebarCollapsed = $derived(!expandable.current || (sidebarChoice ?? !roomy.current));
79
+ let sidebarWidth = $state(readSidebarWidth(localStorage));
80
+ let syncIssuesOpen = $state(false);
81
+ let createLogDrawerOpen = $state(false);
82
+ let SyncIssuesDrawer = $state<SyncIssuesDrawerComponent | null>(null);
83
+ let syncIssuesLoadPromise: Promise<void> | null = null;
84
+ let userEmail = $state(
85
+ typeof window !== "undefined" &&
86
+ isLocalDevelopmentHostname(window.location.hostname)
87
+ ? LOCAL_USER_EMAIL
88
+ : "Loading account…",
89
+ );
90
+
91
+ const syncPhase = $derived.by(() => getSyncPhase());
92
+ /** Pages whose endless lists scroll themselves, so the page fits the screen from lg. */
93
+ const FIT_SECTIONS = ["/calendar"];
94
+ /** Module pages that fit (Finance's months, Health's day pages; `/calories/foods` is an ordinary page). */
95
+ const FIT_PAGES = MODULE_ROUTES.filter((route) => route.fitsScreen).map((route) => route.path);
96
+ function pageFitsScreen(pathname: string): boolean {
97
+ const path = pathname.length > 1 ? pathname.replace(/\/$/, "") : pathname;
98
+ return FIT_SECTIONS.some((section) => path.startsWith(section)) || FIT_PAGES.some((pattern) => matchesPattern(pattern, path));
99
+ }
100
+
101
+ const pageRenderKey = $derived.by(() => {
102
+ // A route a module marks `keepMounted` (Finance's month workbench) stays mounted while only its params change.
103
+ const kept = MODULE_ROUTES.find((route) => route.keepMounted && matchesPattern(route.path, page.url.pathname));
104
+ return kept ? kept.path : page.url.pathname;
105
+ });
106
+
107
+ // Navigation badges and native reminders come from the composed modules.
108
+ // `startup` is the host's composed module list and never changes for a running page.
109
+ // svelte-ignore state_referenced_locally
110
+ attention.configure(startup.attentionContributions);
111
+
112
+ onMount(() => initializeAppearance(tenant.id));
113
+
114
+ onMount(() => startup.startModules());
115
+ onMount(() => startWorkspaceClock());
116
+ onMount(() => startNativeShell({ toggleSidebar: () => toggleSidebar() }));
117
+
118
+ onMount(() => {
119
+ if (import.meta.env.VITE_E2E === "1") {
120
+ void data.workspace.ready.then(() => import("@hearthlog/runtime/testing/e2e-seed.js"))
121
+ .then(({ installE2ESeedHelper }) => installE2ESeedHelper())
122
+ .catch(() => undefined);
123
+ }
124
+ window.dispatchEvent(new CustomEvent("stor:app-mounted"));
125
+ void logs.loadCached();
126
+ let frameId = 0;
127
+ let paintObserver: PerformanceObserver | null = null;
128
+
129
+ if (!hasRecordedFirstContentfulPaintBaseline) {
130
+ const recordFirstContentfulPaint = (entry: PerformanceEntry) => {
131
+ if (hasRecordedFirstContentfulPaintBaseline) return;
132
+ recordTiming(
133
+ "startup",
134
+ "firstContentfulPaint",
135
+ entry.startTime,
136
+ {
137
+ ...measurementMetadata(),
138
+ baselineMetric: "firstContentfulPaint",
139
+ route: page.url.pathname,
140
+ },
141
+ );
142
+ hasRecordedFirstContentfulPaintBaseline = true;
143
+ paintObserver?.disconnect();
144
+ };
145
+ const firstContentfulPaint = performance.getEntriesByName(
146
+ "first-contentful-paint",
147
+ )[0];
148
+ if (firstContentfulPaint) {
149
+ recordFirstContentfulPaint(firstContentfulPaint);
150
+ } else if ("PerformanceObserver" in window) {
151
+ paintObserver = new PerformanceObserver((list) => {
152
+ const entry = list.getEntriesByName(
153
+ "first-contentful-paint",
154
+ )[0];
155
+ if (entry) recordFirstContentfulPaint(entry);
156
+ });
157
+ paintObserver.observe({ type: "paint", buffered: true });
158
+ }
159
+ }
160
+
161
+ if (
162
+ typeof window !== "undefined" &&
163
+ !hasRecordedShellInteractiveBaseline
164
+ ) {
165
+ void tick().then(() => {
166
+ frameId = window.requestAnimationFrame(() => {
167
+ frameId = window.requestAnimationFrame(() => {
168
+ recordTiming(
169
+ "startup",
170
+ "shellInteractive",
171
+ performance.now(),
172
+ {
173
+ ...measurementMetadata(),
174
+ baselineMetric: "shellInteractive",
175
+ route: page.url.pathname,
176
+ },
177
+ );
178
+ hasRecordedShellInteractiveBaseline = true;
179
+ });
180
+ });
181
+ });
182
+ }
183
+
184
+ return () => {
185
+ if (frameId) {
186
+ window.cancelAnimationFrame(frameId);
187
+ }
188
+ paintObserver?.disconnect();
189
+ };
190
+ });
191
+
192
+ onMount(() => {
193
+ let mounted = true;
194
+ void loadCurrentUserEmail()
195
+ .then((email) => {
196
+ if (mounted) userEmail = email;
197
+ })
198
+ .catch((error) => {
199
+ if (mounted) userEmail = "Account unavailable";
200
+ console.warn("[Layout] Could not load Access identity:", error);
201
+ });
202
+
203
+ return () => {
204
+ mounted = false;
205
+ };
206
+ });
207
+
208
+ async function refreshLogs(): Promise<void> {
209
+ try {
210
+ await logs.refresh();
211
+ } catch (error) {
212
+ console.warn("[Layout] Failed to refresh logs:", error);
213
+ }
214
+ }
215
+
216
+ function afterFirstPaint(callback: () => void): () => void {
217
+ if (typeof window === "undefined") {
218
+ callback();
219
+ return () => {};
220
+ }
221
+
222
+ let firstFrame = 0;
223
+ let secondFrame = 0;
224
+ let idleHandle: number | null = null;
225
+ const requestIdle = (
226
+ window as Window & {
227
+ requestIdleCallback?: (
228
+ callback: () => void,
229
+ options?: { timeout: number },
230
+ ) => number;
231
+ cancelIdleCallback?: (handle: number) => void;
232
+ }
233
+ ).requestIdleCallback;
234
+ const cancelIdle = (
235
+ window as Window & {
236
+ cancelIdleCallback?: (handle: number) => void;
237
+ }
238
+ ).cancelIdleCallback;
239
+
240
+ firstFrame = window.requestAnimationFrame(() => {
241
+ secondFrame = window.requestAnimationFrame(() => {
242
+ if (requestIdle) {
243
+ idleHandle = requestIdle(callback, { timeout: 1000 });
244
+ } else {
245
+ callback();
246
+ }
247
+ });
248
+ });
249
+
250
+ return () => {
251
+ if (firstFrame) window.cancelAnimationFrame(firstFrame);
252
+ if (secondFrame) window.cancelAnimationFrame(secondFrame);
253
+ if (idleHandle !== null && cancelIdle) cancelIdle(idleHandle);
254
+ };
255
+ }
256
+
257
+ beforeNavigate(({ from, to, shallow }) => {
258
+ if (shallow) return;
259
+ if (!from || !to || from.url.pathname === to.url.pathname && from.url.search === to.url.search) return;
260
+
261
+ // A route that redirects at once (Finance's `/finance` to the current month) completes the navigation already in progress.
262
+ const resolved = MODULE_ROUTES.find((route) => route.resolvesTo && route.path === from.url.pathname);
263
+ if (resolved && matchesPattern(resolved.resolvesTo!, to.url.pathname) && from.url.search === to.url.search) return;
264
+ beginNavigationTiming();
265
+ });
266
+
267
+ $effect(() => {
268
+ const workspace = data.workspace;
269
+ let mounted = true;
270
+ let cancelInitialNetworkStart: (() => void) | null = null;
271
+
272
+ async function startInitialNetworkSync() {
273
+ let outcome = !navigator.onLine ? 'offline' : document.visibilityState !== 'visible' ? 'skipped' : 'success';
274
+ const route = page.url.pathname;
275
+ const metadata = measurementMetadata();
276
+ try {
277
+ await runSyncCoordinator("startup");
278
+ if (syncStatus.hasError) outcome = 'failed';
279
+ else if (outcome === 'success' && !syncStatus.hasCompletedSync) outcome = 'skipped';
280
+ } catch (syncError) {
281
+ outcome = 'failed';
282
+ console.warn("[Layout] Initial sync failed:", syncError);
283
+ } finally {
284
+ if (!hasRecordedFirstSyncCompleteBaseline) {
285
+ recordTiming("startup", "firstSyncComplete", performance.now(), {
286
+ ...metadata, baselineMetric: "firstSyncComplete", route,
287
+ logId: eventLogFilename.value, outcome,
288
+ });
289
+ hasRecordedFirstSyncCompleteBaseline = true;
290
+ }
291
+ }
292
+ }
293
+
294
+ function scheduleInitialNetworkSync() {
295
+ if (import.meta.env.VITE_E2E === "1") {
296
+ syncStatus.hasCompletedSync = true;
297
+ syncStatus.hasError = false;
298
+ return;
299
+ }
300
+ cancelInitialNetworkStart?.();
301
+ cancelInitialNetworkStart = afterFirstPaint(() => {
302
+ if (!mounted || !eventLogFilename.value || hasRecordedFirstSyncCompleteBaseline) return;
303
+ void startInitialNetworkSync();
304
+ });
305
+ }
306
+
307
+ async function init() {
308
+ let schemaRebuilt = false;
309
+
310
+ if (import.meta.env.DEV && "serviceWorker" in navigator) {
311
+ const registrations =
312
+ await navigator.serviceWorker.getRegistrations();
313
+ for (const registration of registrations) {
314
+ await registration.unregister();
315
+ }
316
+ }
317
+ try {
318
+ // console.log("[Layout] Starting initialization");
319
+
320
+ const bootstrap = await workspace.ready;
321
+ if (!mounted) return;
322
+ await logs.loadCached();
323
+ schemaRebuilt = bootstrap.schemaRebuilt;
324
+
325
+ if (schemaRebuilt) {
326
+ console.log("[Layout] Schema was rebuilt");
327
+ }
328
+
329
+ void waitForPageUpdates().then(() => window.dispatchEvent(new CustomEvent("stor:app-ready"))).catch(() => undefined);
330
+
331
+ if (eventLogFilename.value) {
332
+ scheduleInitialNetworkSync();
333
+ } else {
334
+ syncStatus.hasError = false;
335
+ syncStatus.errorMessage = null;
336
+ console.log(
337
+ "[Layout] No event log selected after initialization; skipping sync",
338
+ );
339
+ }
340
+
341
+ console.log("[Layout] Initialization complete");
342
+ } catch (error) {
343
+ console.error("[Layout] Initialization failed:", error);
344
+ }
345
+ }
346
+
347
+ if (mounted && import.meta.env.DEV && "serviceWorker" in navigator) {
348
+ // ... (SW cleanup) ...
349
+ }
350
+
351
+ async function loopLogsRefresh() {
352
+ if (!mounted) return;
353
+ if (
354
+ document.visibilityState === "visible" &&
355
+ (typeof navigator === "undefined" || navigator.onLine)
356
+ ) {
357
+ await refreshLogs();
358
+ }
359
+ if (mounted) {
360
+ logsRefreshTimeout = setTimeout(loopLogsRefresh, 10000);
361
+ }
362
+ }
363
+
364
+ // Only call init once
365
+ untrack(() => init()).then(() => {
366
+ if (mounted) loopLogsRefresh();
367
+ });
368
+
369
+ // Visibility handler
370
+ async function handleVisibility() {
371
+ if (document.visibilityState === "visible") {
372
+ if (eventLogFilename.value && workspaceIsReady()) {
373
+ await runSyncCoordinator("visible");
374
+ }
375
+ // Trigger refresh immediately on visible
376
+ await refreshLogs();
377
+ } else {
378
+ await stopRealtime();
379
+ }
380
+ }
381
+
382
+ document.addEventListener("visibilitychange", handleVisibility);
383
+
384
+ return () => {
385
+ mounted = false;
386
+ cancelInitialNetworkStart?.();
387
+ document.removeEventListener("visibilitychange", handleVisibility);
388
+ if (logsRefreshTimeout) {
389
+ clearTimeout(logsRefreshTimeout);
390
+ }
391
+ void stopRealtime();
392
+ };
393
+ });
394
+
395
+ async function invalidateAllViews(): Promise<void> {
396
+ await Promise.all(SYNC_INVALIDATION_TARGETS.map((target) => invalidate(target)));
397
+ }
398
+
399
+ async function handleSwitchLog(logId: string): Promise<void> {
400
+ if (!workspaceIsReady()) return;
401
+ if (!logId) return;
402
+
403
+ let selectedLog = logs.value.find((log) => log.id === logId);
404
+ if (!selectedLog) return;
405
+
406
+ let transitionStarted = false;
407
+ try {
408
+ if (selectedLog.status === 'preparing') {
409
+ notify.info(`Preparing ${selectedLog.name}…`);
410
+ await prepareForkFiles(logId);
411
+ await logs.refresh();
412
+ selectedLog = logs.value.find((log) => log.id === logId);
413
+ if (!selectedLog || selectedLog.status !== 'ready') throw new Error('Fork is still preparing');
414
+ }
415
+ beginReadTargetTransition();
416
+ transitionStarted = true;
417
+ await stopRealtime();
418
+
419
+ await eventLogFilename.set(logId, selectedLog.name);
420
+ await loadStoredLogSnapshot(logId, []);
421
+ await runSyncCoordinator("manual");
422
+ await waitForWorkspace();
423
+ await invalidateAllViews();
424
+ await waitForPageUpdates();
425
+ } catch (error) {
426
+ if (transitionStarted) failReadTargetTransition(error);
427
+ console.error("[Layout] Failed to switch log:", error);
428
+ notify.error(error instanceof Error ? error.message : 'Could not open this log');
429
+ throw error;
430
+ }
431
+ }
432
+
433
+ function openCreateLogDrawer(): void {
434
+ if (!workspaceIsReady()) return;
435
+ createLogDrawerOpen = true;
436
+ }
437
+
438
+ async function handleCreateLog(name: string): Promise<void> {
439
+ if (!workspaceIsReady()) return;
440
+ const response = await createAndActivateLog(name);
441
+ notify.success(`Created and switched to "${response.name || name}".`);
442
+ }
443
+
444
+ function loadSyncIssues(): Promise<void> {
445
+ syncIssuesLoadPromise ??= import(
446
+ "@hearthlog/shell/components/sync/SyncIssuesDrawer.svelte"
447
+ ).then((module) => {
448
+ SyncIssuesDrawer = module.default;
449
+ });
450
+ return syncIssuesLoadPromise;
451
+ }
452
+
453
+ function openSyncIssues(): void {
454
+ if (!workspaceIsReady()) return;
455
+ syncIssuesOpen = true;
456
+ void loadSyncIssues();
457
+ }
458
+
459
+ $effect(() => {
460
+ rememberSection(page.url, tenant, localStorage);
461
+ });
462
+
463
+ function toggleSidebar(): void {
464
+ sidebarChoice = !sidebarCollapsed;
465
+ try { localStorage.setItem("stor:sidebar-collapsed", String(sidebarChoice)); }
466
+ catch { /* The current session still keeps the preference. */ }
467
+ }
468
+
469
+ // Dragging the edge previews live and saves on release; folding saves the same choice as the toggle.
470
+ function resizeSidebar(width: number, folded: boolean, final: boolean): void {
471
+ sidebarWidth = width;
472
+ sidebarChoice = folded;
473
+ if (!final) return;
474
+ try {
475
+ localStorage.setItem(SIDEBAR_WIDTH_KEY, String(width));
476
+ localStorage.setItem("stor:sidebar-collapsed", String(folded));
477
+ } catch { /* The current session still keeps the preference. */ }
478
+ }
479
+
480
+ $effect(() => {
481
+ document.documentElement.style.setProperty("--stor-sidebar-width", `${sidebarWidth}px`);
482
+ });
483
+
484
+ // CSS keys on this attribute (page tabs, the page label), so it follows the form the sidebar is in.
485
+ $effect(() => {
486
+ document.documentElement.dataset.sidebarCollapsed = String(sidebarCollapsed);
487
+ });
488
+
489
+ function onOnline() {
490
+ if (!workspaceIsReady()) return;
491
+ console.log("[Layout] Network online");
492
+ void handleOnline();
493
+ }
494
+
495
+ async function onOffline() {
496
+ console.log("[Layout] Network offline");
497
+ await handleOffline();
498
+ }
499
+
500
+ $effect(() => {
501
+ const message = syncStatus.errorMessage;
502
+ if (syncStatus.hasError && message && message !== lastNotifiedSyncError) {
503
+ lastNotifiedSyncError = message;
504
+ // An expired Cloudflare Access session parks sync until the user
505
+ // re-authenticates. Attach the recovery action directly to the
506
+ // toast so the user is never stuck without a way to sign in.
507
+ notify.error(
508
+ message,
509
+ accessSessionState.expired
510
+ ? {
511
+ label: "Sign in again",
512
+ run: () => {
513
+ void retryAccessReauthentication();
514
+ },
515
+ }
516
+ : undefined,
517
+ );
518
+ }
519
+ if (!syncStatus.hasError) lastNotifiedSyncError = "";
520
+ });
521
+ </script>
522
+
523
+ <svelte:window ononline={onOnline} onoffline={onOffline}></svelte:window>
524
+
525
+ <svelte:head>
526
+ <title>{tenant.title}</title>
527
+ <!-- Tenant-branded favicon (the static favicon.ico stays as fallback). -->
528
+ <link rel="icon" type="image/svg+xml" href={tenant.faviconHref} />
529
+ </svelte:head>
530
+
531
+ <div
532
+ data-stor-root
533
+ class="flex h-dvh w-full flex-col bg-[var(--stor-bg)] transition-[padding] duration-150 ease-out motion-reduce:transition-none {sidebarCollapsed
534
+ ? 'md:pl-[104px]'
535
+ : 'lg:pl-[calc(var(--stor-sidebar-width)+24px)]'}"
536
+ >
537
+ <NavBar
538
+ userEmail={userEmail}
539
+ logs={logs.value}
540
+ lineage={logs.lineage}
541
+ currentLogId={eventLogFilename.value}
542
+ currentLogName={eventLogFilename.name}
543
+ dataReady={$workspaceReadiness.ready}
544
+ localStorePhase={localStoreStatus.phase}
545
+ localStoreErrorMessage={localStoreStatus.errorMessage}
546
+ syncPhase={syncPhase}
547
+ syncErrorMessage={syncStatus.errorMessage}
548
+ pendingUploads={syncStatus.pendingUploads}
549
+ needsAttention={syncStatus.needsAttention}
550
+ offlineAvailable={offlineReadiness.phase === "available"}
551
+ lastSyncedAt={syncStatus.lastSyncedAt}
552
+ collapsed={sidebarCollapsed}
553
+ onToggleCollapsed={toggleSidebar}
554
+ {sidebarWidth}
555
+ onResizeSidebar={resizeSidebar}
556
+ onOpenSyncIssues={openSyncIssues}
557
+ onSwitchLog={handleSwitchLog}
558
+ onCreateLog={openCreateLogDrawer}
559
+ />
560
+
561
+ <main
562
+ data-stor-main
563
+ class="min-h-0 flex-1 overflow-y-auto pt-[var(--stor-mobile-header-height)] lg:pt-0"
564
+ data-page-fit={pageFitsScreen(page.url.pathname) ? "" : undefined}
565
+ >
566
+ <MovedBanner />
567
+ {#if $workspaceReadiness.error}
568
+ <ErrorPage error={$workspaceReadiness.error} />
569
+ {:else}
570
+ {#key pageRenderKey}
571
+ {@render children()}
572
+ {/key}
573
+ {/if}
574
+ </main>
575
+ {#if startupSlow}
576
+ <aside class="stor-panel fixed bottom-24 right-4 z-50 max-w-sm p-4 text-sm lg:bottom-4" role="status">
577
+ Local data is taking longer than expected to open.
578
+ <button class="mt-2 block font-semibold underline" onclick={() => window.location.reload()}>Retry loading</button>
579
+ </aside>
580
+ {/if}
581
+ <NotificationCenter />
582
+ <LogCreateDrawer
583
+ show={createLogDrawerOpen}
584
+ onClose={() => createLogDrawerOpen = false}
585
+ onCreate={handleCreateLog}
586
+ />
587
+ {#if SyncIssuesDrawer}
588
+ <SyncIssuesDrawer
589
+ show={syncIssuesOpen}
590
+ onClose={() => syncIssuesOpen = false}
591
+ />
592
+ {:else if syncIssuesOpen}
593
+ <div class="fixed inset-y-0 right-0 z-50 flex w-full max-w-md items-center justify-center border-l border-[var(--stor-border)] bg-white/95 text-sm font-medium text-stone-600 shadow-[var(--stor-elevation-3)]" role="status">
594
+ Loading sync issues…
595
+ </div>
596
+ {/if}
597
+ </div>
@@ -0,0 +1,28 @@
1
+ import type { LoadEvent } from '@sveltejs/kit';
2
+ type PageLoad = (event: LoadEvent) => unknown;
3
+ import { redirect } from '@sveltejs/kit';
4
+ import { tenant } from '@hearthlog/contracts/tenant/config';
5
+ import { tenantRouteRedirect, moduleRedirect } from '@hearthlog/contracts/tenant/route-access';
6
+ import { getAsOfVersion, TIME_TRAVEL_QUERY_PARAM } from '@hearthlog/runtime/history/legacy-url.js';
7
+ import { notify } from '@hearthlog/ui/state/notifications.svelte.js';
8
+ import { prepareWorkspace } from '@hearthlog/runtime/runtime/workspace.js';
9
+
10
+
11
+ export const load = (({ url }) => {
12
+ const movedTarget = moduleRedirect(url, tenant.features);
13
+ if (movedTarget) redirect(307, movedTarget);
14
+ const target = tenantRouteRedirect(url.pathname, tenant);
15
+ if (target) redirect(307, target);
16
+ if (url.searchParams.has(TIME_TRAVEL_QUERY_PARAM)) {
17
+ const version = getAsOfVersion(url);
18
+ const clean = new URL(url);
19
+ clean.searchParams.delete(TIME_TRAVEL_QUERY_PARAM);
20
+ if (version) {
21
+ const from = `${clean.pathname}${clean.search}${clean.hash}`;
22
+ redirect(307, `/history?${new URLSearchParams({ point: String(version), from })}`);
23
+ }
24
+ notify.error('That history version is invalid.');
25
+ redirect(307, `${clean.pathname}${clean.search}${clean.hash}`);
26
+ }
27
+ return { workspace: prepareWorkspace(url) };
28
+ }) satisfies PageLoad;
@@ -0,0 +1,20 @@
1
+ import { redirect } from '@sveltejs/kit';
2
+ import type { LoadEvent } from '@sveltejs/kit';
3
+ type PageLoad = (event: LoadEvent) => unknown;
4
+ import { tenant } from '@hearthlog/contracts/tenant/config';
5
+
6
+ export const load = (({ url }) => {
7
+ const from = url.searchParams.get('from');
8
+ let target = new URL(tenant.home, url.origin);
9
+ if (from?.startsWith('/') && !from.startsWith('//')) {
10
+ const candidate = new URL(from, url.origin);
11
+ if (candidate.origin === url.origin && candidate.pathname !== '/history') target = candidate;
12
+ }
13
+
14
+ const requestedVersion = Number(url.searchParams.get('point'));
15
+ const point = Number.isSafeInteger(requestedVersion) && requestedVersion > 0
16
+ ? String(requestedVersion)
17
+ : 'latest';
18
+ target.hash = `open-history=${point}`;
19
+ redirect(307, `${target.pathname}${target.search}${target.hash}`);
20
+ }) satisfies PageLoad;
@@ -0,0 +1,11 @@
1
+ import { browser } from '$app/env';
2
+ import { redirect } from '@sveltejs/kit';
3
+ import { startTarget } from '@hearthlog/runtime/runtime/start-page.js';
4
+ import { tenant } from '@hearthlog/contracts/tenant/config';
5
+ import type { LoadEvent } from '@sveltejs/kit';
6
+ type PageLoad = (event: LoadEvent) => unknown;
7
+
8
+
9
+ export const load = (({ url }) => {
10
+ redirect(307, startTarget(url, tenant, browser ? localStorage : null));
11
+ }) satisfies PageLoad;
@@ -0,0 +1,9 @@
1
+ import { redirect } from '@sveltejs/kit';
2
+ import type { LoadEvent } from '@sveltejs/kit';
3
+ type PageLoad = (event: LoadEvent) => unknown;
4
+
5
+
6
+ /** Today was removed; old links and installed shortcuts open the start section. */
7
+ export const load = (() => {
8
+ redirect(308, '/');
9
+ }) satisfies PageLoad;