diff --git a/DESIGN.md b/DESIGN.md index 52aabb79bec7..e968520c73c2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -211,6 +211,15 @@ Layout rules: - **Accessibility**: The button and polite live region use page-localized names and outcomes. The icon is decorative, the control remains keyboard-operable, and whole-note links omit heading fragments so section sharing stays owned by heading permalinks. - **Motion**: The beui `action-swap` blur/scale mechanism is adapted to the existing 150ms micro token, using a 3px blur and 75% scale only during the icon crossfade. Reduced-motion mode removes blur, scale, and press transforms while preserving the state change. +### ReadingTrail + +- **Structure**: One 40px history action joins the existing reader action row. It opens a 320px anchored panel listing the notes visited immediately before the current note, newest first, with a bounded path hint for duplicate titles. +- **Behavior**: Record eligible titled pages after `nav` and in-place `render` events. Revisiting a note moves it to the front, so a reader can retrace a nonlinear wander without duplicate entries. Opening the trail closes the overlapping `ReadLater` panel. +- **Storage**: Keep at most 8 safe path, title, and visit-time entries in `sessionStorage`; the trail disappears with the tab session. No note text, account data, cookies, analytics, content writes, or external requests. +- **States**: Empty, populated, open, cleared, storage fallback, hover, pressed, focus-visible, long-list scrolling, and repeated SPA initialization without duplicate nodes or listeners. +- **Accessibility**: The localized trigger reports the number of prior notes; the panel has a localized heading; every entry is a native internal link; Escape and the close action restore trigger focus; clear is named and disabled when the list is empty. +- **Motion**: Open and close are immediate. Existing 150ms color and press transitions apply to controls; reduced-motion mode removes press transforms, and print hides the trail. + ## 6. Motion & Interaction Motion is quiet utility feedback, not brand theater. diff --git a/quartz/components/renderPage.test.ts b/quartz/components/renderPage.test.ts index 747044a8b765..80beec1dae3d 100644 --- a/quartz/components/renderPage.test.ts +++ b/quartz/components/renderPage.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert" import { pageResources, readLaterTriggerLabels, + readingTrailTriggerLabels, renderTranscludes, resolvePageLocale, } from "./renderPage" @@ -77,6 +78,14 @@ test("readLaterTriggerLabels evaluates bounded counts through the locale functio assert.equal(labels.length, 21) }) +test("readingTrailTriggerLabels evaluates every bounded count", () => { + const labels = readingTrailTriggerLabels(({ count }) => `${count} previous`) + + assert.equal(labels[0], "0 previous") + assert.equal(labels[8], "8 previous") + assert.equal(labels.length, 9) +}) + function makeComponentData( allFiles: QuartzComponentProps["allFiles"], ): Pick { diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx index 4a4bba342d90..3ad9fa130c22 100644 --- a/quartz/components/renderPage.tsx +++ b/quartz/components/renderPage.tsx @@ -17,6 +17,7 @@ import { resolveFrame } from "./frames" import type { TreeTransform } from "../plugins/types" import type { BuildCtx } from "../util/ctx" import { READ_LATER_LIMIT } from "./scripts/readLaterStorage" +import { READING_TRAIL_LIMIT } from "./scripts/readingTrailStorage" interface RenderComponents { head: QuartzComponent @@ -53,6 +54,12 @@ export function readLaterTriggerLabels( return Array.from({ length: READ_LATER_LIMIT + 1 }, (_, count) => trigger({ count })) } +export function readingTrailTriggerLabels( + trigger: (variables: { count: number }) => string, +): readonly string[] { + return Array.from({ length: READING_TRAIL_LIMIT + 1 }, (_, count) => trigger({ count })) +} + export function pageResources( baseDir: FullSlug | RelativeURL, staticResources: StaticResources, @@ -373,6 +380,8 @@ export function renderPage( const randomWander = i18n(pageLocale).components.randomWander ?? fallbackRandomWander const fallbackNoteShare = TRANSLATIONS[defaultTranslation].components.noteShare const noteShare = i18n(pageLocale).components.noteShare ?? fallbackNoteShare + const fallbackReadingTrail = TRANSLATIONS[defaultTranslation].components.readingTrail + const readingTrail = i18n(pageLocale).components.readingTrail ?? fallbackReadingTrail // During local dev (--serve), the dev server serves from root without the // baseUrl subpath, so basePath must be empty to avoid broken links. const basePath = @@ -410,6 +419,13 @@ export function renderPage( data-note-share-shared={noteShare.shared} data-note-share-copied={noteShare.copied} data-note-share-failed={noteShare.failed} + data-reading-trail-title={readingTrail.title} + data-reading-trail-trigger={JSON.stringify(readingTrailTriggerLabels(readingTrail.trigger))} + data-reading-trail-close={readingTrail.close} + data-reading-trail-clear={readingTrail.clear} + data-reading-trail-empty={readingTrail.empty} + data-reading-trail-cleared={readingTrail.cleared} + data-reading-trail-failed={readingTrail.failed} > {frame.css &&