@hearthlog/shell 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/LICENSE +235 -0
  2. package/package.json +51 -3
  3. package/src/app.template.html +184 -0
  4. package/src/components/ai/DecisionReviewSheet.svelte +226 -0
  5. package/src/components/ai/SuggestButton.svelte +23 -0
  6. package/src/components/ai/SuggestionChip.svelte +13 -0
  7. package/src/components/ai/SuggestionMark.svelte +15 -0
  8. package/src/components/calendar/CalendarFeedsDrawer.svelte +414 -0
  9. package/src/components/calendar/CalendarMonthSummary.svelte +122 -0
  10. package/src/components/calendar/calendar-weeks.svelte.ts +27 -0
  11. package/src/components/calendar/source-icons.ts +19 -0
  12. package/src/components/data-table/DataTableValue.svelte +14 -0
  13. package/src/components/data-table/EntityActionsCell.svelte +36 -0
  14. package/src/components/data-table/EntityRefsCell.svelte +58 -0
  15. package/src/components/data-table/EntityValue.svelte +42 -0
  16. package/src/components/data-table/entity-columns.ts +87 -0
  17. package/src/components/data-table/sqlite-columns.ts +42 -0
  18. package/src/components/entities/CalendarSourceFields.svelte +149 -0
  19. package/src/components/history/ChangeRecord.svelte +30 -0
  20. package/src/components/history/ForkComposer.svelte +174 -0
  21. package/src/components/history/HistoryScrubber.svelte +245 -0
  22. package/src/components/history/LogHistory.svelte +334 -0
  23. package/src/components/history/LogMap.svelte +293 -0
  24. package/src/components/history/LogTree.svelte +124 -0
  25. package/src/components/layout/AccountMenu.svelte +111 -0
  26. package/src/components/layout/AppearanceOptions.svelte +78 -0
  27. package/src/components/layout/BrandWordmark.svelte +12 -0
  28. package/src/components/layout/ErrorToast.svelte +34 -0
  29. package/src/components/layout/LogCreateDrawer.svelte +111 -0
  30. package/src/components/layout/LogPanel.svelte +225 -0
  31. package/src/components/layout/LogStatusChip.svelte +137 -0
  32. package/src/components/layout/MobilePageAction.svelte +44 -0
  33. package/src/components/layout/MovedBanner.svelte +51 -0
  34. package/src/components/layout/NavBar.svelte +603 -0
  35. package/src/components/layout/NavFlyout.svelte +132 -0
  36. package/src/components/layout/NavItem.svelte +36 -0
  37. package/src/components/layout/NavMoreDialog.svelte +179 -0
  38. package/src/components/layout/PageDataStatus.svelte +36 -0
  39. package/src/components/layout/PhoneGlance.svelte +96 -0
  40. package/src/components/layout/SidebarResizer.svelte +122 -0
  41. package/src/components/layout/SyncIndicator.svelte +24 -0
  42. package/src/components/layout/feature-icons.ts +24 -0
  43. package/src/components/layout/nav-indicator.ts +128 -0
  44. package/src/components/layout/sidebar-size.ts +31 -0
  45. package/src/components/sync/SyncIssuesDrawer.svelte +149 -0
  46. package/src/components/sync/SyncStateBadge.svelte +17 -0
  47. package/src/pages/calendar/Page.svelte +1341 -0
  48. package/src/pages/calendar/load.ts +77 -0
  49. package/src/pages/developer/DatabaseBrowser.svelte +325 -0
  50. package/src/pages/developer/Event.svelte +51 -0
  51. package/src/pages/developer/EventLogManager.svelte +268 -0
  52. package/src/pages/developer/IndexedDbStats.svelte +93 -0
  53. package/src/pages/developer/LedgerAdmin.svelte +877 -0
  54. package/src/pages/developer/LocalSqliteStats.svelte +56 -0
  55. package/src/pages/developer/Page.svelte +590 -0
  56. package/src/pages/developer/PendingWorkImport.svelte +49 -0
  57. package/src/pages/developer/PerformanceDashboard.svelte +262 -0
  58. package/src/pages/developer/StackInfo.svelte +117 -0
  59. package/src/pages/developer/SyncLog.svelte +188 -0
  60. package/src/pages/developer/load.ts +28 -0
  61. package/src/pages/entities/Page.svelte +1069 -0
  62. package/src/pages/entities/load.ts +18 -0
  63. package/src/pages/entity/Page.svelte +655 -0
  64. package/src/pages/entity/load.ts +79 -0
  65. package/src/pages/error/ErrorPage.svelte +147 -0
  66. package/src/pages/layout/Layout.svelte +597 -0
  67. package/src/pages/layout/load.ts +28 -0
  68. package/src/pages/redirects/history.ts +20 -0
  69. package/src/pages/redirects/start.ts +11 -0
  70. package/src/pages/redirects/today.ts +9 -0
  71. package/src/service-worker/index.ts +108 -0
  72. package/src/service-worker/offline-bundle.ts +116 -0
  73. package/src/service-worker/virtual.d.ts +3 -0
  74. package/src/styles/index.css +11 -0
  75. package/src/styles/shell.css +296 -0
  76. package/static/_headers +4 -0
  77. package/static/favicon.ico +0 -0
  78. package/static/offline.html +44 -0
  79. package/static/replay/sw.js +2 -0
  80. package/README.md +0 -3
