@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,132 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { NavChild } from '@hearthlog/contracts/features/navigation';
4
+ import type { AttentionTone } from '@hearthlog/runtime/store/attention.svelte.js';
5
+
6
+ type Badge = { count: number; tone: AttentionTone; label: string };
7
+ interface Item {
8
+ child: NavChild;
9
+ href: string;
10
+ active: boolean;
11
+ badge: Badge | null;
12
+ }
13
+
14
+ let {
15
+ label,
16
+ badge,
17
+ items,
18
+ trigger,
19
+ }: {
20
+ label: string;
21
+ /** The section's own badge, named in the flyout header. */
22
+ badge: Badge | null;
23
+ items: Item[];
24
+ /** The section link. It stays the real navigation target; the flyout only adds shortcuts. */
25
+ trigger: Snippet;
26
+ } = $props();
27
+
28
+ let root = $state<HTMLElement>();
29
+ let panel = $state<HTMLElement>();
30
+ let open = $state(false);
31
+ let top = $state(0);
32
+ let left = $state(0);
33
+ let openTimer: ReturnType<typeof setTimeout> | undefined;
34
+ let closeTimer: ReturnType<typeof setTimeout> | undefined;
35
+ /** Set while Escape hands focus back to the section link, which must not reopen the flyout. */
36
+ let returningFocus = false;
37
+
38
+ /** Sits 12px right of the rail, level with the section link, and stays inside the viewport. */
39
+ function place(): void {
40
+ const link = root?.querySelector<HTMLElement>('[data-nav-link]');
41
+ const rail = root?.closest('aside');
42
+ if (!link || !rail || !panel) return;
43
+ left = rail.getBoundingClientRect().right + 12;
44
+ top = Math.max(12, Math.min(link.getBoundingClientRect().top - 6, window.innerHeight - panel.offsetHeight - 12));
45
+ }
46
+
47
+ function show(): void {
48
+ clearTimeout(closeTimer);
49
+ place();
50
+ open = true;
51
+ }
52
+
53
+ function hide(): void {
54
+ clearTimeout(openTimer);
55
+ open = false;
56
+ }
57
+
58
+ // Hover opens after a short intent delay and only for a real mouse: a tap must navigate, not open.
59
+ function onPointerEnter(event: PointerEvent): void {
60
+ if (event.pointerType !== 'mouse') return;
61
+ clearTimeout(closeTimer);
62
+ openTimer = setTimeout(show, 90);
63
+ }
64
+
65
+ function onPointerLeave(event: PointerEvent): void {
66
+ if (event.pointerType !== 'mouse') return;
67
+ clearTimeout(openTimer);
68
+ closeTimer = setTimeout(hide, 120);
69
+ }
70
+
71
+ // Keyboard focus opens it so the pages are reachable without a mouse; a click or tap focus does not.
72
+ function onFocusIn(event: FocusEvent): void {
73
+ if (!returningFocus && (event.target as HTMLElement).matches(':focus-visible')) show();
74
+ }
75
+
76
+ function onFocusOut(event: FocusEvent): void {
77
+ if (!root?.contains(event.relatedTarget as Node | null)) hide();
78
+ }
79
+
80
+ function onKeydown(event: KeyboardEvent): void {
81
+ if (event.key !== 'Escape' || !open) return;
82
+ event.stopPropagation();
83
+ hide();
84
+ returningFocus = true;
85
+ root?.querySelector<HTMLElement>('[data-nav-link]')?.focus();
86
+ returningFocus = false;
87
+ }
88
+ </script>
89
+
90
+ <div
91
+ bind:this={root}
92
+ role="presentation"
93
+ onpointerenter={onPointerEnter}
94
+ onpointerleave={onPointerLeave}
95
+ onfocusin={onFocusIn}
96
+ onfocusout={onFocusOut}
97
+ onkeydown={onKeydown}
98
+ onclick={hide}
99
+ >
100
+ {@render trigger()}
101
+ <div
102
+ bind:this={panel}
103
+ class="stor-nav-fly"
104
+ data-open={open ? '' : undefined}
105
+ style:top="{top}px"
106
+ style:left="{left}px"
107
+ role="group"
108
+ aria-label={`${label} pages`}
109
+ >
110
+ <div class="stor-nav-fly-head">
111
+ <span class="min-w-0 truncate">{label}</span>
112
+ {#if badge}
113
+ <span class="stor-nav-badge {badge.tone === 'negative' ? 'stor-nav-badge-negative' : 'stor-nav-badge-warning'} shrink-0">{badge.count} overdue</span>
114
+ {/if}
115
+ </div>
116
+ <ul>
117
+ {#each items as item (item.child.id)}
118
+ <li><a
119
+ href={item.href}
120
+ class="stor-nav-child {item.active ? 'stor-nav-child-active' : ''}"
121
+ aria-current={item.active ? 'page' : undefined}
122
+ aria-label={item.badge ? `${item.child.label}, ${item.badge.label}` : undefined}
123
+ >
124
+ <span class="min-w-0 flex-1 truncate">{item.child.label}</span>
125
+ {#if item.badge}
126
+ <span class="stor-nav-badge {item.badge.tone === 'negative' ? 'stor-nav-badge-negative' : 'stor-nav-badge-warning'}" aria-hidden="true">{item.badge.count}</span>
127
+ {/if}
128
+ </a></li>
129
+ {/each}
130
+ </ul>
131
+ </div>
132
+ </div>
@@ -0,0 +1,36 @@
1
+ <script lang="ts">
2
+ import type { Component } from 'svelte';
3
+ import type { HTMLAnchorAttributes } from 'svelte/elements';
4
+ import type { AttentionTone } from '@hearthlog/runtime/store/attention.svelte.js';
5
+
6
+ interface Props extends Omit<HTMLAnchorAttributes, 'class' | 'children'> {
7
+ icon: Component;
8
+ label: string;
9
+ active: boolean;
10
+ /** The rail: icon only, with the count as a dot. */
11
+ collapsed: boolean;
12
+ badge: { count: number; tone: AttentionTone; label: string } | null;
13
+ /** The count sits on the sub-page while a section is open, so the section hides it then. */
14
+ showCount?: boolean;
15
+ }
16
+
17
+ let { icon: Icon, label, active, collapsed, badge, showCount = true, ...rest }: Props = $props();
18
+ </script>
19
+
20
+ <!-- `rest` may carry tooltip props; the anchor is never a button, so its type is dropped. -->
21
+ <a
22
+ {...rest}
23
+ type={undefined}
24
+ data-nav-link
25
+ class="relative flex h-9 items-center rounded-[10px] text-[13.5px] transition-[color,background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--stor-focus)] {collapsed ? 'mx-auto h-10 w-11 justify-center' : 'gap-2.5 pl-3 pr-2'} {active
26
+ ? 'stor-nav-selected font-semibold text-[var(--stor-ink)]'
27
+ : 'font-medium text-[var(--stor-muted-text)] hover:bg-[var(--stor-nav-hover)] hover:text-[var(--stor-ink)]'}"
28
+ aria-current={active ? 'page' : undefined}
29
+ aria-label={badge ? `${label}, ${badge.label}` : label}
30
+ >
31
+ <Icon class="h-[18px] w-[18px] shrink-0" />
32
+ {#if !collapsed}<span class="min-w-0 flex-1 truncate">{label}</span>{/if}
33
+ {#if badge && (collapsed || showCount)}
34
+ <span class="stor-nav-badge {badge.tone === 'negative' ? 'stor-nav-badge-negative' : 'stor-nav-badge-warning'} {collapsed ? 'stor-nav-badge-dot' : ''}" aria-hidden="true">{collapsed ? '' : badge.count}</span>
35
+ {/if}
36
+ </a>
@@ -0,0 +1,179 @@
1
+ <script lang="ts">
2
+ import { page } from "$app/state";
3
+ import { Dialog } from "bits-ui";
4
+ import type { Component } from "svelte";
5
+ import AppearanceOptions from "./AppearanceOptions.svelte";
6
+ import LogStatusChip, { type LogStatusTone } from "./LogStatusChip.svelte";
7
+ import IconLogout from "~icons/mdi/logout";
8
+ import IconChevronRight from "~icons/mdi/chevron-right";
9
+ import { ACCESS_LOGOUT_ENDPOINT, LOCAL_USER_EMAIL } from "@hearthlog/runtime/access/user-session.js";
10
+ import { beginAccessLogout } from "@hearthlog/runtime/access/session.js";
11
+
12
+ interface NavLink {
13
+ path: string;
14
+ label: string;
15
+ subtitle?: string;
16
+ icon: Component;
17
+ matchPaths?: string[];
18
+ }
19
+
20
+ let {
21
+ open,
22
+ userEmail = "",
23
+ links,
24
+ settings = [],
25
+ currentLogName,
26
+ syncLabel,
27
+ syncDescription,
28
+ statusLine,
29
+ statusTitle,
30
+ tone,
31
+ version = null,
32
+ pulsing = false,
33
+ dataReady = false,
34
+ offlineAvailable = false,
35
+ onOpenChange,
36
+ onOpenSyncIssues,
37
+ onOpenLogs,
38
+ }: {
39
+ open: boolean;
40
+ userEmail?: string;
41
+ links: NavLink[];
42
+ settings?: NavLink[];
43
+ currentLogName: string | null;
44
+ syncLabel: string;
45
+ syncDescription: string;
46
+ statusLine: string;
47
+ statusTitle: string;
48
+ tone: LogStatusTone;
49
+ version?: number | null;
50
+ pulsing?: boolean;
51
+ dataReady?: boolean;
52
+ offlineAvailable?: boolean;
53
+ onOpenChange: (open: boolean) => void;
54
+ onOpenSyncIssues: () => void;
55
+ onOpenLogs: () => void;
56
+ } = $props();
57
+
58
+ const initial = $derived((userEmail || "?").trim().charAt(0).toUpperCase());
59
+
60
+ function isActive(path: string): boolean {
61
+ if (path === "/") return page.url.pathname === "/";
62
+ return page.url.pathname === path || page.url.pathname.startsWith(`${path}/`);
63
+ }
64
+
65
+ function isLinkActive(link: NavLink): boolean {
66
+ return (link.matchPaths ?? [link.path]).some(isActive);
67
+ }
68
+
69
+ function close(): void {
70
+ onOpenChange(false);
71
+ }
72
+
73
+ function openSyncIssues(): void {
74
+ close();
75
+ onOpenSyncIssues();
76
+ }
77
+
78
+ function logout(event: MouseEvent): void {
79
+ event.preventDefault();
80
+ close();
81
+ beginAccessLogout("/");
82
+ }
83
+ </script>
84
+
85
+ <Dialog.Root {open} onOpenChange={onOpenChange}>
86
+ <Dialog.Portal>
87
+ <Dialog.Overlay class="fixed inset-0 z-[51] bg-[var(--stor-overlay)] md:hidden" />
88
+ <Dialog.Content
89
+ class="stor-sheet fixed inset-x-3 bottom-[calc(var(--stor-mobile-bottom-inset)+var(--stor-mobile-nav-height)+10px)] z-[52] max-h-[min(42rem,calc(100dvh-6rem))] overflow-y-auto rounded-[26px] p-3 outline-none md:hidden"
90
+ aria-label="More navigation and sync options"
91
+ >
92
+ <div class="mx-auto mb-2 h-1 w-10 rounded-full bg-[var(--stor-border)]" aria-hidden="true"></div>
93
+
94
+ {#if links.length}
95
+ <nav aria-label="More pages" class="grid gap-0.5">
96
+ {#each links as link}
97
+ {@const Icon = link.icon}
98
+ {@const active = isLinkActive(link)}
99
+ <a
100
+ href={link.path}
101
+ class="flex min-h-12 items-center gap-3 rounded-xl px-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] {active ? 'bg-[var(--stor-selected)]' : 'hover:bg-[var(--stor-surface-hover)]'}"
102
+ aria-current={active ? "page" : undefined}
103
+ onclick={close}
104
+ >
105
+ <span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] {active ? 'bg-[var(--stor-surface-raised)] text-[var(--stor-accent-strong)]' : 'bg-[var(--stor-surface-muted)] text-[var(--stor-muted-text)]'}"><Icon class="h-[18px] w-[18px]" /></span>
106
+ <span class="min-w-0 flex-1">
107
+ <span class="block text-sm font-semibold text-[var(--stor-ink)]">{link.label}</span>
108
+ {#if link.subtitle}<span class="block truncate text-xs text-[var(--stor-muted-text)]">{link.subtitle}</span>{/if}
109
+ </span>
110
+ <IconChevronRight class="h-4 w-4 shrink-0 text-[var(--stor-muted-text)]" />
111
+ </a>
112
+ {/each}
113
+ </nav>
114
+ <div class="mx-1 my-2 h-px bg-[var(--stor-border)]"></div>
115
+ {/if}
116
+
117
+ <section aria-label="Log and sync" class="grid gap-2">
118
+ <LogStatusChip
119
+ variant="row"
120
+ {currentLogName}
121
+ {statusLine}
122
+ {statusTitle}
123
+ {tone}
124
+ {version}
125
+ {pulsing}
126
+ disabled={!dataReady}
127
+ note={offlineAvailable && tone === "success" ? "Available offline" : undefined}
128
+ onOpen={() => (tone === "danger" ? openSyncIssues() : onOpenLogs())}
129
+ />
130
+ <!-- Sync details appear by exception: when sync is fine the row says everything. -->
131
+ {#if tone !== "success"}
132
+ <p class="px-1 text-xs text-[var(--stor-muted-text)]">
133
+ <span class="font-semibold text-[var(--stor-ink)]">{syncLabel}</span> · {syncDescription}
134
+ </p>
135
+ <button type="button" class="flex min-h-11 items-center justify-center gap-1.5 rounded-xl bg-[var(--stor-surface-muted)] text-xs font-semibold text-[var(--stor-ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]" onclick={openSyncIssues}>
136
+ Sync details
137
+ </button>
138
+ {/if}
139
+ </section>
140
+
141
+ <section class="mt-3" aria-labelledby="mobile-appearance-heading">
142
+ <h2 id="mobile-appearance-heading" class="px-1 pb-1.5 text-[10.5px] font-semibold uppercase tracking-[0.1em] text-[var(--stor-muted-text)]">Appearance</h2>
143
+ <AppearanceOptions compact />
144
+ </section>
145
+
146
+ {#if settings.length}
147
+ <section class="mt-3" aria-label="Settings">
148
+ <h2 class="px-1 pb-1.5 text-[10.5px] font-semibold uppercase tracking-[0.1em] text-[var(--stor-muted-text)]">Settings</h2>
149
+ <div class="grid grid-cols-2 gap-1.5">
150
+ {#each settings as link}
151
+ {@const Icon = link.icon}
152
+ <a href={link.path} onclick={close} aria-current={isLinkActive(link) ? "page" : undefined} class="flex min-h-11 items-center justify-center gap-1.5 rounded-xl text-xs font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] {isLinkActive(link) ? 'bg-[var(--stor-selected)] text-[var(--stor-accent-strong)]' : 'bg-[var(--stor-surface-muted)] text-[var(--stor-ink)]'}">
153
+ <Icon class="h-4 w-4" /> {link.label}
154
+ </a>
155
+ {/each}
156
+ </div>
157
+ </section>
158
+ {/if}
159
+
160
+ <section class="mt-3 flex items-center gap-2.5 rounded-xl px-1 py-1" aria-label="Account">
161
+ <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>
162
+ <div class="min-w-0 flex-1">
163
+ <div class="truncate text-xs font-semibold text-[var(--stor-ink)]" title={userEmail} data-testid="mobile-current-user-email">{userEmail || "Account"}</div>
164
+ </div>
165
+ {#if userEmail && userEmail !== LOCAL_USER_EMAIL}
166
+ <a
167
+ href={ACCESS_LOGOUT_ENDPOINT}
168
+ onclick={logout}
169
+ class="flex h-11 min-w-[44px] items-center justify-center gap-1.5 rounded-lg px-3 text-xs font-semibold text-[var(--stor-muted-text)] hover:bg-[var(--stor-surface-hover)]"
170
+ aria-label="Log out"
171
+ data-testid="mobile-logout-link"
172
+ >
173
+ <IconLogout class="h-4 w-4" /> Log out
174
+ </a>
175
+ {/if}
176
+ </section>
177
+ </Dialog.Content>
178
+ </Dialog.Portal>
179
+ </Dialog.Root>
@@ -0,0 +1,36 @@
1
+ <script lang="ts">
2
+ import { tick } from 'svelte';
3
+ import { refreshAll } from '$app/navigation';
4
+ import Button from '@hearthlog/ui/components/ui/Button.svelte';
5
+ import type { PageResource } from '@hearthlog/runtime/runtime/page-data.svelte.js';
6
+ let { resource }: { resource: PageResource<any> } = $props();
7
+ $effect(() => resource.attach());
8
+ $effect(() => {
9
+ resource.revision;
10
+ if (resource.pending || !resource.usable || resource.error) return;
11
+ const measurement = resource.measurement;
12
+ let cancelled = false;
13
+ let frame = 0;
14
+ void tick().then(() => {
15
+ if (cancelled) return;
16
+ frame = requestAnimationFrame(() => {
17
+ frame = requestAnimationFrame(() => {
18
+ if (!cancelled) resource.rendered(measurement);
19
+ });
20
+ });
21
+ });
22
+ return () => { cancelled = true; cancelAnimationFrame(frame); };
23
+ });
24
+ </script>
25
+
26
+ {#if resource.error}
27
+ <div class="mx-3 my-2 flex flex-wrap items-center gap-2 rounded-xl bg-[var(--stor-negative-soft)] p-3 text-sm text-[var(--stor-negative)]" role="alert">
28
+ {resource.error instanceof Error ? resource.error.message : 'This page could not load its data.'}
29
+ <Button variant="secondary" size="sm" onclick={() => refreshAll()}>Retry</Button>
30
+ </div>
31
+ {:else}
32
+ <span class="sr-only" role="status">{resource.loading ? 'Loading page data' : resource.refreshing ? 'Updating page data' : 'Page data ready'}</span>
33
+ {#if resource.refreshing}
34
+ <div class="pointer-events-none fixed right-3 top-2 z-40 rounded-full bg-[var(--stor-surface-raised)] px-2 py-1 text-xs text-[var(--stor-muted-text)] shadow-[var(--stor-shadow-sm)]" aria-hidden="true">Updating…</div>
35
+ {/if}
36
+ {/if}
@@ -0,0 +1,96 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+ import Modal from "@hearthlog/ui/components/Modal.svelte";
4
+ import { phoneHeader } from "@hearthlog/runtime/runtime/phone-header.svelte.js";
5
+
6
+ /**
7
+ * The page's one figure in the phone top bar. Tapping it opens the summary
8
+ * sheet, which holds every figure the desktop shows (the page passes its
9
+ * desktop components as children). Wider screens never show the bar.
10
+ */
11
+ let {
12
+ label = "",
13
+ value = "",
14
+ unit = "",
15
+ tone = "default",
16
+ title,
17
+ ariaLabel,
18
+ context = null,
19
+ hideLog = false,
20
+ chip,
21
+ more,
22
+ children,
23
+ }: {
24
+ label?: string;
25
+ value?: string;
26
+ unit?: string;
27
+ tone?: "default" | "warning" | "negative" | "positive";
28
+ /** The sheet's title. */
29
+ title: string;
30
+ ariaLabel?: string;
31
+ /** Shown after the section name in the top bar, e.g. the day in view. */
32
+ context?: string | null;
33
+ /** A wide glance (Health's three figures) takes the log button's place. */
34
+ hideLog?: boolean;
35
+ /** Custom chip content instead of label and value. */
36
+ chip?: Snippet;
37
+ /** A way on from the sheet, e.g. the week; the sheet closes first. */
38
+ more?: { label: string; run: () => void } | { label: string; run: () => void }[];
39
+ children: Snippet;
40
+ } = $props();
41
+
42
+ let open = $state(false);
43
+
44
+ const toneClass = $derived({
45
+ default: "text-[var(--stor-ink)]",
46
+ warning: "text-[var(--stor-warning)]",
47
+ negative: "text-[var(--stor-negative)]",
48
+ positive: "text-[var(--stor-positive)]",
49
+ }[tone]);
50
+
51
+ $effect(() => {
52
+ phoneHeader.glance = glance;
53
+ return () => { if (phoneHeader.glance === glance) phoneHeader.glance = null; };
54
+ });
55
+ $effect(() => {
56
+ phoneHeader.context = context;
57
+ phoneHeader.hideLog = hideLog;
58
+ return () => { phoneHeader.context = null; phoneHeader.hideLog = false; };
59
+ });
60
+ </script>
61
+
62
+ {#snippet glance()}
63
+ <button
64
+ type="button"
65
+ class="stor-phone-glance flex h-10 min-w-0 shrink items-center rounded-full bg-[var(--stor-surface-raised)] px-3.5 text-left shadow-[var(--stor-elevation-1)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]"
66
+ aria-label={ariaLabel ?? (label ? `${label}: ${value} ${unit}`.trim() : title)}
67
+ aria-haspopup="dialog"
68
+ aria-expanded={open}
69
+ data-testid="phone-glance"
70
+ onclick={() => (open = true)}
71
+ >
72
+ {#if chip}
73
+ {@render chip()}
74
+ {:else}
75
+ <span class="flex min-w-0 flex-col items-end leading-tight">
76
+ <span class="max-w-full truncate text-[10.5px] font-medium text-[var(--stor-muted-text)]">{label}</span>
77
+ <span class="max-w-full truncate text-[14.5px] font-semibold tabular-nums {toneClass}">{value}{#if unit}<span class="ml-0.5 text-[11px] font-medium text-[var(--stor-muted-text)]">{unit}</span>{/if}</span>
78
+ </span>
79
+ {/if}
80
+ </button>
81
+ {/snippet}
82
+
83
+ <Modal show={open} {title} onClose={() => (open = false)}>
84
+ {@render children()}
85
+ {#if more && (!Array.isArray(more) || more.length > 0)}
86
+ <div class="mt-3 grid gap-2" style:grid-template-columns="repeat({Array.isArray(more) ? more.length : 1}, minmax(0, 1fr))">
87
+ {#each Array.isArray(more) ? more : [more] as action (action.label)}
88
+ <button
89
+ type="button"
90
+ class="flex min-h-12 w-full items-center justify-center rounded-xl bg-[var(--stor-surface-muted)] text-sm font-semibold text-[var(--stor-ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]"
91
+ onclick={() => { open = false; action.run(); }}
92
+ >{action.label}</button>
93
+ {/each}
94
+ </div>
95
+ {/if}
96
+ </Modal>
@@ -0,0 +1,122 @@
1
+ <script lang="ts">
2
+ import {
3
+ SIDEBAR_DEFAULT_WIDTH,
4
+ SIDEBAR_KEY_STEP,
5
+ SIDEBAR_MAX_WIDTH,
6
+ SIDEBAR_MIN_WIDTH,
7
+ SIDEBAR_RAIL_WIDTH,
8
+ clampSidebarWidth,
9
+ resolveSidebarDrag,
10
+ } from './sidebar-size.js';
11
+
12
+ // The edge of the sidebar card. A drag reports every frame with `final` false and once on release;
13
+ // Escape or a cancelled pointer reports the starting state as final.
14
+ let {
15
+ width,
16
+ folded,
17
+ onChange,
18
+ }: {
19
+ width: number;
20
+ folded: boolean;
21
+ onChange: (width: number, folded: boolean, final: boolean) => void;
22
+ } = $props();
23
+
24
+ type Drag = { moved: boolean; x: number; start: number; width: number; folded: boolean; raw: number; frame: number };
25
+ let drag: Drag | null = null;
26
+ let handle = $state<HTMLElement>();
27
+
28
+ const current = $derived(folded ? SIDEBAR_RAIL_WIDTH : width);
29
+
30
+ function setResizing(on: boolean): void {
31
+ document.documentElement.toggleAttribute('data-sidebar-resizing', on);
32
+ }
33
+
34
+ function apply(final: boolean): void {
35
+ if (!drag) return;
36
+ const resolved = resolveSidebarDrag(drag.raw);
37
+ onChange(resolved.width ?? drag.width, resolved.folded, final);
38
+ }
39
+
40
+ function onPointerDown(event: PointerEvent): void {
41
+ if (event.button !== 0 || !handle) return;
42
+ event.preventDefault();
43
+ handle.setPointerCapture(event.pointerId);
44
+ drag = { moved: false, x: event.clientX, start: current, width, folded, raw: current, frame: 0 };
45
+ setResizing(true);
46
+ }
47
+
48
+ function onPointerMove(event: PointerEvent): void {
49
+ if (!drag) return;
50
+ drag.raw = drag.start + (event.clientX - drag.x);
51
+ drag.moved = true;
52
+ if (drag.frame) return;
53
+ drag.frame = requestAnimationFrame(() => {
54
+ if (!drag) return;
55
+ drag.frame = 0;
56
+ apply(false);
57
+ });
58
+ }
59
+
60
+ function finish(cancel: boolean): void {
61
+ if (!drag) return;
62
+ cancelAnimationFrame(drag.frame);
63
+ const ended = drag;
64
+ drag = null;
65
+ setResizing(false);
66
+ if (!ended.moved) return;
67
+ if (cancel) onChange(ended.width, ended.folded, true);
68
+ else {
69
+ const resolved = resolveSidebarDrag(ended.raw);
70
+ onChange(resolved.width ?? ended.width, resolved.folded, true);
71
+ }
72
+ }
73
+
74
+ function onKeyDown(event: KeyboardEvent): void {
75
+ const open = (next: number) => onChange(clampSidebarWidth(next), false, true);
76
+ if (event.key === 'ArrowLeft') {
77
+ event.preventDefault();
78
+ if (folded) return;
79
+ if (width - SIDEBAR_KEY_STEP < SIDEBAR_MIN_WIDTH) onChange(width, true, true);
80
+ else open(width - SIDEBAR_KEY_STEP);
81
+ } else if (event.key === 'ArrowRight') {
82
+ event.preventDefault();
83
+ open(folded ? width : width + SIDEBAR_KEY_STEP);
84
+ } else if (event.key === 'Home') {
85
+ event.preventDefault();
86
+ open(SIDEBAR_MIN_WIDTH);
87
+ } else if (event.key === 'End') {
88
+ event.preventDefault();
89
+ open(SIDEBAR_MAX_WIDTH);
90
+ } else if (event.key === 'Enter') {
91
+ event.preventDefault();
92
+ onChange(width, !folded, true);
93
+ }
94
+ }
95
+
96
+ $effect(() => () => {
97
+ if (drag) cancelAnimationFrame(drag.frame);
98
+ setResizing(false);
99
+ });
100
+ </script>
101
+
102
+ <svelte:window onkeydown={(event) => { if (event.key === 'Escape' && drag) finish(true); }} />
103
+
104
+ <div
105
+ bind:this={handle}
106
+ class="stor-sidebar-handle"
107
+ role="separator"
108
+ tabindex="0"
109
+ aria-orientation="vertical"
110
+ aria-label="Resize sidebar"
111
+ aria-valuemin={SIDEBAR_MIN_WIDTH}
112
+ aria-valuemax={SIDEBAR_MAX_WIDTH}
113
+ aria-valuenow={current}
114
+ aria-valuetext={folded ? 'Folded to rail' : `${width} pixels`}
115
+ data-stor-no-drag
116
+ onpointerdown={onPointerDown}
117
+ onpointermove={onPointerMove}
118
+ onpointerup={() => finish(false)}
119
+ onpointercancel={() => finish(true)}
120
+ ondblclick={() => onChange(SIDEBAR_DEFAULT_WIDTH, false, true)}
121
+ onkeydown={onKeyDown}
122
+ ></div>
@@ -0,0 +1,24 @@
1
+ <script lang="ts">
2
+ let { pendingCount }: { pendingCount: number } = $props();
3
+ </script>
4
+
5
+ {#if pendingCount > 0}
6
+ <div
7
+ class="fixed bottom-4 left-4 bg-amber-500 text-white px-3 py-2 rounded-lg shadow-[var(--stor-elevation-3)] flex items-center space-x-2 z-50"
8
+ >
9
+ <svg
10
+ class="w-4 h-4 animate-pulse"
11
+ fill="none"
12
+ stroke="currentColor"
13
+ viewBox="0 0 24 24"
14
+ >
15
+ <path
16
+ stroke-linecap="round"
17
+ stroke-linejoin="round"
18
+ stroke-width="2"
19
+ d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
20
+ ></path>
21
+ </svg>
22
+ <span class="text-sm font-medium">{pendingCount} pending</span>
23
+ </div>
24
+ {/if}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Icon assignments for feature navigation, shared by the navigation shell.
3
+ *
4
+ * Kept separate from the feature registry so the registry stays free of
5
+ * browser-only imports (Node build scripts import it directly).
6
+ */
7
+
8
+ import type { Component } from "svelte";
9
+ import IconCalendar from "~icons/mdi/calendar-month-outline";
10
+ import IconCode from "~icons/mdi/code-tags";
11
+ import IconDatabase from "~icons/mdi/database-cog-outline";
12
+ import type { FeatureId } from "@hearthlog/contracts/features/registry";
13
+ import { MODULE_ICONS } from "#generated/module-icons";
14
+
15
+ export const FEATURE_ICONS: Record<FeatureId, Component> = {
16
+ ...MODULE_ICONS,
17
+ calendar: IconCalendar,
18
+ };
19
+
20
+ /** The policy-scoped Entities viewer (not a feature). */
21
+ export const ENTITIES_ICON: Component = IconDatabase;
22
+
23
+ /** The policy-scoped Developer page (not a feature). */
24
+ export const DEVELOPER_ICON: Component = IconCode;