@hearthlog/shell 0.2.0 → 0.3.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.
- package/package.json +5 -5
- package/src/app.template.html +5 -2
- package/src/components/calendar/calendar-attention.ts +15 -0
- package/src/components/layout/LastChangeBar.svelte +73 -0
- package/src/components/layout/NavBar.svelte +89 -9
- package/src/components/layout/NavFlyout.svelte +11 -2
- package/src/components/layout/NavItem.svelte +11 -4
- package/src/components/layout/last-change.svelte.ts +80 -0
- package/src/components/layout/nav-indicator.ts +7 -5
- package/src/components/layout/recent-places.svelte.ts +84 -0
- package/src/pages/layout/Layout.svelte +8 -5
- package/src/styles/shell.css +43 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hearthlog/shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Hearthlog's application shell: layout and navigation, the log panel and history, the calendar, sync status, and the pages the host serves",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"repository": {
|
|
@@ -33,10 +33,10 @@
|
|
|
33
33
|
"@tanstack/svelte-table": "^9.2.4",
|
|
34
34
|
"bits-ui": "^2.19.3",
|
|
35
35
|
"date-fns": "^4.4.0",
|
|
36
|
-
"@hearthlog/
|
|
37
|
-
"@hearthlog/
|
|
38
|
-
"@hearthlog/contracts": "0.
|
|
39
|
-
"@hearthlog/
|
|
36
|
+
"@hearthlog/runtime": "0.3.0",
|
|
37
|
+
"@hearthlog/ui": "0.3.0",
|
|
38
|
+
"@hearthlog/contracts": "0.3.0",
|
|
39
|
+
"@hearthlog/czech": "0.3.0"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"svelte": "^5.57.1",
|
package/src/app.template.html
CHANGED
|
@@ -137,7 +137,7 @@
|
|
|
137
137
|
})();
|
|
138
138
|
</script>
|
|
139
139
|
<style>
|
|
140
|
-
:root{--boot-bg:var(--stor-bg);--boot-border:var(--stor-border);--boot-sidebar-width:var(--stor-sidebar-width,224px);--boot-sidebar-margin:
|
|
140
|
+
:root{--boot-bg:var(--stor-bg);--boot-border:var(--stor-border);--boot-sidebar-width:var(--stor-sidebar-width,224px);--boot-sidebar-margin:0px;--boot-sidebar-radius:0px;--boot-page-margin:12px;--boot-page-radius:20px}
|
|
141
141
|
html[data-sidebar-collapsed="true"]{--boot-sidebar-width:80px}html,body{margin:0;min-width:320px;background:var(--boot-bg)}
|
|
142
142
|
/* Shared with NavBar: branding must keep the same geometry before and after mount. */
|
|
143
143
|
:root{--stor-font-sans:'Inter Variable',ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;--stor-font-mono:'Fira Code Variable',ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,'Liberation Mono','Courier New',monospace}
|
|
@@ -147,6 +147,8 @@
|
|
|
147
147
|
.stor-wordmark{white-space:nowrap}.stor-wordmark .stor-mark{margin:0 .03em}
|
|
148
148
|
#stor-boot-shell{position:fixed;inset:0;z-index:9998;background:var(--boot-bg);font:14px/1.5 var(--stor-font-sans);color:var(--stor-ink)}
|
|
149
149
|
.stor-boot-sidebar{position:absolute;left:var(--boot-sidebar-margin);top:var(--boot-sidebar-margin);bottom:var(--boot-sidebar-margin);width:var(--boot-sidebar-width);background:var(--stor-sidebar);border:0;border-radius:var(--boot-sidebar-radius);box-shadow:var(--stor-sidebar-shadow);box-sizing:border-box;overflow:hidden}
|
|
150
|
+
/* The page panel beside the sidebar, as Layout draws it from the rail up. */
|
|
151
|
+
.stor-boot-page{position:absolute;left:calc(var(--boot-sidebar-width) + 2 * var(--boot-sidebar-margin));top:var(--boot-page-margin);right:var(--boot-page-margin);bottom:var(--boot-page-margin);border-radius:var(--boot-page-radius);background:var(--stor-surface);box-shadow:var(--stor-elevation-1)}
|
|
150
152
|
.stor-boot-brandbar{height:72px;display:flex;gap:12px;align-items:center;padding:0 14px;box-sizing:border-box}
|
|
151
153
|
html[data-sidebar-collapsed="true"] .stor-boot-brandbar{justify-content:center;padding:0 8px}
|
|
152
154
|
.stor-boot-mark{display:none}
|
|
@@ -162,7 +164,7 @@
|
|
|
162
164
|
html[data-stor-shell="mac"]:not([data-sidebar-collapsed="true"]) .stor-boot-brandbar{height:52px;padding-left:76px}
|
|
163
165
|
/* The Mac sidebar shows no brand; the bar stays to hold the drag space under the window controls. */
|
|
164
166
|
html[data-stor-shell="mac"] .stor-boot-brandbar>*{display:none}
|
|
165
|
-
@media(max-width:767px){.stor-brand-logo{font-size:28px}.stor-boot-sidebar{display:none}.stor-boot-mobile-head{display:flex;align-items:center;box-sizing:border-box;height:calc(56px + env(safe-area-inset-top));padding:env(safe-area-inset-top) 12px 0;background:var(--stor-bg)}.stor-boot-mobile-nav{display:block;position:absolute;left:12px;right:12px;bottom:max(12px,env(safe-area-inset-bottom));height:64px;background:var(--stor-surface-raised);border-radius:24px;box-shadow:var(--stor-shadow-md)}.stor-boot-recovery{left:16px;right:16px;bottom:84px}}
|
|
167
|
+
@media(max-width:767px){.stor-brand-logo{font-size:28px}.stor-boot-sidebar,.stor-boot-page{display:none}.stor-boot-mobile-head{display:flex;align-items:center;box-sizing:border-box;height:calc(56px + env(safe-area-inset-top));padding:env(safe-area-inset-top) 12px 0;background:var(--stor-bg)}.stor-boot-mobile-nav{display:block;position:absolute;left:12px;right:12px;bottom:max(12px,env(safe-area-inset-bottom));height:64px;background:var(--stor-surface-raised);border-radius:24px;box-shadow:var(--stor-shadow-md)}.stor-boot-recovery{left:16px;right:16px;bottom:84px}}
|
|
166
168
|
@media(max-width:359px){.stor-boot-mobile-title{display:none}}@media(min-width:360px){.stor-boot-mobile-head .stor-brand-logo{display:none}}
|
|
167
169
|
</style>
|
|
168
170
|
%sveltekit.head%
|
|
@@ -170,6 +172,7 @@
|
|
|
170
172
|
<body data-sveltekit-preload-code="hover">
|
|
171
173
|
<div id="stor-boot-shell" inert aria-hidden="true">
|
|
172
174
|
<aside class="stor-boot-sidebar"><div class="stor-boot-brandbar"><span class="stor-boot-title stor-brand-title">%%BRAND_WORDMARK%%</span><span class="stor-boot-mark stor-brand-logo"><span class="stor-mark"></span></span></div></aside>
|
|
175
|
+
<div class="stor-boot-page"></div>
|
|
173
176
|
<div class="stor-boot-mobile-head"><span class="stor-brand-logo"><span class="stor-mark"></span></span><span class="stor-brand-title stor-boot-mobile-title">%%BRAND_WORDMARK%%</span></div>
|
|
174
177
|
<div class="stor-boot-mobile-nav"></div>
|
|
175
178
|
<section class="stor-boot-recovery" aria-labelledby="stor-boot-recovery-title">
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { differenceInCalendarDays, format, parseISO } from 'date-fns';
|
|
2
|
+
import type { AttentionContribution } from '@hearthlog/contracts';
|
|
3
|
+
import { queryDatabase } from '@hearthlog/runtime/db/read-repositories.js';
|
|
4
|
+
|
|
5
|
+
/** The calendar's own figure in the sidebar: the next pinned date, the one its page counts down to. */
|
|
6
|
+
export const calendarAttention: AttentionContribution = {
|
|
7
|
+
async figures({ today }) {
|
|
8
|
+
const [next] = await queryDatabase((repositories) => repositories.calendar.getUpcomingPinnedCalendarEvents(today, 1));
|
|
9
|
+
if (!next) return [];
|
|
10
|
+
const date = parseISO(next.event_date);
|
|
11
|
+
const days = differenceInCalendarDays(date, parseISO(today));
|
|
12
|
+
const text = days === 0 ? 'Today' : days === 1 ? 'Tomorrow' : format(date, days < 7 ? 'EEE d' : 'd MMM');
|
|
13
|
+
return [{ id: 'calendar', text, label: `next pinned: ${next.title}, ${format(date, 'EEEE d MMMM')}` }];
|
|
14
|
+
},
|
|
15
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from "svelte";
|
|
3
|
+
import { page } from "$app/state";
|
|
4
|
+
import { navigateHistory } from "@hearthlog/runtime/history/navigation.js";
|
|
5
|
+
import IconCheck from "~icons/mdi/check-circle-outline";
|
|
6
|
+
import IconUndo from "~icons/mdi/undo-variant";
|
|
7
|
+
import IconClose from "~icons/mdi/close";
|
|
8
|
+
import { lastChange } from "./last-change.svelte.js";
|
|
9
|
+
|
|
10
|
+
let { collapsed, logId }: {
|
|
11
|
+
/** The rail is in place, so the bar centres over the narrower offset. */
|
|
12
|
+
collapsed: boolean;
|
|
13
|
+
logId: string | null;
|
|
14
|
+
} = $props();
|
|
15
|
+
|
|
16
|
+
$effect(() => lastChange.start());
|
|
17
|
+
|
|
18
|
+
// A change belongs to its log; switching logs takes it away.
|
|
19
|
+
$effect(() => {
|
|
20
|
+
void logId;
|
|
21
|
+
untrack(() => lastChange.dismiss());
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// It stays while you work on the same page and leaves after two moves to other pages.
|
|
25
|
+
let moves = 0;
|
|
26
|
+
$effect(() => {
|
|
27
|
+
void lastChange.current?.batchId;
|
|
28
|
+
moves = 0;
|
|
29
|
+
});
|
|
30
|
+
$effect(() => {
|
|
31
|
+
void page.url.pathname;
|
|
32
|
+
untrack(() => {
|
|
33
|
+
if (lastChange.current && ++moves >= 2) lastChange.dismiss();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const change = $derived(lastChange.current);
|
|
38
|
+
const message = $derived.by(() => {
|
|
39
|
+
if (!change) return "";
|
|
40
|
+
if (change.state === "undoing") return "Undoing…";
|
|
41
|
+
if (change.state === "undone") return `Undid “${change.label}”.`;
|
|
42
|
+
if (change.state === "failed") return "Could not undo this change. Open History to review it.";
|
|
43
|
+
return `${change.label}.`;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function openHistory(): void {
|
|
47
|
+
const url = new URL(window.location.href);
|
|
48
|
+
url.searchParams.set("logPanel", "activity");
|
|
49
|
+
navigateHistory(url);
|
|
50
|
+
lastChange.dismiss();
|
|
51
|
+
}
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
{#if change}
|
|
55
|
+
<!-- From 1024px toasts stack at the bottom right, so the bar starts at the panel's left and stops short of them. -->
|
|
56
|
+
<div class="pointer-events-none fixed bottom-6 right-3 z-[var(--stor-layer-toast)] hidden justify-center md:flex lg:right-[calc(24rem+40px)] lg:justify-start lg:pl-6 {collapsed ? 'md:left-20' : 'md:left-20 lg:left-[var(--stor-sidebar-width)]'}">
|
|
57
|
+
<div class="stor-last-change pointer-events-auto" role="status" data-state={change.state}>
|
|
58
|
+
{#if change.state === "undone"}
|
|
59
|
+
<IconUndo class="h-[18px] w-[18px] shrink-0 text-[var(--stor-muted-text)]" aria-hidden="true" />
|
|
60
|
+
{:else}
|
|
61
|
+
<IconCheck class="h-[18px] w-[18px] shrink-0 text-[var(--stor-positive)]" aria-hidden="true" />
|
|
62
|
+
{/if}
|
|
63
|
+
<span class="min-w-0 flex-1 truncate">{message}</span>
|
|
64
|
+
{#if change.reversal && (change.state === "saved" || change.state === "undoing")}
|
|
65
|
+
<button type="button" class="stor-last-change-action" disabled={change.state === "undoing"} onclick={() => void lastChange.undo()}>Undo</button>
|
|
66
|
+
{/if}
|
|
67
|
+
<button type="button" class="stor-last-change-action stor-last-change-quiet" onclick={openHistory}>History</button>
|
|
68
|
+
<button type="button" class="stor-last-change-close" aria-label="Dismiss" onclick={() => lastChange.dismiss()}>
|
|
69
|
+
<IconClose class="h-4 w-4" aria-hidden="true" />
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
{/if}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
type SyncPhase,
|
|
12
12
|
} from "@hearthlog/runtime/store/sync-state.js";
|
|
13
13
|
import { navIndicator } from "./nav-indicator.js";
|
|
14
|
+
import { placeHref, placeTitle, recentPlaces } from "./recent-places.svelte.js";
|
|
14
15
|
import SidebarResizer from "./SidebarResizer.svelte";
|
|
15
16
|
import NavItem from "./NavItem.svelte";
|
|
16
17
|
import NavFlyout from "./NavFlyout.svelte";
|
|
@@ -224,11 +225,63 @@
|
|
|
224
225
|
syncPhase === "syncing",
|
|
225
226
|
);
|
|
226
227
|
|
|
228
|
+
function pathMatches(pathname: string, path: string): boolean {
|
|
229
|
+
if (path === "/") return pathname === "/";
|
|
230
|
+
return pathname === path || pathname.startsWith(`${path}/`);
|
|
231
|
+
}
|
|
232
|
+
|
|
227
233
|
function isActive(path: string): boolean {
|
|
228
|
-
|
|
229
|
-
|
|
234
|
+
return pathMatches(page.url.pathname, path);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------- recently
|
|
238
|
+
/** The place in view, once its title settles: pages name it in `<title>` after their data loads. */
|
|
239
|
+
function rememberPlace(): void {
|
|
240
|
+
// An error page is no place to return to.
|
|
241
|
+
if (page.error) return;
|
|
242
|
+
const url = new URL(window.location.href);
|
|
243
|
+
const link = navLinks.find((candidate) => (candidate.matchPaths ?? [candidate.path]).some((path) => pathMatches(url.pathname, path)));
|
|
244
|
+
if (!link) return;
|
|
245
|
+
const title = placeTitle(document.title, link.label, tenant.title);
|
|
246
|
+
if (title) recentPlaces.record({ href: placeHref(url), title, section: link.id });
|
|
230
247
|
}
|
|
231
248
|
|
|
249
|
+
let rememberTimer: ReturnType<typeof setTimeout> | undefined;
|
|
250
|
+
function settlePlace(): void {
|
|
251
|
+
clearTimeout(rememberTimer);
|
|
252
|
+
rememberTimer = setTimeout(rememberPlace, 700);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// The log's list opens with its data; a title change or a move to another address records the place.
|
|
256
|
+
$effect(() => {
|
|
257
|
+
if (!dataReady || !currentLogId) return;
|
|
258
|
+
recentPlaces.open(tenant.id, currentLogId);
|
|
259
|
+
const titles = new MutationObserver(settlePlace);
|
|
260
|
+
titles.observe(document.head, { subtree: true, childList: true, characterData: true });
|
|
261
|
+
settlePlace();
|
|
262
|
+
return () => {
|
|
263
|
+
clearTimeout(rememberTimer);
|
|
264
|
+
titles.disconnect();
|
|
265
|
+
};
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
$effect(() => {
|
|
269
|
+
void page.url.href;
|
|
270
|
+
if (dataReady && currentLogId) settlePlace();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
/** Up to four places outside the section in view, each with its section's icon. */
|
|
274
|
+
const recentShown = $derived.by(() => {
|
|
275
|
+
const here = navLinks.find(isLinkActive)?.id ?? null;
|
|
276
|
+
return recentPlaces.places
|
|
277
|
+
.filter((place) => place.section !== here)
|
|
278
|
+
.flatMap((place) => {
|
|
279
|
+
const link = navLinks.find((candidate) => candidate.id === place.section);
|
|
280
|
+
return link ? [{ ...place, icon: link.icon, sectionLabel: link.label }] : [];
|
|
281
|
+
})
|
|
282
|
+
.slice(0, 4);
|
|
283
|
+
});
|
|
284
|
+
|
|
232
285
|
function isLinkActive(link: NavLink): boolean {
|
|
233
286
|
return (link.matchPaths ?? [link.path]).some(isActive);
|
|
234
287
|
}
|
|
@@ -319,7 +372,7 @@
|
|
|
319
372
|
</script>
|
|
320
373
|
|
|
321
374
|
<aside
|
|
322
|
-
class="fixed left-
|
|
375
|
+
class="fixed left-0 top-0 bottom-0 z-50 hidden flex-col bg-[var(--stor-sidebar)] transition-[width] duration-150 ease-out motion-reduce:transition-none md:flex {collapsed
|
|
323
376
|
? 'w-20'
|
|
324
377
|
: 'w-[var(--stor-sidebar-width)]'}"
|
|
325
378
|
data-testid="desktop-sidebar"
|
|
@@ -354,8 +407,10 @@
|
|
|
354
407
|
{#each group.links as link}
|
|
355
408
|
{@const active = isLinkActive(link)}
|
|
356
409
|
{@const badge = attention.badge(link.id)}
|
|
410
|
+
{@const figure = attention.figure(link.id)}
|
|
357
411
|
{#if !collapsed}
|
|
358
|
-
|
|
412
|
+
<!-- The current section is one raised card: its row and its open sub-pages. -->
|
|
413
|
+
<div data-nav-section data-active={active ? '' : undefined}>
|
|
359
414
|
<!-- The count sits on the sub-page that holds the work while the section is open. -->
|
|
360
415
|
<NavItem
|
|
361
416
|
href={linkHref(link.path)}
|
|
@@ -364,6 +419,7 @@
|
|
|
364
419
|
{active}
|
|
365
420
|
{collapsed}
|
|
366
421
|
{badge}
|
|
422
|
+
{figure}
|
|
367
423
|
showCount={!(active && link.children)}
|
|
368
424
|
/>
|
|
369
425
|
{#if link.children}
|
|
@@ -374,15 +430,18 @@
|
|
|
374
430
|
{#each link.children as child}
|
|
375
431
|
{@const childActive = active && isChildActive(link, child)}
|
|
376
432
|
{@const childBadge = child.badgeId ? attention.badge(child.badgeId) : null}
|
|
433
|
+
{@const childFigure = childBadge ? null : attention.figure(child.id)}
|
|
377
434
|
<li><a
|
|
378
435
|
href={childHref(link, child)}
|
|
379
436
|
class="stor-nav-child {childActive ? 'stor-nav-child-active' : ''}"
|
|
380
437
|
aria-current={childActive ? "page" : undefined}
|
|
381
|
-
aria-label={childBadge ?
|
|
438
|
+
aria-label={childBadge || childFigure ? [child.label, childBadge?.label ?? childFigure?.label].join(', ') : undefined}
|
|
382
439
|
>
|
|
383
|
-
<span class="min-w-0 flex-1
|
|
440
|
+
<span class="min-w-0 truncate {childFigure ? 'flex-none' : 'flex-1'}">{child.label}</span>
|
|
384
441
|
{#if childBadge}
|
|
385
442
|
<span class="stor-nav-badge {childBadge.tone === 'negative' ? 'stor-nav-badge-negative' : 'stor-nav-badge-warning'}" aria-hidden="true">{childBadge.count}</span>
|
|
443
|
+
{:else if childFigure}
|
|
444
|
+
<span class="stor-nav-figure" aria-hidden="true">{childFigure.text}</span>
|
|
386
445
|
{/if}
|
|
387
446
|
</a></li>
|
|
388
447
|
{/each}
|
|
@@ -396,22 +455,24 @@
|
|
|
396
455
|
<NavFlyout
|
|
397
456
|
label={link.label}
|
|
398
457
|
{badge}
|
|
458
|
+
{figure}
|
|
399
459
|
items={link.children.map((child) => ({
|
|
400
460
|
child,
|
|
401
461
|
href: childHref(link, child),
|
|
402
462
|
active: active && isChildActive(link, child),
|
|
403
463
|
badge: child.badgeId ? attention.badge(child.badgeId) : null,
|
|
464
|
+
figure: attention.figure(child.id),
|
|
404
465
|
}))}
|
|
405
466
|
>
|
|
406
467
|
{#snippet trigger()}
|
|
407
|
-
<NavItem href={linkHref(link.path)} icon={link.icon} label={link.label} {active} {collapsed} {badge} />
|
|
468
|
+
<NavItem href={linkHref(link.path)} icon={link.icon} label={link.label} {active} {collapsed} {badge} {figure} />
|
|
408
469
|
{/snippet}
|
|
409
470
|
</NavFlyout>
|
|
410
471
|
{:else}
|
|
411
472
|
<!-- Rail: a section without pages names itself in a tooltip. -->
|
|
412
|
-
<Tooltip content={
|
|
473
|
+
<Tooltip content={[link.label, badge?.label, figure?.label].filter(Boolean).join(' · ')} side="right" delayDuration={150}>
|
|
413
474
|
{#snippet trigger({ props })}
|
|
414
|
-
<NavItem {...props} href={linkHref(link.path)} icon={link.icon} label={link.label} {active} {collapsed} {badge} />
|
|
475
|
+
<NavItem {...props} href={linkHref(link.path)} icon={link.icon} label={link.label} {active} {collapsed} {badge} {figure} />
|
|
415
476
|
{/snippet}
|
|
416
477
|
</Tooltip>
|
|
417
478
|
{/if}
|
|
@@ -421,6 +482,25 @@
|
|
|
421
482
|
{/each}
|
|
422
483
|
</nav>
|
|
423
484
|
|
|
485
|
+
{#if !collapsed && recentShown.length > 0}
|
|
486
|
+
<!-- Where this device was lately in this log, outside the section in view. -->
|
|
487
|
+
<section class="shrink-0 px-3 pb-2" aria-label="Recently">
|
|
488
|
+
<p class="px-3 pb-1.5 pt-1 text-[10.5px] font-semibold uppercase tracking-[0.1em] text-[var(--stor-muted-text)]">Recently</p>
|
|
489
|
+
<ul class="flex flex-col gap-px">
|
|
490
|
+
{#each recentShown as place (place.href)}
|
|
491
|
+
{@const Icon = place.icon}
|
|
492
|
+
<li><a href={place.href} class="stor-recent-place" aria-label={`${place.title}, ${place.sectionLabel}`}>
|
|
493
|
+
<Icon class="h-4 w-4 shrink-0 text-[var(--stor-muted-text)]" />
|
|
494
|
+
<span class="flex min-w-0 flex-col leading-tight" aria-hidden="true">
|
|
495
|
+
<span class="truncate text-[13px] font-medium text-[var(--stor-ink)]">{place.title}</span>
|
|
496
|
+
<span class="truncate text-[11.5px] text-[var(--stor-muted-text)]">{place.sectionLabel}</span>
|
|
497
|
+
</span>
|
|
498
|
+
</a></li>
|
|
499
|
+
{/each}
|
|
500
|
+
</ul>
|
|
501
|
+
</section>
|
|
502
|
+
{/if}
|
|
503
|
+
|
|
424
504
|
<!-- Places sit above the line; the log, its sync state and the account sit below it. -->
|
|
425
505
|
<div class="shrink-0 border-t border-[var(--stor-border)] {collapsed ? 'flex flex-col items-center gap-0.5 px-2 py-2.5' : 'space-y-0.5 px-2.5 py-2'}">
|
|
426
506
|
<LogStatusChip
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { NavFigure } from '@hearthlog/contracts';
|
|
3
4
|
import type { NavChild } from '@hearthlog/contracts/features/navigation';
|
|
4
5
|
import type { AttentionTone } from '@hearthlog/runtime/store/attention.svelte.js';
|
|
5
6
|
|
|
@@ -9,17 +10,21 @@
|
|
|
9
10
|
href: string;
|
|
10
11
|
active: boolean;
|
|
11
12
|
badge: Badge | null;
|
|
13
|
+
figure: NavFigure | null;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
let {
|
|
15
17
|
label,
|
|
16
18
|
badge,
|
|
19
|
+
figure = null,
|
|
17
20
|
items,
|
|
18
21
|
trigger,
|
|
19
22
|
}: {
|
|
20
23
|
label: string;
|
|
21
24
|
/** The section's own badge, named in the flyout header. */
|
|
22
25
|
badge: Badge | null;
|
|
26
|
+
/** The section's live figure, shown in the header while nothing is overdue. */
|
|
27
|
+
figure?: NavFigure | null;
|
|
23
28
|
items: Item[];
|
|
24
29
|
/** The section link. It stays the real navigation target; the flyout only adds shortcuts. */
|
|
25
30
|
trigger: Snippet;
|
|
@@ -111,6 +116,8 @@
|
|
|
111
116
|
<span class="min-w-0 truncate">{label}</span>
|
|
112
117
|
{#if badge}
|
|
113
118
|
<span class="stor-nav-badge {badge.tone === 'negative' ? 'stor-nav-badge-negative' : 'stor-nav-badge-warning'} shrink-0">{badge.count} overdue</span>
|
|
119
|
+
{:else if figure}
|
|
120
|
+
<span class="stor-nav-figure" title={figure.label}>{figure.text}</span>
|
|
114
121
|
{/if}
|
|
115
122
|
</div>
|
|
116
123
|
<ul>
|
|
@@ -119,11 +126,13 @@
|
|
|
119
126
|
href={item.href}
|
|
120
127
|
class="stor-nav-child {item.active ? 'stor-nav-child-active' : ''}"
|
|
121
128
|
aria-current={item.active ? 'page' : undefined}
|
|
122
|
-
aria-label={item.badge ?
|
|
129
|
+
aria-label={item.badge || item.figure ? [item.child.label, item.badge?.label ?? item.figure?.label].join(', ') : undefined}
|
|
123
130
|
>
|
|
124
|
-
<span class="min-w-0 flex-1
|
|
131
|
+
<span class="min-w-0 truncate {!item.badge && item.figure ? 'flex-none' : 'flex-1'}">{item.child.label}</span>
|
|
125
132
|
{#if item.badge}
|
|
126
133
|
<span class="stor-nav-badge {item.badge.tone === 'negative' ? 'stor-nav-badge-negative' : 'stor-nav-badge-warning'}" aria-hidden="true">{item.badge.count}</span>
|
|
134
|
+
{:else if item.figure}
|
|
135
|
+
<span class="stor-nav-figure" aria-hidden="true">{item.figure.text}</span>
|
|
127
136
|
{/if}
|
|
128
137
|
</a></li>
|
|
129
138
|
{/each}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Component } from 'svelte';
|
|
3
3
|
import type { HTMLAnchorAttributes } from 'svelte/elements';
|
|
4
|
+
import type { NavFigure } from '@hearthlog/contracts';
|
|
4
5
|
import type { AttentionTone } from '@hearthlog/runtime/store/attention.svelte.js';
|
|
5
6
|
|
|
6
7
|
interface Props extends Omit<HTMLAnchorAttributes, 'class' | 'children'> {
|
|
@@ -12,9 +13,14 @@
|
|
|
12
13
|
badge: { count: number; tone: AttentionTone; label: string } | null;
|
|
13
14
|
/** The count sits on the sub-page while a section is open, so the section hides it then. */
|
|
14
15
|
showCount?: boolean;
|
|
16
|
+
/** The section's live figure, muted beside the label; a shown count takes its place. */
|
|
17
|
+
figure?: NavFigure | null;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
|
-
let { icon: Icon, label, active, collapsed, badge, showCount = true, ...rest }: Props = $props();
|
|
20
|
+
let { icon: Icon, label, active, collapsed, badge, showCount = true, figure = null, ...rest }: Props = $props();
|
|
21
|
+
|
|
22
|
+
const countShown = $derived(!!badge && (collapsed || showCount));
|
|
23
|
+
const figureShown = $derived(!!figure && !collapsed && !countShown);
|
|
18
24
|
</script>
|
|
19
25
|
|
|
20
26
|
<!-- `rest` may carry tooltip props; the anchor is never a button, so its type is dropped. -->
|
|
@@ -26,11 +32,12 @@
|
|
|
26
32
|
? 'stor-nav-selected font-semibold text-[var(--stor-ink)]'
|
|
27
33
|
: 'font-medium text-[var(--stor-muted-text)] hover:bg-[var(--stor-nav-hover)] hover:text-[var(--stor-ink)]'}"
|
|
28
34
|
aria-current={active ? 'page' : undefined}
|
|
29
|
-
aria-label={
|
|
35
|
+
aria-label={[label, badge?.label, figure?.label].filter(Boolean).join(', ')}
|
|
30
36
|
>
|
|
31
37
|
<Icon class="h-[18px] w-[18px] shrink-0" />
|
|
32
|
-
{#if !collapsed}<span class="min-w-0 flex-1
|
|
33
|
-
{#if
|
|
38
|
+
{#if !collapsed}<span class="min-w-0 truncate {figureShown ? 'flex-none' : 'flex-1'}">{label}</span>{/if}
|
|
39
|
+
{#if figureShown}<span class="stor-nav-figure" aria-hidden="true">{figure?.text}</span>{/if}
|
|
40
|
+
{#if countShown && badge}
|
|
34
41
|
<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
42
|
{/if}
|
|
36
43
|
</a>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { executeLocalCommand, onLocalCommandCommitted } from '@hearthlog/runtime/commands/index.js';
|
|
2
|
+
import { undoEvents, undoTargets, type UndoTarget } from '@hearthlog/runtime/commands/undo.js';
|
|
3
|
+
import { queryDatabase } from '@hearthlog/runtime/db/read-repositories.js';
|
|
4
|
+
import type { ExtendedAppEvent } from '@hearthlog/runtime/event/types.js';
|
|
5
|
+
import { refreshPageData } from '@hearthlog/runtime/runtime/refresh.js';
|
|
6
|
+
import { SYNC_INVALIDATION_TARGETS } from '@hearthlog/runtime/store/sync-invalidation.js';
|
|
7
|
+
|
|
8
|
+
const LIFETIME_MS = 10 * 60_000;
|
|
9
|
+
|
|
10
|
+
export interface LastChange {
|
|
11
|
+
batchId: string;
|
|
12
|
+
/** As Log history names the action ("Added Albert Vinohrady"). */
|
|
13
|
+
label: string;
|
|
14
|
+
/** The records whose removal reverses it, when it only added standard records; null otherwise. */
|
|
15
|
+
reversal: UndoTarget[] | null;
|
|
16
|
+
state: 'saved' | 'undoing' | 'undone' | 'failed';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function actionLabel(eventIds: readonly string[]): Promise<string> {
|
|
20
|
+
try {
|
|
21
|
+
const [latest] = await queryDatabase((repositories) => repositories.history.list({ limit: 1 }));
|
|
22
|
+
if (latest?.kind === 'action' && eventIds.includes(latest.id)) return latest.label;
|
|
23
|
+
} catch {
|
|
24
|
+
// The label is a courtesy; the change itself is saved.
|
|
25
|
+
}
|
|
26
|
+
return 'Saved your change';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The latest change made in this tab, offered back for undo until the person moves on. */
|
|
30
|
+
class LastChangeStore {
|
|
31
|
+
current = $state<LastChange | null>(null);
|
|
32
|
+
#undoing = false;
|
|
33
|
+
#timer: ReturnType<typeof setTimeout> | undefined;
|
|
34
|
+
|
|
35
|
+
/** Follows this tab's commits; returns the teardown. */
|
|
36
|
+
start(): () => void {
|
|
37
|
+
const stop = onLocalCommandCommitted((result, events) => {
|
|
38
|
+
// The undo's own removals are not a new change to offer back.
|
|
39
|
+
if (this.#undoing) return;
|
|
40
|
+
void this.#show(result.batchId, result.eventIds, events);
|
|
41
|
+
});
|
|
42
|
+
return () => {
|
|
43
|
+
stop();
|
|
44
|
+
this.dismiss();
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async #show(batchId: string, eventIds: readonly string[], events: readonly ExtendedAppEvent[]): Promise<void> {
|
|
49
|
+
const reversal = undoTargets(events);
|
|
50
|
+
const label = await actionLabel(eventIds);
|
|
51
|
+
this.current = { batchId, label, reversal, state: 'saved' };
|
|
52
|
+
clearTimeout(this.#timer);
|
|
53
|
+
this.#timer = setTimeout(() => this.dismiss(), LIFETIME_MS);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
dismiss(): void {
|
|
57
|
+
clearTimeout(this.#timer);
|
|
58
|
+
this.current = null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async undo(): Promise<void> {
|
|
62
|
+
const change = this.current;
|
|
63
|
+
if (!change?.reversal || change.state !== 'saved') return;
|
|
64
|
+
this.current = { ...change, state: 'undoing' };
|
|
65
|
+
this.#undoing = true;
|
|
66
|
+
try {
|
|
67
|
+
// One submission per undone action, so a repeated click cannot remove twice.
|
|
68
|
+
await executeLocalCommand(undoEvents(change.reversal), { submissionId: `undo:${change.batchId}` });
|
|
69
|
+
if (this.current?.batchId === change.batchId) this.current = { ...change, state: 'undone' };
|
|
70
|
+
} catch {
|
|
71
|
+
if (this.current?.batchId === change.batchId) this.current = { ...change, state: 'failed' };
|
|
72
|
+
return;
|
|
73
|
+
} finally {
|
|
74
|
+
this.#undoing = false;
|
|
75
|
+
}
|
|
76
|
+
await refreshPageData(...SYNC_INVALIDATION_TARGETS);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const lastChange = new LastChangeStore();
|
|
@@ -31,10 +31,11 @@ function boxIn(el: HTMLElement, nav: HTMLElement): Box {
|
|
|
31
31
|
* `tabIndicator`.
|
|
32
32
|
*
|
|
33
33
|
* Measures the selected top-level link (`a[data-nav-link]` with
|
|
34
|
-
* `aria-current`)
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
34
|
+
* `aria-current`), or the `[data-nav-section]` card around it in the expanded
|
|
35
|
+
* sidebar, and glides a raised pill to it. The pill keeps following the row
|
|
36
|
+
* while a sub-list opens or closes, so it lands on the row's resting position
|
|
37
|
+
* and size rather than where the row was when the page changed. The selected
|
|
38
|
+
* section keeps its own background until the pill is placed, and reduced
|
|
38
39
|
* motion snaps.
|
|
39
40
|
*
|
|
40
41
|
* Usage: `{@attach navIndicator}` on the positioned `<nav>` that holds the links.
|
|
@@ -68,7 +69,8 @@ export function navIndicator(nav: HTMLElement): () => void {
|
|
|
68
69
|
nav.classList.remove('stor-nav-sliding');
|
|
69
70
|
return;
|
|
70
71
|
}
|
|
71
|
-
|
|
72
|
+
// The expanded sidebar raises the whole section, so the pill wraps its open sub-pages too.
|
|
73
|
+
const target = boxIn(selected.closest<HTMLElement>('[data-nav-section]') ?? selected, nav);
|
|
72
74
|
if (!shown || reducedMotion.matches) {
|
|
73
75
|
shown = target;
|
|
74
76
|
} else {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { HISTORY_PARAMS } from '@hearthlog/runtime/history/url.js';
|
|
2
|
+
|
|
3
|
+
/** A place worth returning to: a month, a sub-page, a record. */
|
|
4
|
+
export interface RecentPlace {
|
|
5
|
+
href: string;
|
|
6
|
+
title: string;
|
|
7
|
+
/** The navigation destination it belongs to. */
|
|
8
|
+
section: string;
|
|
9
|
+
at: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const LIMIT = 12;
|
|
13
|
+
|
|
14
|
+
function storageKey(siteId: string, logId: string): string {
|
|
15
|
+
return `stor:recent-places:${siteId}:${logId}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isPlace(value: unknown): value is RecentPlace {
|
|
19
|
+
const place = value as RecentPlace;
|
|
20
|
+
return typeof place?.href === 'string' && typeof place.title === 'string' && typeof place.section === 'string' && typeof place.at === 'number';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function read(key: string): RecentPlace[] {
|
|
24
|
+
try {
|
|
25
|
+
const stored: unknown = JSON.parse(localStorage.getItem(key) ?? '[]');
|
|
26
|
+
return Array.isArray(stored) ? stored.filter(isPlace).slice(0, LIMIT) : [];
|
|
27
|
+
} catch {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The places this device opened in the current log, newest first. They stay on the device (browser storage, never
|
|
34
|
+
* synced) and per log, so switching logs shows that log's places. Advisory: unreadable storage means an empty list.
|
|
35
|
+
*/
|
|
36
|
+
class RecentPlaces {
|
|
37
|
+
places = $state<RecentPlace[]>([]);
|
|
38
|
+
#key: string | null = null;
|
|
39
|
+
|
|
40
|
+
/** Loads the list of a log; a no-op while it is already open. */
|
|
41
|
+
open(siteId: string, logId: string): void {
|
|
42
|
+
const key = storageKey(siteId, logId);
|
|
43
|
+
if (key === this.#key) return;
|
|
44
|
+
this.#key = key;
|
|
45
|
+
this.places = read(key);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Moves a place to the front; one entry per title within a section, so a month or a record appears once. */
|
|
49
|
+
record(place: Omit<RecentPlace, 'at'>): void {
|
|
50
|
+
if (!this.#key) return;
|
|
51
|
+
if (this.places[0]?.href === place.href && this.places[0].title === place.title) return;
|
|
52
|
+
const next = [
|
|
53
|
+
{ ...place, at: Date.now() },
|
|
54
|
+
...this.places.filter((entry) => entry.title !== place.title || entry.section !== place.section),
|
|
55
|
+
].slice(0, LIMIT);
|
|
56
|
+
this.places = next;
|
|
57
|
+
try {
|
|
58
|
+
localStorage.setItem(this.#key, JSON.stringify(next));
|
|
59
|
+
} catch {
|
|
60
|
+
// Storage full or blocked: the list still holds for this tab.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const recentPlaces = new RecentPlaces();
|
|
66
|
+
|
|
67
|
+
/** The address to return to, without one-shot editor params (`new…`) or the log panel's state. */
|
|
68
|
+
export function placeHref(url: URL): string {
|
|
69
|
+
const clean = new URL(url);
|
|
70
|
+
for (const name of [...clean.searchParams.keys()]) {
|
|
71
|
+
if (name.startsWith('new') || (HISTORY_PARAMS as readonly string[]).includes(name)) clean.searchParams.delete(name);
|
|
72
|
+
}
|
|
73
|
+
return clean.pathname + clean.search;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The place's name from the document title ("October 2026 | Finance | Household" names "October 2026"), or null for
|
|
78
|
+
* a page named only by its section or the site, which the navigation already lists.
|
|
79
|
+
*/
|
|
80
|
+
export function placeTitle(documentTitle: string, sectionLabel: string, siteTitle: string): string | null {
|
|
81
|
+
const title = documentTitle.split(' | ')[0]?.trim() ?? '';
|
|
82
|
+
if (!title || title === sectionLabel || title === siteTitle) return null;
|
|
83
|
+
return title;
|
|
84
|
+
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { localStoreStatus } from "@hearthlog/runtime/store/local-store.svelte.js";
|
|
17
17
|
import { logs } from "@hearthlog/runtime/store/logs.svelte.js";
|
|
18
18
|
import NavBar from "@hearthlog/shell/components/layout/NavBar.svelte";
|
|
19
|
+
import LastChangeBar from "@hearthlog/shell/components/layout/LastChangeBar.svelte";
|
|
19
20
|
import { SIDEBAR_WIDTH_KEY, readSidebarWidth } from "@hearthlog/shell/components/layout/sidebar-size.js";
|
|
20
21
|
import LogCreateDrawer from "@hearthlog/shell/components/layout/LogCreateDrawer.svelte";
|
|
21
22
|
import NotificationCenter from "@hearthlog/ui/components/NotificationCenter.svelte";
|
|
@@ -29,7 +30,8 @@
|
|
|
29
30
|
import { offlineReadiness } from "@hearthlog/runtime/store/offline-readiness.svelte.js";
|
|
30
31
|
import { featureForPath, ENTITIES_PATH } from "@hearthlog/contracts/features/registry";
|
|
31
32
|
import { attention } from "@hearthlog/runtime/store/attention.svelte.js";
|
|
32
|
-
import { tenant } from "@hearthlog/contracts/tenant/config";
|
|
33
|
+
import { isFeatureEnabled, tenant } from "@hearthlog/contracts/tenant/config";
|
|
34
|
+
import { calendarAttention } from "@hearthlog/shell/components/calendar/calendar-attention.js";
|
|
33
35
|
import { createAndActivateLog, prepareForkFiles } from "@hearthlog/runtime/log-management.js";
|
|
34
36
|
import { SYNC_INVALIDATION_TARGETS } from "@hearthlog/runtime/store/sync-invalidation.js";
|
|
35
37
|
import { beginReadTargetTransition, failReadTargetTransition } from "@hearthlog/runtime/runtime/read-coordinator.js";
|
|
@@ -104,10 +106,10 @@
|
|
|
104
106
|
return kept ? kept.path : page.url.pathname;
|
|
105
107
|
});
|
|
106
108
|
|
|
107
|
-
// Navigation badges and native reminders come from the composed modules.
|
|
109
|
+
// Navigation badges, figures and native reminders come from the composed modules, plus the calendar's own figure.
|
|
108
110
|
// `startup` is the host's composed module list and never changes for a running page.
|
|
109
111
|
// svelte-ignore state_referenced_locally
|
|
110
|
-
attention.configure(startup.attentionContributions);
|
|
112
|
+
attention.configure([...(isFeatureEnabled("calendar") ? [calendarAttention] : []), ...startup.attentionContributions]);
|
|
111
113
|
|
|
112
114
|
onMount(() => initializeAppearance(tenant.id));
|
|
113
115
|
|
|
@@ -531,8 +533,8 @@
|
|
|
531
533
|
<div
|
|
532
534
|
data-stor-root
|
|
533
535
|
class="flex h-dvh w-full flex-col bg-[var(--stor-bg)] transition-[padding] duration-150 ease-out motion-reduce:transition-none {sidebarCollapsed
|
|
534
|
-
? 'md:pl-
|
|
535
|
-
: 'lg:pl-[
|
|
536
|
+
? 'md:pl-20'
|
|
537
|
+
: 'lg:pl-[var(--stor-sidebar-width)]'}"
|
|
536
538
|
>
|
|
537
539
|
<NavBar
|
|
538
540
|
userEmail={userEmail}
|
|
@@ -578,6 +580,7 @@
|
|
|
578
580
|
<button class="mt-2 block font-semibold underline" onclick={() => window.location.reload()}>Retry loading</button>
|
|
579
581
|
</aside>
|
|
580
582
|
{/if}
|
|
583
|
+
<LastChangeBar collapsed={sidebarCollapsed} logId={eventLogFilename.value} />
|
|
581
584
|
<NotificationCenter />
|
|
582
585
|
<LogCreateDrawer
|
|
583
586
|
show={createLogDrawerOpen}
|
package/src/styles/shell.css
CHANGED
|
@@ -3,11 +3,17 @@
|
|
|
3
3
|
.stor-native-field:focus { border-color: var(--stor-focus); box-shadow: 0 0 0 2px var(--stor-selected); }
|
|
4
4
|
.stor-native-field:disabled { cursor: not-allowed; color: var(--stor-muted-text); }
|
|
5
5
|
@media (min-width: 640px) { .stor-native-field { min-height: 36px; font-size: 14px; } }
|
|
6
|
-
/* Navigation: the current destination is a raised pill, the same selection as tabs.
|
|
6
|
+
/* Navigation: the current destination is a raised pill, the same selection as tabs. In the expanded
|
|
7
|
+
sidebar the pill is a card around the whole section, its row and its open sub-pages. */
|
|
7
8
|
:root { --stor-nav-hover: color-mix(in srgb, var(--stor-ink) 5%, transparent); }
|
|
8
9
|
/* The pill glides between rows (navIndicator). The selected row keeps its own look until the pill is placed. */
|
|
9
|
-
.stor-nav-indicator { position: absolute; z-index: 0; left: 0; top: 0; border-radius:
|
|
10
|
+
.stor-nav-indicator { position: absolute; z-index: 0; left: 0; top: 0; border-radius: 12px; opacity: 0; pointer-events: none; will-change: transform; }
|
|
10
11
|
.stor-nav-sliding .stor-nav-indicator { opacity: 1; }
|
|
12
|
+
[data-nav-section] { position: relative; border-radius: 12px; }
|
|
13
|
+
[data-nav-section][data-active] { background: var(--stor-surface-raised); box-shadow: var(--stor-elevation-2); }
|
|
14
|
+
[data-nav-section] .stor-nav-selected, .stor-nav-sliding [data-nav-section][data-active] { background: transparent; box-shadow: none; }
|
|
15
|
+
/* A section's live figure: muted, right-aligned, and the first thing to truncate when the sidebar is narrow. */
|
|
16
|
+
.stor-nav-figure { min-width: 0; flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; font-size: 12px; font-weight: 500; color: var(--stor-muted-text); font-variant-numeric: tabular-nums; }
|
|
11
17
|
.stor-nav-badge { min-width: 20px; height: 18px; padding: 0 6px; border-radius: 999px; font-size: 10.5px; font-weight: 650; line-height: 18px; text-align: center; font-variant-numeric: tabular-nums; }
|
|
12
18
|
.stor-nav-badge-negative { background: var(--stor-negative-soft); color: var(--stor-negative); }
|
|
13
19
|
.stor-nav-badge-warning { background: var(--stor-warning-soft); color: var(--stor-warning); }
|
|
@@ -19,6 +25,19 @@
|
|
|
19
25
|
.stor-nav-sub[data-open] { grid-template-rows: 1fr; }
|
|
20
26
|
.stor-nav-sub-in { min-height: 0; overflow: hidden; visibility: hidden; transition: visibility 0s linear 200ms; }
|
|
21
27
|
.stor-nav-sub[data-open] > .stor-nav-sub-in { visibility: visible; transition-delay: 0s; }
|
|
28
|
+
/* Recently: two-line rows whose icons sit in the navigation's icon column. */
|
|
29
|
+
.stor-recent-place { display: flex; align-items: center; gap: 11px; min-height: 40px; padding: 3px 10px 3px 13px; border-radius: 10px; transition: background-color 120ms; }
|
|
30
|
+
.stor-recent-place:hover { background: var(--stor-nav-hover); }
|
|
31
|
+
.stor-recent-place:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--stor-focus); }
|
|
32
|
+
/* Last change: one quiet bar over the bottom of the page panel, offering Undo for what this tab just saved. */
|
|
33
|
+
.stor-last-change { display: flex; align-items: center; gap: 8px; width: min(560px, 100%); min-height: 48px; box-sizing: border-box; padding: 6px 6px 6px 14px; border-radius: 14px; background: var(--stor-surface-overlay); box-shadow: var(--stor-elevation-3); font-size: 13.5px; color: var(--stor-ink); }
|
|
34
|
+
.stor-last-change-action { flex: none; min-height: 34px; padding: 0 12px; border-radius: 10px; font-size: 13.5px; font-weight: 600; color: var(--stor-accent-text); transition: background-color 120ms; }
|
|
35
|
+
.stor-last-change-action:hover:not(:disabled) { background: var(--stor-nav-hover); }
|
|
36
|
+
.stor-last-change-action:disabled { opacity: 0.55; }
|
|
37
|
+
.stor-last-change-quiet { font-weight: 500; color: var(--stor-muted-text); }
|
|
38
|
+
.stor-last-change-close { display: grid; flex: none; place-items: center; width: 34px; height: 34px; border-radius: 10px; color: var(--stor-muted-text); }
|
|
39
|
+
.stor-last-change-close:hover { background: var(--stor-nav-hover); color: var(--stor-ink); }
|
|
40
|
+
.stor-last-change :is(button):focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--stor-focus); }
|
|
22
41
|
/* Rail flyout: a section's pages beside the rail, opened by hover or keyboard focus (NavFlyout). */
|
|
23
42
|
.stor-nav-fly { position: fixed; z-index: 60; width: 13rem; padding: 8px; border-radius: 16px; background: var(--stor-surface-overlay); box-shadow: var(--stor-elevation-3); opacity: 0; visibility: hidden; transform: translateX(-4px); transition: opacity 140ms ease, transform 140ms ease, visibility 0s linear 140ms; }
|
|
24
43
|
.stor-nav-fly[data-open] { opacity: 1; visibility: visible; transform: none; transition-delay: 0s; }
|
|
@@ -28,16 +47,15 @@
|
|
|
28
47
|
.stor-nav-fly-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 4px 8px 8px; font-size: 13px; font-weight: 600; color: var(--stor-ink); }
|
|
29
48
|
.stor-nav-fly .stor-nav-child { height: 32px; padding: 0 8px; }
|
|
30
49
|
.stor-nav-fly .stor-nav-child-active { padding-left: 14px; }
|
|
31
|
-
.stor-nav-fly .stor-nav-child-active::before { left: 0; top: 8px; bottom: 8px; }
|
|
32
|
-
|
|
33
|
-
.stor-nav-children
|
|
34
|
-
.stor-nav-child { position: relative; display: flex; align-items: center; gap: 8px; height:
|
|
50
|
+
.stor-nav-fly .stor-nav-child-active::before { content: ""; position: absolute; left: 0; top: 8px; bottom: 8px; width: 2px; border-radius: 2px; background: var(--stor-ink); }
|
|
51
|
+
/* Sub-pages line up with the section's label inside its card (row padding 12px + 18px icon + 10px gap). */
|
|
52
|
+
.stor-nav-children { display: flex; flex-direction: column; gap: 1px; margin: 0; padding: 0 0 6px; }
|
|
53
|
+
.stor-nav-child { position: relative; display: flex; align-items: center; gap: 8px; height: 32px; padding: 0 8px 0 40px; border-radius: 8px; font-size: 13px; font-weight: 500; color: var(--stor-muted-text); transition: color 120ms, background-color 120ms; }
|
|
35
54
|
.stor-nav-child:hover { color: var(--stor-ink); background: var(--stor-nav-hover); }
|
|
36
55
|
.stor-nav-child:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--stor-focus); }
|
|
37
56
|
.stor-nav-child .stor-nav-badge { min-width: 18px; height: 16px; padding: 0 5px; font-size: 10px; line-height: 16px; }
|
|
38
57
|
/* Selection is ink, so red keeps meaning late work. */
|
|
39
58
|
.stor-nav-child-active { color: var(--stor-ink); font-weight: 600; }
|
|
40
|
-
.stor-nav-child-active::before { content: ""; position: absolute; left: -13.25px; top: 7px; bottom: 7px; width: 2px; border-radius: 2px; background: var(--stor-ink); }
|
|
41
59
|
.stor-dock-badge { position: absolute; top: -3px; right: 2px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 999px; font-size: 9.5px; font-weight: 700; line-height: 16px; text-align: center; color: var(--stor-negative-text); box-shadow: 0 0 0 2px var(--stor-surface-raised); }
|
|
42
60
|
.stor-dock-badge-negative { background: var(--stor-negative); }
|
|
43
61
|
.stor-dock-badge-warning { background: var(--stor-warning); color: var(--stor-surface); }
|
|
@@ -79,7 +97,23 @@
|
|
|
79
97
|
|
|
80
98
|
.stor-mobile-header { background: var(--stor-bg); box-shadow: var(--stor-header-shadow); }
|
|
81
99
|
|
|
82
|
-
|
|
100
|
+
/* From the rail up the sidebar sits on the canvas and the page is one raised panel beside it.
|
|
101
|
+
Inside the panel the canvas is the panel's own surface, so sticky headers and fades match it,
|
|
102
|
+
and panels step down from white cards to outlined sections. */
|
|
103
|
+
|
|
104
|
+
:root { --stor-page-shadow: var(--stor-elevation-1); }
|
|
105
|
+
|
|
106
|
+
@media (min-width: 768px) {
|
|
107
|
+
[data-stor-main] {
|
|
108
|
+
--stor-bg: var(--stor-surface);
|
|
109
|
+
--stor-elevation-1: 0 0 0 1px color-mix(in srgb, var(--stor-border) 70%, transparent);
|
|
110
|
+
--stor-panel-shadow: var(--stor-elevation-1);
|
|
111
|
+
margin: 12px 12px 12px 0;
|
|
112
|
+
border-radius: var(--stor-panel-radius);
|
|
113
|
+
background: var(--stor-surface);
|
|
114
|
+
box-shadow: var(--stor-page-shadow);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
83
117
|
|
|
84
118
|
body:has([data-stor-drawer]) .stor-mobile-action,
|
|
85
119
|
body:has([data-stor-drawer]) .stor-mobile-fab { visibility: hidden; }
|
|
@@ -207,7 +241,6 @@ html[data-stor-shell="mac"] [data-stor-root] {
|
|
|
207
241
|
|
|
208
242
|
html[data-stor-shell="mac"] [data-testid="desktop-sidebar"] {
|
|
209
243
|
background: color-mix(in srgb, var(--stor-sidebar) var(--stor-shell-sidebar-tint), transparent);
|
|
210
|
-
box-shadow: inset 0 0 0 0.5px color-mix(in srgb, var(--stor-ink) 9%, transparent), var(--stor-sidebar-shadow);
|
|
211
244
|
}
|
|
212
245
|
|
|
213
246
|
html[data-stor-shell="mac"] .stor-lights { display: block; flex: none; width: var(--stor-shell-lights); height: 14px; }
|
|
@@ -254,7 +287,7 @@ html[data-stor-shell="ios"] .stor-mobile-nav {
|
|
|
254
287
|
|
|
255
288
|
/* iPad shows the rail from 768px: clear the status bar, which the edge-to-edge page now sits under. */
|
|
256
289
|
|
|
257
|
-
html[data-stor-shell="ios"] [data-testid="desktop-sidebar"] { top:
|
|
290
|
+
html[data-stor-shell="ios"] [data-testid="desktop-sidebar"] { top: env(safe-area-inset-top, 0px); }
|
|
258
291
|
|
|
259
292
|
html[data-stor-shell="ios"] [data-stor-main] { padding-top: var(--stor-mobile-header-height); }
|
|
260
293
|
|