@@ -0,0 +1,111 @@
1
+ <script lang="ts">
2
+ const drawerId = $props.id();
3
+ const drawerFormId0 = drawerId + "-0";
4
+
5
+ import { tick } from "svelte";
6
+ import Drawer from "@hearthlog/ui/components/Drawer.svelte";
7
+ import Button from "@hearthlog/ui/components/ui/Button.svelte";
8
+ import Input from "@hearthlog/ui/components/ui/Input.svelte";
9
+ import Label from "@hearthlog/ui/components/ui/Label.svelte";
10
+
11
+ let {
12
+ show,
13
+ onClose,
14
+ onCreate,
15
+ }: {
16
+ show: boolean;
17
+ onClose: () => void;
18
+ onCreate: (name: string) => Promise<void>;
19
+ } = $props();
20
+
21
+ let name = $state("");
22
+ let saving = $state(false);
23
+ let errorMessage = $state("");
24
+ let opened = false;
25
+
26
+ $effect(() => {
27
+ if (!show) {
28
+ opened = false;
29
+ return;
30
+ }
31
+ if (opened) return;
32
+ opened = true;
33
+ name = "";
34
+ errorMessage = "";
35
+ void tick().then(() => {
36
+ document.querySelector<HTMLInputElement>(
37
+ '[data-testid="new-log-name"]',
38
+ )?.focus();
39
+ });
40
+ });
41
+
42
+ async function submit(event: SubmitEvent): Promise<void> {
43
+ event.preventDefault();
44
+ if (saving) return;
45
+
46
+ const trimmedName = name.trim();
47
+ if (!trimmedName) {
48
+ errorMessage = "Enter a log name.";
49
+ return;
50
+ }
51
+
52
+ saving = true;
53
+ errorMessage = "";
54
+ try {
55
+ await onCreate(trimmedName);
56
+ onClose();
57
+ } catch (error) {
58
+ errorMessage =
59
+ error instanceof Error
60
+ ? error.message
61
+ : "The log could not be created.";
62
+ } finally {
63
+ saving = false;
64
+ }
65
+ }
66
+ </script>
67
+
68
+ <Drawer
69
+ {show}
70
+ title="Create new log"
71
+ description="Create an empty event log and start using it immediately."
72
+ {onClose}
73
+ >
74
+ <form id={drawerFormId0} class="space-y-4" onsubmit={submit}>
75
+ <Label class="block">
76
+ Log name
77
+ <Input
78
+ data-testid="new-log-name"
79
+ bind:value={name}
80
+ placeholder="Personal, work, archive..."
81
+ autocomplete="off"
82
+ class="mt-1.5"
83
+ disabled={saving}
84
+ />
85
+ </Label>
86
+
87
+ <p class="text-xs leading-5 text-[var(--stor-muted-text)]">
88
+ The new log will become the active log in this browser. Existing logs
89
+ remain unchanged.
90
+ </p>
91
+
92
+ {#if errorMessage}
93
+ <div
94
+ class="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
95
+ role="alert"
96
+ >
97
+ {errorMessage}
98
+ </div>
99
+ {/if}
100
+
101
+ </form>
102
+ {#snippet footer()}
103
+ <Button variant="secondary" size="sm" onclick={onClose} disabled={saving}>
104
+ Cancel
105
+ </Button>
106
+ <Button type="submit" form={drawerFormId0} size="sm" disabled={saving || !name.trim()}>
107
+ {saving ? "Creating..." : "Create log"}
108
+ </Button>
109
+
110
+ {/snippet}
111
+ </Drawer>
@@ -0,0 +1,225 @@
1
+ <script lang="ts">
2
+ import { untrack } from 'svelte';
3
+ import { historyRouteUrl, navigateHistory } from '@hearthlog/runtime/history/navigation.js';
4
+ import { historyUrlState } from '@hearthlog/runtime/history/url.js';
5
+ import Drawer from '@hearthlog/ui/components/Drawer.svelte';
6
+ import Button from '@hearthlog/ui/components/ui/Button.svelte';
7
+ import Input from '@hearthlog/ui/components/ui/Input.svelte';
8
+ import Select from '@hearthlog/ui/components/ui/Select.svelte';
9
+ import LogHistory from '../history/LogHistory.svelte';
10
+ import LogMap from '../history/LogMap.svelte';
11
+ import LogTree from '../history/LogTree.svelte';
12
+ import ForkComposer from '../history/ForkComposer.svelte';
13
+ import { queryDatabase } from '@hearthlog/runtime/db/read-repositories.js';
14
+ import { FEATURES } from '@hearthlog/contracts/features/registry';
15
+ import { isFeatureEnabled } from '@hearthlog/contracts/tenant/config';
16
+ import type { LogLineageIndex, LogSummary } from '@hearthlog/runtime/ledger/index.js';
17
+ import { flattenTree, logAncestors, logTree } from '@hearthlog/runtime/history/lineage.js';
18
+ import { mapSpan, type MapSpan } from '@hearthlog/runtime/history/log-map.js';
19
+ import type { HistoryPoint } from '@hearthlog/runtime/history/contracts.js';
20
+ import { logs as logStore } from '@hearthlog/runtime/store/logs.svelte.js';
21
+ import { events } from '@hearthlog/runtime/store/events.svelte.js';
22
+ import { syncStatus } from '@hearthlog/runtime/store/sync.svelte.js';
23
+ import { notify } from '@hearthlog/ui/state/notifications.svelte.js';
24
+ import IconLayers from '~icons/mdi/layers-outline';
25
+ import IconCopy from '~icons/mdi/content-copy';
26
+ import IconPlus from '~icons/mdi/plus';
27
+
28
+ let {
29
+ show,
30
+ logs,
31
+ lineage,
32
+ currentLogId,
33
+ currentLogName,
34
+ onClose,
35
+ onSwitch,
36
+ onCreate,
37
+ onOpenSyncIssues = () => {},
38
+ createDisabled = false,
39
+ createDisabledReason,
40
+ }: {
41
+ show: boolean;
42
+ logs: LogSummary[];
43
+ lineage: LogLineageIndex | null;
44
+ currentLogId: string | null;
45
+ currentLogName: string | null;
46
+ onClose: () => void;
47
+ onSwitch: (logId: string) => Promise<void>;
48
+ onCreate: () => void;
49
+ onOpenSyncIssues?: () => void;
50
+ createDisabled?: boolean;
51
+ createDisabledReason?: string;
52
+ } = $props();
53
+
54
+ const view = $derived(historyUrlState(historyRouteUrl()));
55
+ const tree = $derived(logTree(logs, lineage, { currentLogId, showHidden: view.showHidden }));
56
+ const shown = $derived(flattenTree(tree));
57
+ const ancestors = $derived(logAncestors(logs, currentLogId));
58
+ const hiddenCount = $derived(logs.filter(log => log.hiddenAt && log.id !== currentLogId).length);
59
+ const featureOptions = [{ value: 'all', label: 'All features' }, ...FEATURES.filter(feature => isFeatureEnabled(feature.id))
60
+ .map(feature => ({ value: feature.id, label: feature.page?.label ?? feature.id }))];
61
+
62
+ let switchingLogId = $state<string | null>(null);
63
+ let hidingLogId = $state<string | null>(null);
64
+ let forkBusy = $state(false);
65
+ let error = $state('');
66
+ let range = $state<{ from: number; to: number } | null>(null);
67
+ let points = $state<HistoryPoint[]>([]);
68
+ let span = $state<MapSpan | null>(null);
69
+ let reveal = $state<{ id: string; nonce: number } | null>(null);
70
+ let query = $state('');
71
+
72
+ function navigate(values: Record<string, string | null>, replace = false) {
73
+ const url = new URL(window.location.href);
74
+ for (const [key, value] of Object.entries(values)) {
75
+ if (value) url.searchParams.set(key, value); else url.searchParams.delete(key);
76
+ }
77
+ navigateHistory(url, replace);
78
+ }
79
+
80
+ $effect(() => { query = view.search; });
81
+ $effect(() => {
82
+ const search = query;
83
+ if (search === untrack(() => view.search)) return;
84
+ const timer = setTimeout(() => navigate({ historySearch: search }, true), 250);
85
+ return () => clearTimeout(timer);
86
+ });
87
+
88
+ // The map's span ends at the newest action; its points are read from SQL for that span.
89
+ $effect(() => {
90
+ const scope = currentLogId;
91
+ const nodes = shown;
92
+ void events.updatedAt;
93
+ if (!show || !scope || view.tab !== 'history') return;
94
+ let cancelled = false;
95
+ void queryDatabase(async r => {
96
+ const now = Date.now();
97
+ const next = mapSpan(nodes, now, await r.history.newest());
98
+ return { next, rows: await r.history.points(next.start, next.end) };
99
+ }).then(({ next, rows }) => { if (!cancelled) { span = next; points = rows; } })
100
+ .catch(() => { if (!cancelled) points = []; });
101
+ return () => { cancelled = true; };
102
+ });
103
+
104
+ async function openLog(logId: string): Promise<void> {
105
+ if (logId === currentLogId || switchingLogId || forkBusy) return;
106
+ const log = logs.find(item => item.id === logId);
107
+ if (!log || log.status === 'preparing') return;
108
+ switchingLogId = logId;
109
+ error = '';
110
+ try {
111
+ await onSwitch(logId);
112
+ onClose();
113
+ } catch (cause) {
114
+ error = cause instanceof Error ? cause.message : 'Could not open this log.';
115
+ } finally {
116
+ switchingLogId = null;
117
+ }
118
+ }
119
+
120
+ async function setHidden(logId: string, hidden: boolean): Promise<void> {
121
+ const log = logs.find(item => item.id === logId);
122
+ if (!log || hidingLogId) return;
123
+ hidingLogId = logId;
124
+ try {
125
+ await logStore.setHidden(logId, hidden);
126
+ if (hidden) {
127
+ notify.success(`Hid ${log.name}`, { label: 'Undo', run: async () => {
128
+ try { await logStore.setHidden(logId, false); } catch (cause) { notify.error(cause instanceof Error ? cause.message : 'Could not show this log.'); }
129
+ } });
130
+ }
131
+ } catch (cause) {
132
+ notify.error(cause instanceof Error ? cause.message : `Could not ${hidden ? 'hide' : 'show'} this log.`);
133
+ } finally {
134
+ hidingLogId = null;
135
+ }
136
+ }
137
+
138
+ function changeTab(tab: 'history' | 'logs'): void {
139
+ error = '';
140
+ navigate({ logPanel: tab === 'logs' ? 'logs' : 'activity', historyStep: null });
141
+ }
142
+
143
+ function createEmptyLog(): void {
144
+ onClose();
145
+ onCreate();
146
+ }
147
+ </script>
148
+
149
+ <Drawer {show} title={currentLogName ?? 'Your log'} onClose={() => { if (!forkBusy) onClose(); }}
150
+ footer={view.tab === 'logs' || view.step === 'copy' || (view.step === 'fork' && view.action) ? panelFooter : undefined}>
151
+ {#snippet lead()}
152
+ <div class="space-y-3">
153
+ {#if ancestors.length > 0}
154
+ <p class="-mt-2 flex flex-wrap items-center gap-x-1 gap-y-0.5 text-xs text-[var(--stor-muted-text)]" aria-label="Where this log came from">
155
+ <IconLayers class="mr-0.5 h-3.5 w-3.5" />
156
+ {#each ancestors as ancestor (ancestor.id)}
157
+ <button type="button" class="rounded-md px-1 py-0.5 font-medium hover:bg-[var(--stor-surface-hover)] hover:text-[var(--stor-ink)] disabled:hover:bg-transparent"
158
+ disabled={switchingLogId !== null || forkBusy} onclick={() => void openLog(ancestor.id)}>{ancestor.name}</button>
159
+ <span aria-hidden="true" class="text-[var(--stor-border)]">›</span>
160
+ {/each}
161
+ <span class="px-1 font-semibold text-[var(--stor-ink)]">{currentLogName}</span>
162
+ </p>
163
+ {/if}
164
+ <div class="flex items-center gap-2">
165
+ <div class="stor-segmented-control flex min-w-0 flex-1" role="group" aria-label="Show">
166
+ <button type="button" class="stor-segmented-option flex-1" aria-pressed={view.tab === 'history'} onclick={() => changeTab('history')}>History</button>
167
+ <button type="button" class="stor-segmented-option flex-1" aria-pressed={view.tab === 'logs'} onclick={() => changeTab('logs')}>Logs</button>
168
+ </div>
169
+ <Button variant="secondary" size="sm" disabled={forkBusy} aria-pressed={view.step === 'copy'}
170
+ onclick={() => navigate({ logPanel: 'activity', historyStep: view.step === 'copy' ? null : 'copy', historyAction: null })}>
171
+ <IconCopy class="h-4 w-4" />Copy this log
172
+ </Button>
173
+ </div>
174
+ {#if view.tab === 'history'}
175
+ {#if shown.length > 0 && span}
176
+ <LogMap nodes={shown} {logs} {currentLogId} {points} {span} {range} openActionId={view.action}
177
+ disabled={switchingLogId !== null || forkBusy} onOpen={(id) => void openLog(id)}
178
+ onPick={(id) => navigate({ historyAction: id, historyStep: null })}
179
+ onReveal={(id) => { reveal = { id, nonce: (reveal?.nonce ?? 0) + 1 }; }} />
180
+ {/if}
181
+ <div class="grid grid-cols-[minmax(0,1fr)_8rem] gap-2 sm:grid-cols-[minmax(0,1fr)_9rem]">
182
+ <Input type="search" aria-label="Search history" placeholder="Search history" bind:value={query} />
183
+ <Select ariaLabel="Filter history by feature" value={view.feature || 'all'} options={featureOptions}
184
+ onValueChange={value => navigate({ historyFeature: value === 'all' ? null : value })} />
185
+ </div>
186
+ {/if}
187
+ {#if error}<p role="alert" class="text-sm text-[var(--stor-negative)]">{error}</p>{/if}
188
+ </div>
189
+ {/snippet}
190
+
191
+ {#if view.tab === 'logs'}
192
+ {#if logs.length === 0}
193
+ <p class="rounded-2xl bg-[var(--stor-surface-muted)] p-5 text-sm text-[var(--stor-muted-text)]">No logs yet. Create an empty log to get started.</p>
194
+ {:else}
195
+ <LogTree nodes={tree} {currentLogId} {switchingLogId} busyLogId={hidingLogId}
196
+ hideDisabledReason={syncStatus.isOnline ? undefined : 'Connect to hide or show logs.'}
197
+ onOpen={(id) => void openLog(id)} onHide={(id, hidden) => void setHidden(id, hidden)} />
198
+ {/if}
199
+ {:else}
200
+ <LogHistory {currentLogId} {currentLogName} {logs} {lineage} showHidden={view.showHidden} {switchingLogId} {reveal} locked={forkBusy}
201
+ onOpenLog={(id) => void openLog(id)} {onOpenSyncIssues} onRange={(value) => { range = value; }} />
202
+ {/if}
203
+
204
+ </Drawer>
205
+
206
+ {#snippet panelFooter()}
207
+ {#if view.tab === 'logs'}
208
+ <div class="flex w-full flex-wrap items-center justify-between gap-2">
209
+ {#if hiddenCount > 0 || view.showHidden}
210
+ <Button variant="ghost" size="sm" aria-pressed={view.showHidden} onclick={() => navigate({ hiddenLogs: view.showHidden ? null : '1' }, true)}>
211
+ {view.showHidden ? 'Hide hidden logs' : `Show ${hiddenCount} hidden`}
212
+ </Button>
213
+ {:else}
214
+ <span></span>
215
+ {/if}
216
+ <Button variant="secondary" size="sm" disabled={createDisabled} title={createDisabled ? createDisabledReason : undefined} onclick={createEmptyLog}>
217
+ <IconPlus class="h-4 w-4" />New empty log
218
+ </Button>
219
+ </div>
220
+ {:else if view.step === 'copy' || (view.step === 'fork' && view.action)}
221
+ <ForkComposer step={view.step} actionId={view.action} {currentLogId} {currentLogName} {logs}
222
+ onCancel={() => navigate({ historyStep: null })} onSwitch={onSwitch} onDone={onClose}
223
+ onBusyChange={(busy) => { forkBusy = busy; }} />
224
+ {/if}
225
+ {/snippet}
@@ -0,0 +1,137 @@
1
+ <script lang="ts" module>
2
+ export type LogStatusTone = 'neutral' | 'success' | 'warning' | 'danger';
3
+ </script>
4
+
5
+ <script lang="ts">
6
+ import Tooltip from '@hearthlog/ui/components/ui/Tooltip.svelte';
7
+ import { formatNumber } from '@hearthlog/ui/utils/number-format.js';
8
+ import IconLayers from '~icons/mdi/layers-outline';
9
+ import IconUnfold from '~icons/mdi/unfold-more-horizontal';
10
+
11
+ let {
12
+ currentLogName,
13
+ statusLine,
14
+ statusTitle,
15
+ tone,
16
+ version = null,
17
+ pulsing = false,
18
+ variant = 'foot',
19
+ expanded = false,
20
+ disabled = false,
21
+ note,
22
+ onOpen,
23
+ }: {
24
+ currentLogName: string | null;
25
+ /** Short sync state, e.g. "Synced · 12:09". */
26
+ statusLine: string;
27
+ /** Full sync sentence for the tooltip and accessible description. */
28
+ statusTitle: string;
29
+ tone: LogStatusTone;
30
+ /** Head version of the active log; hidden when the log has no changes yet. */
31
+ version?: number | null;
32
+ pulsing?: boolean;
33
+ /** `foot` in the sidebar, `tile` in the collapsed rail, `row` in the phone sheet, `pill` or `round` in the phone top bar. */
34
+ variant?: 'foot' | 'tile' | 'row' | 'pill' | 'round';
35
+ expanded?: boolean;
36
+ disabled?: boolean;
37
+ /** A small positive tag at the end of a `row`, such as "Available offline". */
38
+ note?: string;
39
+ onOpen: () => void;
40
+ } = $props();
41
+
42
+ const logLabel = $derived(currentLogName || (disabled ? 'Loading log…' : 'Choose a log'));
43
+ const versionLabel = $derived(version !== null && version > 0 ? `v${formatNumber(version)}` : null);
44
+ const descriptionId = $props.id();
45
+ const danger = $derived(tone === 'danger');
46
+ // Sync that is fine stays silent. A dot marks the exception: saving, offline or needing review.
47
+ const showDot = $derived(tone !== 'success');
48
+ const dotClass = $derived({
49
+ success: 'bg-[var(--stor-positive)]',
50
+ warning: 'bg-[var(--stor-warning)]',
51
+ danger: 'bg-[var(--stor-negative)]',
52
+ neutral: 'bg-[var(--stor-surface)] ring-2 ring-inset ring-[var(--stor-muted-text)]',
53
+ }[tone]);
54
+ // The ring around the dot matches the surface the tile sits on.
55
+ const ringClass = $derived(
56
+ variant === 'row'
57
+ ? 'shadow-[0_0_0_2px_var(--stor-surface-muted)]'
58
+ : 'shadow-[0_0_0_2px_var(--stor-sidebar)]',
59
+ );
60
+ </script>
61
+
62
+ {#snippet versionTag()}
63
+ {#if versionLabel}
64
+ <span class="shrink-0 rounded-full bg-[color-mix(in_srgb,var(--stor-ink)_8%,transparent)] px-1.5 text-[10px] font-semibold leading-4 tabular-nums {danger ? '' : 'text-[var(--stor-muted-text)]'}">{versionLabel}</span>
65
+ {/if}
66
+ {/snippet}
67
+
68
+ {#snippet chip(tip: Record<string, unknown> = {})}
69
+ <button
70
+ {...tip}
71
+ type="button"
72
+ class="stor-log-chip group flex items-center text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] disabled:cursor-wait disabled:opacity-60 {variant === 'foot'
73
+ ? 'w-full gap-2.5 rounded-xl p-1.5'
74
+ : variant === 'row'
75
+ ? 'w-full gap-2.5 rounded-2xl py-2 pl-2 pr-2.5'
76
+ : variant === 'tile'
77
+ ? 'mx-auto h-11 w-11 justify-center rounded-xl'
78
+ : variant === 'round'
79
+ ? 'relative h-10 w-10 shrink-0 justify-center rounded-full'
80
+ : 'h-9 min-w-0 max-w-full gap-2 rounded-full pl-2.5 pr-2 text-xs font-semibold'} {danger
81
+ ? 'bg-[var(--stor-negative-soft)] text-[var(--stor-negative)] ring-1 ring-inset ring-[color-mix(in_srgb,var(--stor-negative),transparent_70%)]'
82
+ : variant === 'pill' || variant === 'round'
83
+ ? 'bg-[var(--stor-surface-raised)] text-[var(--stor-ink)] shadow-[var(--stor-shadow-sm)] hover:shadow-[var(--stor-shadow-md)]'
84
+ : variant === 'row'
85
+ ? 'bg-[var(--stor-surface-muted)] text-[var(--stor-ink)]'
86
+ : 'text-[var(--stor-ink)] hover:bg-[var(--stor-nav-hover)]'}"
87
+ title={variant === 'tile' ? undefined : `${logLabel}${versionLabel ? ` ${versionLabel}` : ''} · ${statusTitle}`}
88
+ aria-label={currentLogName ? `Active log: ${currentLogName}` : 'Open your logs'}
89
+ aria-describedby={descriptionId}
90
+ aria-haspopup="dialog"
91
+ aria-expanded={expanded}
92
+ {disabled}
93
+ onclick={(event) => {
94
+ (tip.onclick as ((event: MouseEvent) => void) | undefined)?.(event);
95
+ onOpen();
96
+ }}
97
+ data-testid="log-status-chip"
98
+ data-tone={tone}
99
+ >
100
+ <span id={descriptionId} class="sr-only">{versionLabel ? `${statusTitle} Log version ${version}.` : statusTitle}</span>
101
+ {#if variant === 'round'}
102
+ <IconLayers class="h-[18px] w-[18px] text-[var(--stor-muted-text)]" />
103
+ {#if showDot}<span class="absolute right-1.5 top-1.5 h-2.5 w-2.5 rounded-full {dotClass} {pulsing ? 'motion-safe:animate-pulse' : ''} shadow-[0_0_0_2px_var(--stor-surface-raised)]" aria-hidden="true"></span>{/if}
104
+ {:else if variant === 'pill'}
105
+ {#if showDot}<span class="h-2 w-2 shrink-0 rounded-full {dotClass} {pulsing ? 'motion-safe:animate-pulse' : ''}" aria-hidden="true"></span>{/if}
106
+ <span class="min-w-0 truncate">{logLabel}</span>
107
+ {@render versionTag()}
108
+ <span class="hidden shrink-0 font-medium opacity-70 min-[380px]:inline">· {statusLine}</span>
109
+ <IconUnfold class="h-3.5 w-3.5 shrink-0 opacity-60" />
110
+ {:else}
111
+ <span class="relative flex h-8 w-8 shrink-0 items-center justify-center rounded-[10px] {danger ? 'bg-[color-mix(in_srgb,var(--stor-negative),transparent_85%)]' : 'bg-[var(--stor-accent-soft)] text-[var(--stor-accent-strong)]'}">
112
+ <IconLayers class="h-4 w-4" />
113
+ {#if showDot}<span class="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full {dotClass} {pulsing ? 'motion-safe:animate-pulse' : ''} {ringClass}" aria-hidden="true"></span>{/if}
114
+ </span>
115
+ {#if variant !== 'tile'}
116
+ <span class="flex min-w-0 flex-1 flex-col leading-tight">
117
+ <span class="flex min-w-0 items-center gap-1.5">
118
+ <span class="truncate text-[13px] font-semibold">{logLabel}</span>
119
+ {@render versionTag()}
120
+ </span>
121
+ <span class="truncate text-[11px] {danger ? '' : 'text-[var(--stor-muted-text)]'}">{statusLine}</span>
122
+ </span>
123
+ {#if note}<span class="shrink-0 text-[11px] font-semibold text-[var(--stor-positive)]">{note}</span>{/if}
124
+ <IconUnfold class="h-4 w-4 shrink-0 {danger ? '' : 'text-[var(--stor-muted-text)]'}" />
125
+ {/if}
126
+ {/if}
127
+ </button>
128
+ {/snippet}
129
+
130
+ {#if variant === 'tile'}
131
+ <!-- The rail has no label, so the tile names the log and its sync state in a tooltip. -->
132
+ <Tooltip content={`${logLabel}${versionLabel ? ` ${versionLabel}` : ''}: ${statusLine}`} side="right" delayDuration={150}>
133
+ {#snippet trigger({ props })}{@render chip(props)}{/snippet}
134
+ </Tooltip>
135
+ {:else}
136
+ {@render chip()}
137
+ {/if}
@@ -0,0 +1,44 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import CommandOnly from '@hearthlog/ui/components/commands/CommandOnly.svelte';
4
+
5
+ let {
6
+ children,
7
+ hideWhenModalOpen = false,
8
+ isModalOpen = false,
9
+ hidden = false
10
+ }: {
11
+ children: Snippet;
12
+ /** Set while a drawer/scanner/dialog from this page is open. */
13
+ hideWhenModalOpen?: boolean;
14
+ isModalOpen?: boolean;
15
+ /** Set while another surface (for example a selection bar) owns the mobile action area. */
16
+ hidden?: boolean;
17
+ } = $props();
18
+
19
+ const isHidden = $derived(hidden || (hideWhenModalOpen && isModalOpen));
20
+
21
+ // Any scroll (the page or its list) shrinks a pill with a `.stor-action-label` to its icon until it rests.
22
+ let moving = $state(false);
23
+ $effect(() => {
24
+ let rest: ReturnType<typeof setTimeout> | undefined;
25
+ const onScroll = () => {
26
+ moving = true;
27
+ clearTimeout(rest);
28
+ rest = setTimeout(() => (moving = false), 500);
29
+ };
30
+ document.addEventListener('scroll', onScroll, { capture: true, passive: true });
31
+ return () => {
32
+ clearTimeout(rest);
33
+ document.removeEventListener('scroll', onScroll, { capture: true });
34
+ };
35
+ });
36
+ </script>
37
+
38
+ {#if !isHidden}
39
+ <CommandOnly>
40
+ <div class="stor-mobile-action lg:hidden" data-moving={moving ? '' : undefined}>
41
+ {@render children()}
42
+ </div>
43
+ </CommandOnly>
44
+ {/if}
@@ -0,0 +1,51 @@
1
+ <script lang="ts">
2
+ import { onMount } from "svelte";
3
+ import Button from "@hearthlog/ui/components/ui/Button.svelte";
4
+ import { clearMovedWorkspace, movedWorkspace, restoreMovedWorkspace } from "@hearthlog/runtime/store/moved.svelte.js";
5
+ import { exportPendingWork } from "@hearthlog/runtime/store/pending-work-transfer.js";
6
+ import { notify } from "@hearthlog/ui/state/notifications.svelte.js";
7
+ import { sync } from "@hearthlog/runtime/store/sync.svelte.js";
8
+
9
+ let busy = $state(false);
10
+ onMount(() => { void restoreMovedWorkspace(); });
11
+
12
+ const destination = $derived(movedWorkspace.value?.destination ?? null);
13
+
14
+ async function exportWork(): Promise<void> {
15
+ busy = true;
16
+ try {
17
+ const { file, name } = await exportPendingWork();
18
+ const url = URL.createObjectURL(new Blob([JSON.stringify(file, null, 2)], { type: "application/json" }));
19
+ const link = Object.assign(document.createElement("a"), { href: url, download: name });
20
+ link.click();
21
+ URL.revokeObjectURL(url);
22
+ notify.success(file.operations.length === 0 ? "Nothing was waiting to be sent" : `Exported ${file.operations.length} pending change${file.operations.length === 1 ? "" : "s"}`);
23
+ } catch (error) {
24
+ notify.error(error instanceof Error ? error.message : "Could not export the pending changes");
25
+ } finally {
26
+ busy = false;
27
+ }
28
+ }
29
+
30
+ async function tryAgain(): Promise<void> {
31
+ await clearMovedWorkspace();
32
+ void sync("manual").catch(() => undefined);
33
+ }
34
+ </script>
35
+
36
+ {#if movedWorkspace.value}
37
+ <div class="m-2.5 sm:m-3 rounded-xl border border-[var(--stor-border)] bg-[var(--stor-surface-muted)] p-3 text-sm" role="alert">
38
+ <p class="font-medium text-[var(--stor-ink)]">This workspace moved</p>
39
+ <p class="mt-1 text-[var(--stor-muted-text)]">
40
+ {#if destination}It lives at {destination} now.{:else}It lives somewhere else now.{/if}
41
+ Changes made here stay on this device until you export them.
42
+ </p>
43
+ <div class="mt-3 flex flex-wrap gap-2">
44
+ <Button variant="secondary" size="sm" onclick={exportWork} disabled={busy}>Export pending changes</Button>
45
+ {#if destination}
46
+ <Button variant="secondary" size="sm" onclick={() => location.assign(destination)}>Open new location</Button>
47
+ {/if}
48
+ <Button variant="ghost" size="sm" onclick={tryAgain} disabled={busy}>Try again here</Button>
49
+ </div>
50
+ </div>
51
+ {/if}