@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,293 @@
1
+ <script lang="ts">
2
+ import type { LogSummary } from '@hearthlog/runtime/ledger/index.js';
3
+ import type { HistoryPoint } from '@hearthlog/runtime/history/contracts.js';
4
+ import type { LogNode } from '@hearthlog/runtime/history/lineage.js';
5
+ import { logMap, type MapSpan } from '@hearthlog/runtime/history/log-map.js';
6
+ import { formatForkPoint } from '@hearthlog/runtime/history/log-format.js';
7
+
8
+ let {
9
+ nodes,
10
+ logs,
11
+ currentLogId,
12
+ points = [],
13
+ span,
14
+ openActionId = null,
15
+ range = null,
16
+ disabled = false,
17
+ onOpen,
18
+ onPick,
19
+ onReveal,
20
+ }: {
21
+ /** Shown logs in tree order. */
22
+ nodes: LogNode[];
23
+ /** Every log, to place copied actions on the source's line even past hidden logs. */
24
+ logs: LogSummary[];
25
+ currentLogId: string | null;
26
+ /** The current log's actions within the map. */
27
+ points?: HistoryPoint[];
28
+ /** The time the map covers. */
29
+ span: MapSpan;
30
+ /** The action open in the list. */
31
+ openActionId?: string | null;
32
+ /** The time span of the actions on screen in the list below. */
33
+ range?: { from: number; to: number } | null;
34
+ disabled?: boolean;
35
+ /** Open another log. */
36
+ onOpen: (logId: string) => void;
37
+ /** Open an action in the list. */
38
+ onPick: (actionId: string) => void;
39
+ /** Scroll the list to an action without opening it. */
40
+ onReveal: (actionId: string) => void;
41
+ } = $props();
42
+
43
+ // One clock per panel visit; the map does not need to tick.
44
+ const now = Date.now();
45
+ const model = $derived(logMap(nodes, { currentLogId, now, span }));
46
+ const currentRow = $derived(model.rows.find(row => row.node.log.id === currentLogId) ?? null);
47
+
48
+ /** Each action on the line of the log it was made in: copied ones on their source's line. */
49
+ const marks = $derived.by(() => {
50
+ if (!currentRow) return [];
51
+ const byId = new Map(logs.map(log => [log.id, log]));
52
+ const rowY = new Map(model.rows.map(row => [row.node.log.id, row.y]));
53
+ return points.map(point => {
54
+ let log = byId.get(currentLogId ?? '');
55
+ let y = currentRow.y;
56
+ while (log?.sourceLogId && point.version !== null && point.version <= (log.sourceVersion ?? 0)) {
57
+ log = byId.get(log.sourceLogId);
58
+ const shown = log ? rowY.get(log.id) : undefined;
59
+ if (shown !== undefined) y = shown;
60
+ }
61
+ return { ...point, x: model.x(point.timestamp), y, copied: y !== currentRow.y };
62
+ });
63
+ });
64
+
65
+ const bracket = $derived.by(() => {
66
+ if (!currentRow || !range) return null;
67
+ const from = model.x(range.from) - 4;
68
+ const to = model.x(range.to) + 4;
69
+ return { x: from, width: Math.max(8, to - from) };
70
+ });
71
+
72
+ let svg = $state<SVGSVGElement | null>(null);
73
+ let box = $state<HTMLDivElement | null>(null);
74
+ let hover = $state<(typeof marks)[number] | null>(null);
75
+ let scrubbing = $state(false);
76
+ let pressed: string | null = null;
77
+ let suppressOpen = false;
78
+ let frame = 0;
79
+
80
+ function path(row: (typeof model.rows)[number]): string {
81
+ if (!row.fork) return `M${row.x0} ${row.y} H${row.x1}`;
82
+ const bend = Math.min(6, row.y - row.fork.y);
83
+ return `M${row.fork.x} ${row.fork.y} V${row.y - bend} Q${row.fork.x} ${row.y} ${row.fork.x + bend} ${row.y} H${Math.max(row.x1, row.fork.x + bend)}`;
84
+ }
85
+
86
+ function openable(node: LogNode): boolean {
87
+ return node.log.id !== currentLogId && node.log.status !== 'preparing';
88
+ }
89
+
90
+ /** The pointer in map units. */
91
+ function local(event: PointerEvent): { x: number; y: number } | null {
92
+ const rect = svg?.getBoundingClientRect();
93
+ if (!rect || rect.width === 0) return null;
94
+ return {
95
+ x: ((event.clientX - rect.left) / rect.width) * model.width,
96
+ y: ((event.clientY - rect.top) / rect.height) * model.height,
97
+ };
98
+ }
99
+
100
+ /** The mark under the pointer, if one is close enough to aim at. */
101
+ function markAt(at: { x: number; y: number }, reach: number) {
102
+ let best: (typeof marks)[number] | null = null;
103
+ let distance = reach * reach;
104
+ // Marks come newest first; within a stack the newest answers unless another is clearly closer.
105
+ for (const mark of marks) {
106
+ const d = (mark.x - at.x) ** 2 + ((mark.y - at.y) * 1.5) ** 2;
107
+ if (best ? d < distance - 1 : d <= distance) { best = mark; distance = d; }
108
+ }
109
+ return best;
110
+ }
111
+
112
+ /** The action closest in time to a place on the axis. */
113
+ function nearestInTime(x: number) {
114
+ let best: (typeof marks)[number] | null = null;
115
+ for (const mark of marks) if (!best || Math.abs(mark.x - x) < Math.abs(best.x - x)) best = mark;
116
+ return best;
117
+ }
118
+
119
+ function scrubTo(x: number): void {
120
+ if (frame) cancelAnimationFrame(frame);
121
+ frame = requestAnimationFrame(() => {
122
+ frame = 0;
123
+ const mark = nearestInTime(x);
124
+ if (mark) { hover = mark; onReveal(mark.id); }
125
+ });
126
+ }
127
+
128
+ function reach(event: PointerEvent): number {
129
+ return event.pointerType === 'touch' ? 16 : 9;
130
+ }
131
+
132
+ function pointerdown(event: PointerEvent): void {
133
+ const at = local(event);
134
+ if (!at || disabled || event.button !== 0) return;
135
+ const mark = markAt(at, reach(event));
136
+ if (mark) {
137
+ pressed = mark.id;
138
+ suppressOpen = true;
139
+ return;
140
+ }
141
+ suppressOpen = false;
142
+ if ((event.target as Element | null)?.closest('[data-log]')) return;
143
+ scrubbing = true;
144
+ svg?.setPointerCapture(event.pointerId);
145
+ scrubTo(at.x);
146
+ }
147
+
148
+ function pointermove(event: PointerEvent): void {
149
+ const at = local(event);
150
+ if (!at) return;
151
+ if (scrubbing) { scrubTo(at.x); return; }
152
+ hover = markAt(at, reach(event));
153
+ }
154
+
155
+ function pointerup(event: PointerEvent): void {
156
+ const at = local(event);
157
+ if (pressed && at && markAt(at, reach(event))?.id === pressed) onPick(pressed);
158
+ pressed = null;
159
+ if (scrubbing) {
160
+ scrubbing = false;
161
+ if (svg?.hasPointerCapture(event.pointerId)) svg.releasePointerCapture(event.pointerId);
162
+ }
163
+ }
164
+
165
+ function openLog(logId: string): void {
166
+ if (suppressOpen) { suppressOpen = false; return; }
167
+ if (!disabled) onOpen(logId);
168
+ }
169
+
170
+ function keydown(event: KeyboardEvent, node: LogNode): void {
171
+ if (event.key !== 'Enter' && event.key !== ' ') return;
172
+ event.preventDefault();
173
+ if (openable(node) && !disabled) onOpen(node.log.id);
174
+ }
175
+
176
+ // The wheel over the map scrolls the list, as a minimap does.
177
+ $effect(() => {
178
+ const element = box;
179
+ if (!element) return;
180
+ const wheel = (event: WheelEvent) => {
181
+ const body = element.closest('[data-stor-drawer]')?.querySelector<HTMLElement>('.stor-drawer-body');
182
+ if (!body) return;
183
+ event.preventDefault();
184
+ body.scrollBy({ top: event.deltaY + event.deltaX });
185
+ };
186
+ element.addEventListener('wheel', wheel, { passive: false });
187
+ return () => element.removeEventListener('wheel', wheel);
188
+ });
189
+
190
+ $effect(() => () => { if (frame) cancelAnimationFrame(frame); });
191
+
192
+ /** Other actions drawn on the same spot, which the label can only count. */
193
+ const nearby = $derived(hover ? marks.filter(mark => mark.id !== hover!.id && mark.y === hover!.y && Math.abs(mark.x - hover!.x) < 2.5).length : 0);
194
+
195
+ const tooltip = $derived(hover ? {
196
+ left: (hover.x / model.width) * 100,
197
+ top: (hover.y / model.height) * 100,
198
+ shift: hover.x < model.width * 0.25 ? '-8px' : hover.x > model.width * 0.75 ? 'calc(-100% + 8px)' : '-50%',
199
+ } : null);
200
+ </script>
201
+
202
+ <div bind:this={box} class="log-map rounded-2xl bg-[var(--stor-surface-muted)] px-1.5 pt-0.5">
203
+ <div class="relative">
204
+ <svg
205
+ bind:this={svg}
206
+ viewBox="0 0 {model.width} {model.height}"
207
+ class="block w-full touch-none select-none {scrubbing ? 'cursor-grabbing' : hover ? 'cursor-pointer' : 'cursor-ew-resize'}"
208
+ role="group"
209
+ aria-label="Map of your logs over time. Drag along it to move through the list."
210
+ onpointerdown={pointerdown}
211
+ onpointermove={pointermove}
212
+ onpointerup={pointerup}
213
+ onpointercancel={pointerup}
214
+ onpointerleave={() => { if (!scrubbing) hover = null; }}
215
+ >
216
+ <line class="axis" x1="8" x2={model.width - 8} y1={model.axisY} y2={model.axisY} />
217
+ {#each model.ticks as tick (tick.label + tick.x)}
218
+ <text class="tick" x={tick.x} y={model.axisY + 11} text-anchor={tick.anchor}>{tick.label}</text>
219
+ {/each}
220
+ {#if bracket}
221
+ <rect class="range" x={bracket.x} y="4" width={bracket.width} height={model.axisY - 4} rx="5" />
222
+ {/if}
223
+ {#each model.rows as row (row.node.log.id)}
224
+ {@const log = row.node.log}
225
+ {@const current = log.id === currentLogId}
226
+ {#if row.clipped}
227
+ <line class="lead-in" x1="2" x2={row.x0 + 10} y1={row.y} y2={row.y} />
228
+ {/if}
229
+ {#if openable(row.node)}
230
+ <g
231
+ class="log"
232
+ class:hidden-log={Boolean(log.hiddenAt)}
233
+ data-log={log.id}
234
+ role="button"
235
+ tabindex="0"
236
+ aria-label={`Open ${log.name}`}
237
+ aria-disabled={disabled}
238
+ onclick={() => openLog(log.id)}
239
+ onkeydown={(event) => keydown(event, row.node)}
240
+ >
241
+ <path class="hit" d={path(row)} />
242
+ <path class="line" d={path(row)} />
243
+ <text class="name" x={row.label.x} y={row.y - 6} text-anchor={row.label.anchor}>{log.name}</text>
244
+ </g>
245
+ {:else}
246
+ <g class="log" class:current class:preparing={log.status === 'preparing'} aria-hidden="true">
247
+ <path class="line" d={path(row)} />
248
+ <text class="name" x={row.label.x} y={row.y - 6} text-anchor={row.label.anchor}>{log.name}{log.status === 'preparing' ? ' · preparing' : ''}</text>
249
+ </g>
250
+ {/if}
251
+ {/each}
252
+ <g aria-hidden="true">
253
+ {#each marks as mark (mark.id)}
254
+ <circle class="mark" class:copied={mark.copied} cx={mark.x} cy={mark.y} r="2.3" />
255
+ {/each}
256
+ {#each marks.filter(mark => mark.id === openActionId || mark.id === hover?.id) as mark (mark.id)}
257
+ <circle class="mark-on" cx={mark.x} cy={mark.y} r="4.2" />
258
+ {/each}
259
+ </g>
260
+ </svg>
261
+ {#if hover && tooltip}
262
+ <div
263
+ class="pointer-events-none absolute z-10 w-max max-w-[16rem] rounded-lg bg-[var(--stor-ink)] px-2 py-1 text-xs leading-snug text-[var(--stor-surface)] shadow-[var(--stor-shadow-md)]"
264
+ style="left: {tooltip.left}%; top: {tooltip.top}%; transform: translate({tooltip.shift}, calc(-100% - 8px));"
265
+ aria-hidden="true"
266
+ >
267
+ <span class="block truncate font-semibold">{hover.label}</span>
268
+ <span class="block whitespace-nowrap opacity-75">{formatForkPoint(hover.timestamp)}{hover.copied ? ' · copied' : ''}{nearby ? ` · +${nearby} nearby` : ''}</span>
269
+ </div>
270
+ {/if}
271
+ </div>
272
+ </div>
273
+
274
+ <style>
275
+ .axis { stroke: var(--stor-border); stroke-width: 1; }
276
+ .tick { font-size: 9.5px; fill: var(--stor-muted-text); }
277
+ .line { fill: none; stroke: color-mix(in srgb, var(--stor-muted-text) 75%, transparent); stroke-width: 2; stroke-linecap: round; }
278
+ .hit { fill: none; stroke: transparent; stroke-width: 12; }
279
+ .name { font-size: 10.5px; font-weight: 550; fill: var(--stor-muted-text); }
280
+ .log[role='button'] { cursor: pointer; outline: none; }
281
+ .log[role='button']:hover .line, .log[role='button']:focus-visible .line { stroke: var(--stor-ink); }
282
+ .log[role='button']:hover .name, .log[role='button']:focus-visible .name { fill: var(--stor-ink); }
283
+ .log[role='button']:focus-visible .hit { stroke: color-mix(in srgb, var(--stor-focus) 22%, transparent); }
284
+ .current .line { stroke: var(--stor-ink); stroke-width: 3; }
285
+ .current .name { fill: var(--stor-ink); font-weight: 700; }
286
+ .preparing .line { stroke-dasharray: 3 4; }
287
+ .hidden-log { opacity: 0.55; }
288
+ .lead-in { stroke: color-mix(in srgb, var(--stor-muted-text) 60%, transparent); stroke-width: 2; stroke-dasharray: 2 3; }
289
+ .mark { fill: var(--stor-ink); stroke: var(--stor-surface-muted); stroke-width: 1.2; pointer-events: none; }
290
+ .mark.copied { fill: var(--stor-muted-text); }
291
+ .mark-on { fill: var(--stor-surface); stroke: var(--stor-ink); stroke-width: 2; pointer-events: none; }
292
+ .range { fill: color-mix(in srgb, var(--stor-ink) 7%, transparent); stroke: color-mix(in srgb, var(--stor-ink) 30%, transparent); stroke-width: 1; pointer-events: none; }
293
+ </style>
@@ -0,0 +1,124 @@
1
+ <script lang="ts">
2
+ import type { LogNode } from '@hearthlog/runtime/history/lineage.js';
3
+ import { ownChanges } from '@hearthlog/runtime/history/lineage.js';
4
+ import { formatLogDay } from '@hearthlog/runtime/history/log-format.js';
5
+ import { formatNumber } from '@hearthlog/ui/utils/number-format.js';
6
+ import IconCheck from '~icons/mdi/check';
7
+ import IconEyeOff from '~icons/mdi/eye-off-outline';
8
+ import IconEye from '~icons/mdi/eye-outline';
9
+
10
+ let {
11
+ nodes,
12
+ currentLogId,
13
+ switchingLogId = null,
14
+ busyLogId = null,
15
+ hideDisabledReason,
16
+ onOpen,
17
+ onHide,
18
+ }: {
19
+ nodes: LogNode[];
20
+ currentLogId: string | null;
21
+ switchingLogId?: string | null;
22
+ busyLogId?: string | null;
23
+ /** Why hiding is unavailable, such as being offline. */
24
+ hideDisabledReason?: string;
25
+ onOpen: (logId: string) => void;
26
+ onHide: (logId: string, hidden: boolean) => void;
27
+ } = $props();
28
+
29
+ // From the fourth level down, logs stop indenting; their meta line names the source.
30
+ const FLAT_DEPTH = 3;
31
+
32
+ function meta(node: LogNode): string {
33
+ const log = node.log;
34
+ const parts: string[] = [];
35
+ if (log.hiddenAt) parts.push('Hidden');
36
+ if (log.sourceLogId) {
37
+ parts.push(`from ${log.sourceLogName ?? 'another log'}`);
38
+ const forked = formatLogDay(log.sourceTimestamp ?? log.createdAt);
39
+ if (forked) parts.push(forked);
40
+ if (log.status === 'preparing') {
41
+ parts.push('preparing its files');
42
+ } else {
43
+ const own = ownChanges(log);
44
+ parts.push(own === 0 ? 'no changes of its own' : `${formatNumber(own)} own ${own === 1 ? 'change' : 'changes'}`);
45
+ if (own > 0 && log.lastUpdatedAt) parts.push(formatLogDay(log.lastUpdatedAt));
46
+ }
47
+ } else if (log.headVersion === 0) {
48
+ parts.push('Empty');
49
+ const created = formatLogDay(log.createdAt);
50
+ if (created) parts.push(`created ${created}`);
51
+ } else {
52
+ parts.push(`${formatNumber(log.headVersion)} ${log.headVersion === 1 ? 'change' : 'changes'}`);
53
+ if (log.lastUpdatedAt) parts.push(`last change ${formatLogDay(log.lastUpdatedAt)}`);
54
+ }
55
+ return parts.join(' · ');
56
+ }
57
+ </script>
58
+
59
+ {#snippet branch(items: LogNode[], depth: number)}
60
+ <ul class="log-tree" class:nested={depth > 0 && depth <= FLAT_DEPTH}>
61
+ {#each items as node (node.log.id)}
62
+ {@const log = node.log}
63
+ {@const current = log.id === currentLogId}
64
+ <li>
65
+ <div class="flex items-start gap-1">
66
+ <button
67
+ type="button"
68
+ class="flex min-h-12 min-w-0 flex-1 items-start gap-3 rounded-xl px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] disabled:cursor-default {current
69
+ ? 'bg-[var(--stor-surface-raised)] shadow-[0_0_0_1px_color-mix(in_srgb,var(--stor-border)_70%,transparent),0_1px_3px_rgb(16_42_67/0.07)]'
70
+ : 'hover:bg-[var(--stor-surface-hover)]'} {log.hiddenAt ? 'opacity-70' : ''}"
71
+ aria-current={current ? 'true' : undefined}
72
+ aria-label={current ? `${log.name}, current log` : log.status === 'preparing' ? `${log.name}, preparing` : `Open ${log.name}`}
73
+ disabled={current || log.status === 'preparing' || switchingLogId !== null}
74
+ onclick={() => onOpen(log.id)}
75
+ >
76
+ <span class="min-w-0 flex-1">
77
+ <span class="block break-words text-sm font-semibold text-[var(--stor-ink)]">{log.name}</span>
78
+ <span class="mt-0.5 block break-words text-xs text-[var(--stor-muted-text)]">{meta(node)}</span>
79
+ </span>
80
+ {#if current}
81
+ <span class="mt-0.5 flex shrink-0 items-center gap-1 text-xs font-semibold text-[var(--stor-ink)]"><IconCheck class="h-4 w-4" />Current</span>
82
+ {:else if log.status === 'preparing'}
83
+ <span class="mt-0.5 shrink-0 rounded-full bg-[var(--stor-info-soft)] px-2 py-0.5 text-xs font-medium text-[var(--stor-info)]">Preparing</span>
84
+ {:else if switchingLogId === log.id}
85
+ <span class="mt-0.5 shrink-0 text-xs text-[var(--stor-muted-text)]" role="status">Opening…</span>
86
+ {/if}
87
+ </button>
88
+ {#if !current && log.status !== 'preparing'}
89
+ <button
90
+ type="button"
91
+ class="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-[var(--stor-muted-text)] hover:bg-[var(--stor-surface-hover)] hover:text-[var(--stor-ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] disabled:opacity-40 sm:h-9 sm:w-9"
92
+ aria-label={log.hiddenAt ? `Show ${log.name}` : `Hide ${log.name}`}
93
+ title={hideDisabledReason ?? (log.hiddenAt ? 'Show in the tree' : 'Hide from the tree')}
94
+ disabled={Boolean(hideDisabledReason) || busyLogId !== null}
95
+ onclick={() => onHide(log.id, !log.hiddenAt)}
96
+ >
97
+ {#if log.hiddenAt}<IconEye class="h-4 w-4" />{:else}<IconEyeOff class="h-4 w-4" />{/if}
98
+ </button>
99
+ {/if}
100
+ </div>
101
+ {#if node.children.length > 0}
102
+ {@render branch(node.children, depth + 1)}
103
+ {/if}
104
+ </li>
105
+ {/each}
106
+ </ul>
107
+ {/snippet}
108
+
109
+ <nav aria-label="Your logs">
110
+ {@render branch(nodes, 0)}
111
+ </nav>
112
+
113
+ <style>
114
+ .log-tree { display: flex; flex-direction: column; gap: 2px; margin: 0; padding: 0; list-style: none; }
115
+ .log-tree.nested { position: relative; margin-top: 2px; padding-left: 22px; }
116
+ .log-tree.nested > li { position: relative; }
117
+ /* An elbow from the parent's line to each fork, and the line on to later siblings. */
118
+ .log-tree.nested > li::before {
119
+ content: ''; position: absolute; left: -12px; top: 0; width: 10px; height: 26px;
120
+ border-left: 1.5px solid var(--stor-border); border-bottom: 1.5px solid var(--stor-border); border-bottom-left-radius: 8px;
121
+ }
122
+ .log-tree.nested > li::after { content: ''; position: absolute; left: -12px; top: 0; bottom: 0; border-left: 1.5px solid var(--stor-border); }
123
+ .log-tree.nested > li:last-child::after { display: none; }
124
+ </style>
@@ -0,0 +1,111 @@
1
+ <script lang="ts">
2
+ import { Popover } from 'bits-ui';
3
+ import type { Component } from 'svelte';
4
+ import { ACCESS_LOGOUT_ENDPOINT, LOCAL_USER_EMAIL } from '@hearthlog/runtime/access/user-session.js';
5
+ import { beginAccessLogout } from '@hearthlog/runtime/access/session.js';
6
+ import Tooltip from '@hearthlog/ui/components/ui/Tooltip.svelte';
7
+ import AppearanceOptions from './AppearanceOptions.svelte';
8
+ import IconDots from '~icons/mdi/dots-vertical';
9
+ import IconLogout from '~icons/mdi/logout';
10
+
11
+ interface SettingsLink {
12
+ path: string;
13
+ label: string;
14
+ subtitle: string;
15
+ icon: Component;
16
+ }
17
+
18
+ let {
19
+ userEmail,
20
+ settings,
21
+ collapsed = false,
22
+ isActive,
23
+ }: {
24
+ userEmail: string;
25
+ settings: SettingsLink[];
26
+ collapsed?: boolean;
27
+ isActive: (path: string) => boolean;
28
+ } = $props();
29
+
30
+ let open = $state(false);
31
+ const initial = $derived((userEmail || '?').trim().charAt(0).toUpperCase());
32
+ const canLogOut = $derived(Boolean(userEmail) && userEmail !== LOCAL_USER_EMAIL);
33
+
34
+ function logout(event: MouseEvent): void {
35
+ // Full document navigation through Access so the edge clears the session.
36
+ event.preventDefault();
37
+ open = false;
38
+ beginAccessLogout('/');
39
+ }
40
+ </script>
41
+
42
+ {#snippet accountTrigger(tip: Record<string, unknown> = {})}
43
+ <Popover.Trigger
44
+ {...tip}
45
+ class="group flex items-center rounded-xl text-left transition hover:bg-[color-mix(in_srgb,var(--stor-ink),transparent_95%)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] {collapsed ? 'mx-auto h-11 w-11 justify-center' : 'w-full gap-2.5 p-1.5'}"
46
+ aria-label={`Account and settings, ${userEmail}`}
47
+ data-testid={collapsed ? 'account-menu-collapsed' : 'account-menu'}
48
+ >
49
+ <span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[linear-gradient(140deg,var(--stor-accent-soft),color-mix(in_srgb,var(--stor-accent),var(--stor-surface)_72%))] text-xs font-bold text-[var(--stor-accent-strong)]" aria-hidden="true">{initial}</span>
50
+ {#if !collapsed}
51
+ <span class="min-w-0 flex-1 truncate text-xs font-semibold text-[var(--stor-ink)]">{userEmail}</span>
52
+ <IconDots class="h-4 w-4 shrink-0 text-[var(--stor-muted-text)]" />
53
+ {/if}
54
+ </Popover.Trigger>
55
+ {/snippet}
56
+
57
+ <Popover.Root bind:open>
58
+ {#if collapsed}
59
+ <!-- The rail has no label, so the avatar names itself in a tooltip. -->
60
+ <Tooltip content={`${userEmail} · Settings and appearance`} side="right" delayDuration={150}>
61
+ {#snippet trigger({ props })}{@render accountTrigger(props)}{/snippet}
62
+ </Tooltip>
63
+ {:else}
64
+ {@render accountTrigger()}
65
+ {/if}
66
+ <Popover.Portal>
67
+ <Popover.Content
68
+ side={collapsed ? 'right' : 'top'}
69
+ align={collapsed ? 'end' : 'start'}
70
+ sideOffset={10}
71
+ class="stor-menu z-[var(--stor-layer-menu)] w-72 p-2 outline-none"
72
+ >
73
+ <div class="flex items-center gap-2.5 px-2 pb-2 pt-1">
74
+ <span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[var(--stor-accent-soft)] text-sm font-bold text-[var(--stor-accent-strong)]" aria-hidden="true">{initial}</span>
75
+ <span class="min-w-0 truncate text-sm font-semibold text-[var(--stor-ink)]" data-testid="current-user-email">{userEmail}</span>
76
+ </div>
77
+ <h2 class="px-2 pb-1.5 pt-2 text-[10.5px] font-semibold uppercase tracking-[0.1em] text-[var(--stor-muted-text)]">Appearance</h2>
78
+ <AppearanceOptions compact />
79
+ {#if settings.length}
80
+ <h2 class="px-2 pb-1 pt-3 text-[10.5px] font-semibold uppercase tracking-[0.1em] text-[var(--stor-muted-text)]">Settings</h2>
81
+ {#each settings as link}
82
+ {@const Icon = link.icon}
83
+ <a
84
+ href={link.path}
85
+ class="flex items-center gap-2.5 rounded-xl p-2 transition hover:bg-[var(--stor-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]"
86
+ aria-current={isActive(link.path) ? 'page' : undefined}
87
+ onclick={() => (open = false)}
88
+ >
89
+ <span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--stor-surface-muted)] text-[var(--stor-muted-text)]"><Icon class="h-4 w-4" /></span>
90
+ <span class="min-w-0">
91
+ <span class="block text-sm font-semibold text-[var(--stor-ink)]">{link.label}</span>
92
+ <span class="block truncate text-xs text-[var(--stor-muted-text)]">{link.subtitle}</span>
93
+ </span>
94
+ </a>
95
+ {/each}
96
+ {/if}
97
+ {#if canLogOut}
98
+ <div class="mx-1 my-2 h-px bg-[var(--stor-border)]"></div>
99
+ <a
100
+ href={ACCESS_LOGOUT_ENDPOINT}
101
+ onclick={logout}
102
+ class="flex items-center gap-2.5 rounded-xl p-2 text-sm font-semibold text-[var(--stor-ink)] transition hover:bg-[var(--stor-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]"
103
+ data-testid="logout-link"
104
+ >
105
+ <span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--stor-surface-muted)] text-[var(--stor-muted-text)]"><IconLogout class="h-4 w-4" /></span>
106
+ Log out
107
+ </a>
108
+ {/if}
109
+ </Popover.Content>
110
+ </Popover.Portal>
111
+ </Popover.Root>
@@ -0,0 +1,78 @@
1
+ <script lang="ts">
2
+ import { appearance } from "@hearthlog/runtime/theme/appearance.svelte.js";
3
+ import type { ThemePreference } from "@hearthlog/contracts/tenant/theme";
4
+ import IconDesktop from "~icons/mdi/monitor";
5
+ import IconLight from "~icons/mdi/white-balance-sunny";
6
+ import IconDark from "~icons/mdi/weather-night";
7
+
8
+ let { compact = false }: { compact?: boolean } = $props();
9
+
10
+ const options: {
11
+ value: ThemePreference;
12
+ label: string;
13
+ description: string;
14
+ icon: typeof IconDesktop;
15
+ }[] = [
16
+ { value: "system", label: "System", description: "Match this device", icon: IconDesktop },
17
+ { value: "light", label: "Light", description: "Always use light mode", icon: IconLight },
18
+ { value: "dark", label: "Dark", description: "Always use dark mode", icon: IconDark },
19
+ ];
20
+ </script>
21
+
22
+ {#if compact}
23
+ <fieldset class="grid grid-cols-3 gap-1 rounded-xl bg-[var(--stor-surface-muted)] p-1" aria-label="Appearance">
24
+ <legend class="sr-only">Appearance</legend>
25
+ {#each options as option}
26
+ {@const Icon = option.icon}
27
+ {@const selected = appearance.preference === option.value}
28
+ <label
29
+ class="flex min-h-11 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg px-1 py-1.5 text-xs font-semibold transition has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-[var(--stor-focus)] sm:min-h-14 {selected
30
+ ? 'bg-[var(--stor-surface-raised)] text-[var(--stor-ink)] shadow-[var(--stor-shadow-sm)]'
31
+ : 'text-[var(--stor-muted-text)] hover:text-[var(--stor-ink)]'}"
32
+ >
33
+ <input
34
+ class="sr-only"
35
+ type="radio"
36
+ name="stor-appearance-compact"
37
+ value={option.value}
38
+ checked={selected}
39
+ onchange={() => appearance.setPreference(option.value)}
40
+ />
41
+ <Icon class="h-4 w-4" />
42
+ <span>{option.label}</span>
43
+ </label>
44
+ {/each}
45
+ </fieldset>
46
+ {:else}
47
+ <fieldset class="grid gap-1" aria-label="Appearance">
48
+ <legend class="sr-only">Appearance</legend>
49
+ {#each options as option}
50
+ {@const Icon = option.icon}
51
+ <label
52
+ class="flex min-h-11 cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2 text-left transition hover:bg-[var(--stor-surface-hover)]"
53
+ class:bg-[var(--stor-selected)]={appearance.preference === option.value}
54
+ >
55
+ <input
56
+ class="sr-only"
57
+ type="radio"
58
+ name="stor-appearance"
59
+ value={option.value}
60
+ checked={appearance.preference === option.value}
61
+ onchange={() => appearance.setPreference(option.value)}
62
+ />
63
+ <span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--stor-surface-raised)] text-[var(--stor-accent-strong)] ring-1 ring-[var(--stor-border)]">
64
+ <Icon class="h-4 w-4" />
65
+ </span>
66
+ <span class="min-w-0">
67
+ <span class="block text-sm font-semibold text-[var(--stor-ink)]">{option.label}</span>
68
+ <span class="block text-xs text-[var(--stor-muted-text)]">{option.description}</span>
69
+ </span>
70
+ <span
71
+ class="ml-auto h-2.5 w-2.5 rounded-full border border-[var(--stor-field-border)]"
72
+ class:bg-[var(--stor-action)]={appearance.preference === option.value}
73
+ aria-hidden="true"
74
+ ></span>
75
+ </label>
76
+ {/each}
77
+ </fieldset>
78
+ {/if}
@@ -0,0 +1,12 @@
1
+ <script lang="ts">
2
+ import { wordmarkParts } from "@hearthlog/contracts/tenant/brand";
3
+
4
+ let { title }: { title: string } = $props();
5
+ const parts = $derived(wordmarkParts(title));
6
+ </script>
7
+
8
+ {#if parts}
9
+ <span class="stor-wordmark" role="img" aria-label={title}>{parts.before}<span class="stor-mark"></span>{parts.after}</span>
10
+ {:else}
11
+ {title}
12
+ {/if}
@@ -0,0 +1,34 @@
1
+ <script lang="ts">
2
+ let {
3
+ message,
4
+ onDismiss,
5
+ }: {
6
+ message: string;
7
+ onDismiss: () => void;
8
+ } = $props();
9
+ </script>
10
+
11
+ <div
12
+ class="fixed bottom-4 right-4 bg-red-500 text-white px-4 py-3 rounded-lg shadow-[var(--stor-elevation-3)] flex items-center space-x-3 max-w-sm z-50"
13
+ >
14
+ <span class="flex-1 text-sm">{message}</span>
15
+ <button
16
+ onclick={onDismiss}
17
+ class="shrink-0 text-white hover:text-red-100 focus:outline-none"
18
+ aria-label="Dismiss error"
19
+ >
20
+ <svg
21
+ class="w-5 h-5"
22
+ fill="none"
23
+ stroke="currentColor"
24
+ viewBox="0 0 24 24"
25
+ >
26
+ <path
27
+ stroke-linecap="round"
28
+ stroke-linejoin="round"
29
+ stroke-width="2"
30
+ d="M6 18L18 6M6 6l12 12"
31
+ ></path>
32
+ </svg>
33
+ </button>
34
+ </div>