@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,603 @@
1
+ <script lang="ts">
2
+ import { page } from "$app/state";
3
+ import { historyRouteUrl, navigateHistory } from '@hearthlog/runtime/history/navigation.js';
4
+ import { HISTORY_PARAMS } from '@hearthlog/runtime/history/url.js';
5
+ import BrandWordmark from "./BrandWordmark.svelte";
6
+ import type { Component } from "svelte";
7
+ import type { LogLineageIndex, LogSummary } from "@hearthlog/runtime/ledger/index.js";
8
+ import type { LocalStorePhase } from "@hearthlog/runtime/store/local-store.svelte.js";
9
+ import {
10
+ describeAppDataState,
11
+ type SyncPhase,
12
+ } from "@hearthlog/runtime/store/sync-state.js";
13
+ import { navIndicator } from "./nav-indicator.js";
14
+ import SidebarResizer from "./SidebarResizer.svelte";
15
+ import NavItem from "./NavItem.svelte";
16
+ import NavFlyout from "./NavFlyout.svelte";
17
+ import Tooltip from "@hearthlog/ui/components/ui/Tooltip.svelte";
18
+ import LogStatusChip from "./LogStatusChip.svelte";
19
+ import { logVersion, refreshLogVersion } from "@hearthlog/runtime/store/log-version.svelte.js";
20
+ import Drawer from '@hearthlog/ui/components/Drawer.svelte';
21
+ import Button from '@hearthlog/ui/components/ui/Button.svelte';
22
+ import DataPlaceholder from '@hearthlog/ui/components/data/DataPlaceholder.svelte';
23
+ import AccountMenu from "./AccountMenu.svelte";
24
+ import { buildNavigation, type NavChild } from "@hearthlog/contracts/features/navigation";
25
+ import { attention } from "@hearthlog/runtime/store/attention.svelte.js";
26
+ import { events } from "@hearthlog/runtime/store/events.svelte.js";
27
+ import { tenant } from "@hearthlog/contracts/tenant/config";
28
+ import { DEVELOPER_ICON, ENTITIES_ICON, FEATURE_ICONS } from "./feature-icons.js";
29
+ import { MODULE_SECTION_ICONS } from "#generated/module-icons";
30
+ import IconDots from "~icons/mdi/dots-horizontal";
31
+ import IconChevronDown from "~icons/mdi/chevron-down";
32
+ import IconCheck from "~icons/mdi/check";
33
+ import { DropdownMenu } from "bits-ui";
34
+ import { goto } from "$app/navigation";
35
+ import { phoneHeader } from "@hearthlog/runtime/runtime/phone-header.svelte.js";
36
+ import IconDockLeft from "~icons/mdi/dock-left";
37
+ import IconDockRight from "~icons/mdi/dock-right";
38
+
39
+ type LogPanelComponent = typeof import('./LogPanel.svelte').default;
40
+
41
+ type NavMoreDialogComponent =
42
+ typeof import("./NavMoreDialog.svelte").default;
43
+
44
+ interface NavLink {
45
+ id: string;
46
+ path: string;
47
+ label: string;
48
+ icon: Component;
49
+ matchPaths?: string[];
50
+ mobilePrimary?: boolean;
51
+ mobileOrder?: number;
52
+ children?: NavChild[];
53
+ }
54
+
55
+ interface NavGroup {
56
+ label: string;
57
+ links: NavLink[];
58
+ }
59
+
60
+ const navigation = buildNavigation(tenant);
61
+ const navGroups: NavGroup[] = navigation.groups.map((group) => ({
62
+ ...group,
63
+ links: group.links.map((link) => ({ ...link, icon: link.section ? MODULE_SECTION_ICONS[link.section] : FEATURE_ICONS[link.id as keyof typeof FEATURE_ICONS] })),
64
+ }));
65
+ const settingsLinks = navigation.settings.map((link) => ({
66
+ ...link,
67
+ icon: link.id === "entities" ? ENTITIES_ICON : DEVELOPER_ICON,
68
+ }));
69
+ const navLinks = navGroups.flatMap((group) => group.links);
70
+ const mobilePrimaryLinks = navLinks
71
+ .filter((link) => link.mobilePrimary)
72
+ .sort((left, right) => (left.mobileOrder ?? 99) - (right.mobileOrder ?? 99));
73
+ const moreNavLinks = navLinks.filter((link) => !link.mobilePrimary);
74
+
75
+ let {
76
+ userEmail,
77
+ logs,
78
+ lineage,
79
+ currentLogId,
80
+ currentLogName,
81
+ localStorePhase,
82
+ localStoreErrorMessage,
83
+ syncPhase,
84
+ syncErrorMessage,
85
+ pendingUploads,
86
+ needsAttention,
87
+ offlineAvailable = false,
88
+ lastSyncedAt,
89
+ collapsed,
90
+ dataReady = false,
91
+ onToggleCollapsed,
92
+ sidebarWidth,
93
+ onResizeSidebar,
94
+ onOpenSyncIssues,
95
+ onSwitchLog,
96
+ onCreateLog,
97
+ }: {
98
+ userEmail: string;
99
+ logs: LogSummary[];
100
+ lineage: LogLineageIndex | null;
101
+ currentLogId: string | null;
102
+ currentLogName: string | null;
103
+ localStorePhase: LocalStorePhase;
104
+ localStoreErrorMessage: string | null;
105
+ syncPhase: SyncPhase;
106
+ syncErrorMessage: string | null;
107
+ pendingUploads: number;
108
+ needsAttention: number;
109
+ offlineAvailable?: boolean;
110
+ lastSyncedAt: Date | null;
111
+ collapsed: boolean;
112
+ dataReady?: boolean;
113
+ onToggleCollapsed: () => void;
114
+ sidebarWidth: number;
115
+ onResizeSidebar: (width: number, folded: boolean, final: boolean) => void;
116
+ onOpenSyncIssues: () => void;
117
+ onSwitchLog: (logId: string) => Promise<void>;
118
+ onCreateLog: () => void;
119
+ } = $props();
120
+
121
+ let isMoreOpen = $state(false);
122
+ const logTreeOpen = $derived(historyRouteUrl().searchParams.has('logPanel'));
123
+ let NavMoreDialog = $state<NavMoreDialogComponent | null>(null);
124
+ let navMoreDialogLoadPromise: Promise<void> | null = null;
125
+ let LogPanel = $state<LogPanelComponent | null>(null);
126
+ let logPanelLoadFailed = $state(false);
127
+ let logPanelLoadPromise: Promise<void> | null = null;
128
+
129
+ $effect(() => {
130
+ if (!logTreeOpen || LogPanel) return;
131
+ logPanelLoadPromise ??= import('./LogPanel.svelte')
132
+ .then((module) => { LogPanel = module.default; })
133
+ .catch(() => { logPanelLoadFailed = true; });
134
+ });
135
+
136
+ const activeMoreLink = $derived.by(() =>
137
+ moreNavLinks.find(isLinkActive) ??
138
+ null,
139
+ );
140
+
141
+ const isMoreActive = $derived(isMoreOpen || activeMoreLink !== null);
142
+ // Spatial spec: More keeps the visible label "More" even when the
143
+ // current secondary page lives inside it (e.g. Calculator).
144
+ const moreButtonLabel = "More";
145
+
146
+ $effect(() => {
147
+ if (!dataReady) return;
148
+ const currentUrl = historyRouteUrl();
149
+ const params = new URLSearchParams(currentUrl.hash.slice(1));
150
+ const requestedPoint = params.get('open-history');
151
+ if (requestedPoint === null) return;
152
+ const version = Number(requestedPoint);
153
+ const cleanUrl = new URL(currentUrl);
154
+ cleanUrl.hash = '';
155
+ cleanUrl.searchParams.set('logPanel', 'activity');
156
+ if (Number.isSafeInteger(version) && version > 0) cleanUrl.searchParams.set('historyVersion', String(version));
157
+ navigateHistory(cleanUrl, true);
158
+ });
159
+
160
+ const moreButtonAriaLabel = $derived(
161
+ isMoreOpen
162
+ ? "Close menu"
163
+ : activeMoreLink
164
+ ? `Open menu. Current page: ${activeMoreLink.label}`
165
+ : "Open menu",
166
+ );
167
+
168
+ const syncPresentation = $derived.by(() => {
169
+ // Truthful sync state: a local save is not proof of remote sync.
170
+ // Never report "Synced" before local data is ready.
171
+ if (!dataReady && localStorePhase !== "failed") {
172
+ return { label: "Loading…", description: "Opening local data", tone: "neutral" as const };
173
+ }
174
+ return describeAppDataState({
175
+ localStorePhase,
176
+ localStoreErrorMessage,
177
+ syncPhase,
178
+ syncErrorMessage,
179
+ pendingUploads,
180
+ needsAttention,
181
+ lastSyncedAt,
182
+ });
183
+ });
184
+ const syncLabel = $derived(syncPresentation.label);
185
+ const syncDescription = $derived(syncPresentation.description);
186
+ const syncTitle = $derived(
187
+ `${syncLabel}. ${syncDescription}${offlineAvailable ? ". Available offline." : ""}`,
188
+ );
189
+ const createLogDisabledReason = $derived.by(() => {
190
+ if (!dataReady) return "Wait for local data to open";
191
+ if (syncPhase === "offline") return "Connect to the internet to create a log";
192
+ return undefined;
193
+ });
194
+ const syncTone = $derived(syncPresentation.tone);
195
+ /** Short state under the log name: "Synced · 12:09", "3 pending", "Offline". */
196
+ const syncLine = $derived(
197
+ syncPhase === "synced" && lastSyncedAt && dataReady
198
+ ? `Synced · ${lastSyncedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`
199
+ : syncLabel,
200
+ );
201
+ const syncShort = $derived(syncTone === "danger" ? "Review" : syncLabel);
202
+
203
+ // Badges re-count after every event change, log switch, and once data opens.
204
+ $effect(() => {
205
+ void events.updatedAt;
206
+ void currentLogId;
207
+ if (!dataReady) return;
208
+ void attention.refresh();
209
+ });
210
+
211
+ // The log's version moves with every accepted event and every local save.
212
+ $effect(() => {
213
+ void events.updatedAt;
214
+ void currentLogId;
215
+ if (!dataReady) return;
216
+ void refreshLogVersion(currentLogId);
217
+ });
218
+
219
+ const version = $derived(logVersion.value);
220
+
221
+ const showSyncDot = $derived(
222
+ syncPresentation.tone === "warning" ||
223
+ syncPresentation.tone === "danger" ||
224
+ syncPhase === "syncing",
225
+ );
226
+
227
+ function isActive(path: string): boolean {
228
+ if (path === "/") return page.url.pathname === "/";
229
+ return page.url.pathname === path || page.url.pathname.startsWith(`${path}/`);
230
+ }
231
+
232
+ function isLinkActive(link: NavLink): boolean {
233
+ return (link.matchPaths ?? [link.path]).some(isActive);
234
+ }
235
+
236
+ function linkHref(path: string): string { return path; }
237
+
238
+ /** A query-only child keeps the section's current path, e.g. the viewed Finance month. */
239
+ function childHref(link: NavLink, child: NavChild): string {
240
+ if (child.tab === undefined) return child.href;
241
+ const base = isLinkActive(link) ? page.url.pathname : link.path;
242
+ return child.tab ? `${base}?tab=${child.tab}` : base;
243
+ }
244
+
245
+ /**
246
+ * The selected sub-page. Invoices switches tabs with shallow routing, so its
247
+ * page state is newer than the address the router last loaded.
248
+ */
249
+ function currentTab(): string | null {
250
+ const shallow = (page.state as { invoiceRoute?: { tab?: string } }).invoiceRoute;
251
+ return shallow?.tab ?? page.url.searchParams.get("tab");
252
+ }
253
+
254
+ function isChildActive(link: NavLink, child: NavChild): boolean {
255
+ if (child.tab !== undefined) {
256
+ const tab = currentTab();
257
+ const known = link.children?.some((option) => option.tab && option.tab === tab);
258
+ return child.tab ? tab === child.tab : !known;
259
+ }
260
+ // Path children: the longest matching route wins (/calories/foods over /calories).
261
+ const matches = (link.children ?? []).filter((option) => isActive(option.href));
262
+ const best = matches.sort((left, right) => right.href.length - left.href.length)[0];
263
+ return best?.id === child.id;
264
+ }
265
+
266
+ // ---------------------------------------------------------------- phone top bar
267
+ /** The section in view names the bar; its sub-pages open from the title. */
268
+ const phoneSection = $derived(
269
+ navLinks.find(isLinkActive) ?? settingsLinks.find((link) => isActive(link.path)) ?? null,
270
+ );
271
+ const phoneChildren = $derived(
272
+ phoneSection && "children" in phoneSection && phoneSection.children && phoneSection.children.length > 1
273
+ ? phoneSection.children
274
+ : [],
275
+ );
276
+ const phoneChild = $derived(phoneChildren.find((child) => isChildActive(phoneSection as NavLink, child)) ?? null);
277
+ const phoneTitle = $derived(phoneChild?.label ?? phoneSection?.label ?? "");
278
+ const phoneSmall = $derived(
279
+ [phoneChildren.length > 0 ? phoneSection?.label : null, phoneHeader.context].filter(Boolean).join(" · "),
280
+ );
281
+ /** A copied log keeps its name in sight; the first log needs only the status. */
282
+ const inCopiedLog = $derived(!!lineage && !!currentLogId && !lineage.rootIds.includes(currentLogId));
283
+
284
+ function openLogChip(): void {
285
+ openLogTree();
286
+ }
287
+
288
+ function loadNavMoreDialog(): Promise<void> {
289
+ navMoreDialogLoadPromise ??= import("./NavMoreDialog.svelte").then(
290
+ (module) => {
291
+ NavMoreDialog = module.default;
292
+ },
293
+ );
294
+ return navMoreDialogLoadPromise;
295
+ }
296
+
297
+ function setMoreOpen(open: boolean): void {
298
+ isMoreOpen = open;
299
+ if (open) void loadNavMoreDialog();
300
+ }
301
+
302
+ function openLogTree(): void {
303
+ const url = new URL(window.location.href);
304
+ url.searchParams.set('logPanel', 'activity');
305
+ navigateHistory(url);
306
+ }
307
+
308
+ function closeLogTree(): void {
309
+ const url = new URL(window.location.href);
310
+ for (const key of HISTORY_PARAMS) url.searchParams.delete(key);
311
+ navigateHistory(url, true);
312
+ }
313
+
314
+ function toggleMore(): void {
315
+ setMoreOpen(!isMoreOpen);
316
+ }
317
+
318
+
319
+ </script>
320
+
321
+ <aside
322
+ class="fixed left-3 top-3 bottom-3 z-50 hidden flex-col rounded-[20px] bg-[var(--stor-sidebar)] shadow-[var(--stor-sidebar-shadow)] transition-[width] duration-150 ease-out motion-reduce:transition-none md:flex {collapsed
323
+ ? 'w-20'
324
+ : 'w-[var(--stor-sidebar-width)]'}"
325
+ data-testid="desktop-sidebar"
326
+ >
327
+ <div class="flex shrink-0 {collapsed ? 'flex-col items-center gap-2 px-2 pt-4' : 'h-[4.5rem] items-center gap-2 pl-3.5 pr-2.5'}" data-stor-drag>
328
+ <!-- Reserved for the native window controls; empty and hidden outside the Mac app. -->
329
+ <span class="stor-lights" data-stor-lights aria-hidden="true"></span>
330
+ <!-- The Mac app names itself in the menu bar, so its sidebar drops the brand (app.css). -->
331
+ <a href={linkHref("/")} data-stor-brand class="flex min-w-0 items-center gap-3 rounded-xl font-semibold text-[var(--stor-ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]" title={tenant.title}>
332
+ {#if collapsed}
333
+ <span class="stor-brand-logo"><span class="stor-mark"></span></span>
334
+ {:else}
335
+ <span class="stor-brand-title truncate"><BrandWordmark title={tenant.title} /></span>
336
+ {/if}
337
+ </a>
338
+ <button
339
+ type="button"
340
+ class="hidden h-8 w-8 shrink-0 items-center justify-center rounded-lg text-[var(--stor-muted-text)] transition hover:bg-[var(--stor-surface-hover)] lg:flex hover:text-[var(--stor-ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] {collapsed ? '' : 'ml-auto'}"
341
+ aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
342
+ title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
343
+ onclick={onToggleCollapsed}
344
+ >
345
+ {#if collapsed}<IconDockRight class="h-4.5 w-4.5" />{:else}<IconDockLeft class="h-4.5 w-4.5" />{/if}
346
+ </button>
347
+ </div>
348
+
349
+ <nav class="relative min-h-0 flex-1 overflow-y-auto pb-3 pt-2 {collapsed ? 'px-2' : 'px-3'}" aria-label="Main navigation" {@attach navIndicator}>
350
+ {#each navGroups as group, groupIndex}
351
+ <section class={groupIndex > 0 ? (collapsed ? "mt-2 border-t border-[var(--stor-border)] pt-2" : "mt-3") : ""} aria-label={group.label || "Pages"}>
352
+ {#if group.label && !collapsed}<p class="px-3 pb-1.5 pt-2 text-[10.5px] font-semibold uppercase tracking-[0.1em] text-[var(--stor-muted-text)]">{group.label}</p>{/if}
353
+ <div class="space-y-0.5">
354
+ {#each group.links as link}
355
+ {@const active = isLinkActive(link)}
356
+ {@const badge = attention.badge(link.id)}
357
+ {#if !collapsed}
358
+ <div>
359
+ <!-- The count sits on the sub-page that holds the work while the section is open. -->
360
+ <NavItem
361
+ href={linkHref(link.path)}
362
+ icon={link.icon}
363
+ label={link.label}
364
+ {active}
365
+ {collapsed}
366
+ {badge}
367
+ showCount={!(active && link.children)}
368
+ />
369
+ {#if link.children}
370
+ <!-- Sub-pages stay in the DOM and open in place; closed lists are invisible to focus. -->
371
+ <div class="stor-nav-sub" data-open={active ? '' : undefined}>
372
+ <div class="stor-nav-sub-in">
373
+ <ul class="stor-nav-children" aria-label={`${link.label} pages`}>
374
+ {#each link.children as child}
375
+ {@const childActive = active && isChildActive(link, child)}
376
+ {@const childBadge = child.badgeId ? attention.badge(child.badgeId) : null}
377
+ <li><a
378
+ href={childHref(link, child)}
379
+ class="stor-nav-child {childActive ? 'stor-nav-child-active' : ''}"
380
+ aria-current={childActive ? "page" : undefined}
381
+ aria-label={childBadge ? `${child.label}, ${childBadge.label}` : undefined}
382
+ >
383
+ <span class="min-w-0 flex-1 truncate">{child.label}</span>
384
+ {#if childBadge}
385
+ <span class="stor-nav-badge {childBadge.tone === 'negative' ? 'stor-nav-badge-negative' : 'stor-nav-badge-warning'}" aria-hidden="true">{childBadge.count}</span>
386
+ {/if}
387
+ </a></li>
388
+ {/each}
389
+ </ul>
390
+ </div>
391
+ </div>
392
+ {/if}
393
+ </div>
394
+ {:else if link.children}
395
+ <!-- Rail: a section with pages opens a flyout beside the rail. -->
396
+ <NavFlyout
397
+ label={link.label}
398
+ {badge}
399
+ items={link.children.map((child) => ({
400
+ child,
401
+ href: childHref(link, child),
402
+ active: active && isChildActive(link, child),
403
+ badge: child.badgeId ? attention.badge(child.badgeId) : null,
404
+ }))}
405
+ >
406
+ {#snippet trigger()}
407
+ <NavItem href={linkHref(link.path)} icon={link.icon} label={link.label} {active} {collapsed} {badge} />
408
+ {/snippet}
409
+ </NavFlyout>
410
+ {:else}
411
+ <!-- Rail: a section without pages names itself in a tooltip. -->
412
+ <Tooltip content={badge ? `${link.label} · ${badge.label}` : link.label} side="right" delayDuration={150}>
413
+ {#snippet trigger({ props })}
414
+ <NavItem {...props} href={linkHref(link.path)} icon={link.icon} label={link.label} {active} {collapsed} {badge} />
415
+ {/snippet}
416
+ </Tooltip>
417
+ {/if}
418
+ {/each}
419
+ </div>
420
+ </section>
421
+ {/each}
422
+ </nav>
423
+
424
+ <!-- Places sit above the line; the log, its sync state and the account sit below it. -->
425
+ <div class="shrink-0 border-t border-[var(--stor-border)] {collapsed ? 'flex flex-col items-center gap-0.5 px-2 py-2.5' : 'space-y-0.5 px-2.5 py-2'}">
426
+ <LogStatusChip
427
+ variant={collapsed ? "tile" : "foot"}
428
+ {currentLogName}
429
+ statusLine={syncLine}
430
+ statusTitle={syncTitle}
431
+ tone={syncTone}
432
+ {version}
433
+ pulsing={syncPhase === "syncing"}
434
+ disabled={!dataReady}
435
+ expanded={logTreeOpen}
436
+ onOpen={openLogChip}
437
+ />
438
+ <AccountMenu {userEmail} settings={settingsLinks} {collapsed} {isActive} />
439
+ </div>
440
+ <div class="hidden lg:block"><SidebarResizer width={sidebarWidth} folded={collapsed} onChange={onResizeSidebar} /></div>
441
+ </aside>
442
+
443
+ {#if logTreeOpen}
444
+ {#if LogPanel}
445
+ <LogPanel
446
+ show={logTreeOpen}
447
+ {logs}
448
+ {lineage}
449
+ {currentLogId}
450
+ {currentLogName}
451
+ onClose={closeLogTree}
452
+ onSwitch={onSwitchLog}
453
+ onCreate={onCreateLog}
454
+ onOpenSyncIssues={onOpenSyncIssues}
455
+ createDisabled={createLogDisabledReason !== undefined}
456
+ createDisabledReason={createLogDisabledReason}
457
+ />
458
+ {:else}
459
+ <Drawer show={logTreeOpen} title={currentLogName ?? 'Your log'} onClose={closeLogTree}>
460
+ {#if logPanelLoadFailed}
461
+ <p role="alert" class="text-sm text-[var(--stor-muted-text)]">Could not load log history.</p>
462
+ <Button variant="secondary" class="mt-3" onclick={() => window.location.reload()}>Reload</Button>
463
+ {:else}
464
+ <DataPlaceholder rows={6} label="Loading log history" />
465
+ {/if}
466
+ </Drawer>
467
+ {/if}
468
+ {/if}
469
+
470
+ {#snippet phoneTitleBlock(menu: boolean)}
471
+ <span class="flex min-w-0 flex-col items-start leading-tight">
472
+ {#if phoneSmall}<span class="max-w-full truncate text-[11.5px] font-medium text-[var(--stor-muted-text)]">{phoneSmall}</span>{/if}
473
+ <span class="stor-serif-title flex min-w-0 max-w-full items-center gap-0.5 text-[21px] leading-[1.15] text-[var(--stor-ink)]">
474
+ <span class="truncate">{phoneTitle}</span>
475
+ {#if menu}<IconChevronDown class="h-[18px] w-[18px] shrink-0 text-[var(--stor-muted-text)]" />{/if}
476
+ </span>
477
+ </span>
478
+ {/snippet}
479
+
480
+ <!-- The phone top bar: the section and sub-page on the left, the page's one figure and the log on the right. -->
481
+ <header class="stor-mobile-header fixed inset-x-0 top-0 z-50 flex h-[var(--stor-mobile-header-height)] items-center gap-2 pl-4 pr-2.5 pt-[env(safe-area-inset-top)] md:hidden" data-testid="phone-top-bar">
482
+ {#if phoneHeader.selection}
483
+ {@const selecting = phoneHeader.selection}
484
+ <p class="stor-serif-title mr-auto truncate text-[21px] text-[var(--stor-ink)]" role="status">{selecting.label}</p>
485
+ {#if selecting.onSelectAll}<button type="button" class="min-h-11 px-2 text-[15px] font-semibold text-[var(--stor-accent-text)]" onclick={selecting.onSelectAll}>Select all</button>{/if}
486
+ <button type="button" class="min-h-11 px-2 text-[15px] font-semibold text-[var(--stor-accent-text)]" onclick={selecting.onCancel}>Cancel</button>
487
+ {:else if !phoneSection}
488
+ <a href={linkHref("/")} class="flex min-w-0 shrink-0 items-center gap-2" title={tenant.title} aria-label={`Open ${tenant.title} start section`}>
489
+ <span class="min-[360px]:hidden"><span class="stor-brand-logo"><span class="stor-mark"></span></span></span>
490
+ <span class="stor-brand-title hidden min-w-0 truncate text-[var(--stor-ink)] min-[360px]:block"><BrandWordmark title={tenant.title} /></span>
491
+ </a>
492
+ {:else if phoneChildren.length > 0}
493
+ <DropdownMenu.Root>
494
+ <DropdownMenu.Trigger
495
+ class="-ml-1 flex min-w-0 shrink items-center rounded-xl px-1 py-0.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]"
496
+ aria-label={`${phoneSection.label}: ${phoneTitle}. Switch page`}
497
+ data-testid="phone-section-menu"
498
+ >
499
+ {@render phoneTitleBlock(true)}
500
+ </DropdownMenu.Trigger>
501
+ <DropdownMenu.Portal>
502
+ <DropdownMenu.Content side="bottom" align="start" sideOffset={6} loop class="stor-menu z-[var(--stor-layer-menu)] w-60 overflow-hidden p-1.5">
503
+ {#each phoneChildren as child (child.id)}
504
+ {@const childActive = phoneChild?.id === child.id}
505
+ {@const childBadge = child.badgeId ? attention.badge(child.badgeId) : null}
506
+ <DropdownMenu.Item
507
+ class="flex min-h-12 cursor-default items-center gap-2.5 rounded-xl px-3 text-[15.5px] outline-none data-[highlighted]:bg-[var(--stor-surface-muted)] {childActive ? 'bg-[var(--stor-accent-soft)] font-semibold text-[var(--stor-accent-strong)]' : 'font-medium text-[var(--stor-ink)]'}"
508
+ onSelect={() => void goto(childHref(phoneSection as NavLink, child))}
509
+ >
510
+ <span class="min-w-0 flex-1 truncate">{child.label}</span>
511
+ {#if childBadge}<span class="stor-dock-badge static {childBadge.tone === 'negative' ? 'stor-dock-badge-negative' : 'stor-dock-badge-warning'}" aria-label={childBadge.label}>{childBadge.count}</span>{/if}
512
+ {#if childActive}<IconCheck class="h-[18px] w-[18px] shrink-0" />{/if}
513
+ </DropdownMenu.Item>
514
+ {/each}
515
+ </DropdownMenu.Content>
516
+ </DropdownMenu.Portal>
517
+ </DropdownMenu.Root>
518
+ {:else}
519
+ <div class="flex min-w-0 shrink">{@render phoneTitleBlock(false)}</div>
520
+ {/if}
521
+ {#if !phoneHeader.selection}
522
+ <div class="ml-auto flex min-w-0 items-center justify-end gap-2">
523
+ {#if phoneHeader.glance}{@render phoneHeader.glance()}{/if}
524
+ {#if !phoneHeader.hideLog}
525
+ <LogStatusChip
526
+ variant={inCopiedLog ? "pill" : "round"}
527
+ {currentLogName}
528
+ statusLine={syncShort}
529
+ statusTitle={syncTitle}
530
+ tone={syncTone}
531
+ {version}
532
+ pulsing={syncPhase === "syncing"}
533
+ disabled={!dataReady}
534
+ expanded={logTreeOpen}
535
+ onOpen={openLogChip}
536
+ />
537
+ {/if}
538
+ </div>
539
+ {/if}
540
+ </header>
541
+
542
+ {#if isMoreOpen && NavMoreDialog}
543
+ <NavMoreDialog
544
+ open={isMoreOpen}
545
+ {userEmail}
546
+ links={moreNavLinks}
547
+ settings={settingsLinks}
548
+ {currentLogName}
549
+ {syncLabel}
550
+ {syncDescription}
551
+ statusLine={syncLine}
552
+ statusTitle={syncTitle}
553
+ tone={syncTone}
554
+ {version}
555
+ pulsing={syncPhase === "syncing"}
556
+ {dataReady}
557
+ {offlineAvailable}
558
+ onOpenChange={setMoreOpen}
559
+ onOpenSyncIssues={onOpenSyncIssues}
560
+ onOpenLogs={() => { setMoreOpen(false); openLogTree(); }}
561
+ />
562
+ {/if}
563
+
564
+ <nav
565
+ class="stor-mobile-nav fixed z-[55] grid md:hidden"
566
+ style={`grid-template-columns: repeat(${mobilePrimaryLinks.length + 1}, minmax(0, 1fr))`}
567
+ aria-label="Mobile navigation"
568
+ >
569
+ {#each mobilePrimaryLinks as link}
570
+ {@const Icon = link.icon}
571
+ <!-- While the More sheet is open it owns the selection, so only one dock item looks selected. -->
572
+ {@const active = isLinkActive(link) && !isMoreOpen}
573
+ {@const badge = attention.badge(link.id)}
574
+ <a
575
+ href={linkHref(link.path)}
576
+ class="group flex min-w-0 flex-col items-center justify-center gap-0.5 rounded-xl text-[10.5px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--stor-focus)] {active ? 'text-[var(--stor-accent-strong)]' : 'text-[var(--stor-muted-text)]'}"
577
+ aria-current={active ? "page" : undefined}
578
+ aria-label={badge ? `${link.label}, ${badge.label}` : undefined}
579
+ >
580
+ <span class="relative flex h-7 min-w-11 items-center justify-center rounded-full transition {active ? 'bg-[var(--stor-accent-soft)]' : 'group-active:bg-[var(--stor-surface-muted)]'}">
581
+ <Icon class="h-[18px] w-[18px]" />
582
+ {#if badge}<span class="stor-dock-badge {badge.tone === 'negative' ? 'stor-dock-badge-negative' : 'stor-dock-badge-warning'}" aria-hidden="true">{badge.count}</span>{/if}
583
+ </span>
584
+ <span class="max-w-full truncate px-0.5">{link.label}</span>
585
+ </a>
586
+ {/each}
587
+ <button
588
+ type="button"
589
+ class="group relative flex min-w-0 flex-col items-center justify-center gap-0.5 rounded-xl text-[10.5px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--stor-focus)] {isMoreActive ? 'text-[var(--stor-accent-strong)]' : 'text-[var(--stor-muted-text)]'}"
590
+ onclick={toggleMore}
591
+ aria-current={activeMoreLink ? "page" : undefined}
592
+ aria-label={moreButtonAriaLabel}
593
+ aria-expanded={isMoreOpen}
594
+ >
595
+ <span class="relative flex h-7 min-w-11 items-center justify-center rounded-full transition {isMoreActive ? 'bg-[var(--stor-accent-soft)]' : 'group-active:bg-[var(--stor-surface-muted)]'}">
596
+ <IconDots class="h-[18px] w-[18px]" />
597
+ {#if showSyncDot}
598
+ <span class="absolute right-1 top-0.5 h-2 w-2 rounded-full shadow-[0_0_0_2px_var(--stor-surface-raised)] {syncPresentation.tone === 'danger' ? 'bg-[var(--stor-negative)]' : 'bg-[var(--stor-warning)]'}" aria-hidden="true"></span>
599
+ {/if}
600
+ </span>
601
+ {moreButtonLabel}
602
+ </button>
603
+ </nav>