Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions quartz/components/renderPage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import assert from "node:assert"
import {
pageResources,
readLaterTriggerLabels,
readingTrailTriggerLabels,
renderTranscludes,
resolvePageLocale,
} from "./renderPage"
Expand Down Expand Up @@ -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<QuartzComponentProps, "allFiles" | "cfg"> {
Expand Down
16 changes: 16 additions & 0 deletions quartz/components/renderPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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 && <style dangerouslySetInnerHTML={{ __html: frame.css }} />}
<div id="quartz-root" class="page" data-frame={frame.name}>
Expand Down
83 changes: 83 additions & 0 deletions quartz/components/scripts/readingTrail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import assert from "node:assert"
import test, { describe } from "node:test"
import enUs from "../../i18n/locales/en-US"
import zhCn from "../../i18n/locales/zh-CN"
import zhTw from "../../i18n/locales/zh-TW"
import { readingTrailScript } from "./readingTrail"
import {
READING_TRAIL_LIMIT,
parseReadingTrailEntries,
recordReadingTrailEntry,
type ReadingTrailEntry,
} from "./readingTrailStorage"

test("reading-trail browser script compiles", () => {
assert.doesNotThrow(() => new Function(readingTrailScript))
})

test("reading-trail browser script follows the Quartz render lifecycle", () => {
assert.match(readingTrailScript, /sessionStorage\.getItem\(READING_TRAIL_KEY\)/)
assert.match(readingTrailScript, /document\.addEventListener\("nav", initializeReadingTrail\)/)
assert.match(readingTrailScript, /document\.addEventListener\("render", initializeReadingTrail\)/)
assert.match(readingTrailScript, /window\.addCleanup\(cleanupReadingTrail\)/)
assert.match(readingTrailScript, /entry\.path !== current\.path/)
})

test("reading-trail labels use the central locale catalog", () => {
assert.equal(enUs.components.readingTrail.trigger({ count: 1 }), "Reading trail, 1 previous note")
assert.equal(
enUs.components.readingTrail.trigger({ count: 2 }),
"Reading trail, 2 previous notes",
)
assert.equal(zhCn.components.readingTrail.title, "阅读足迹")
assert.equal(zhTw.components.readingTrail.clear, "清空閱讀足跡")
})

describe("reading-trail storage", () => {
test("keeps only safe unique entries ordered by their newest visit", () => {
const stored = JSON.stringify([
{ path: "/older", title: " Older ", visitedAt: 10 },
{ path: "javascript:alert(1)", title: "Unsafe", visitedAt: 50 },
{ path: "//evil.example", title: "Protocol relative", visitedAt: 50 },
{ path: "/line\nfeed", title: "Control", visitedAt: 50 },
{ path: "/newer", title: "Newer", visitedAt: 30 },
{ path: "/older", title: "Latest older", visitedAt: 20 },
{ path: "/missing-title", title: "", visitedAt: 60 },
])

assert.deepEqual(parseReadingTrailEntries(stored), [
{ path: "/newer", title: "Newer", visitedAt: 30 },
{ path: "/older", title: "Latest older", visitedAt: 20 },
])
})

test("returns an empty list for malformed JSON", () => {
assert.deepEqual(parseReadingTrailEntries("[{"), [])
})

test("moves a revisited note to the front without a duplicate", () => {
const entries: readonly ReadingTrailEntry[] = [
{ path: "/second", title: "Second", visitedAt: 20 },
{ path: "/first", title: "First", visitedAt: 10 },
]
const current = { path: "/first", title: "First again", visitedAt: 30 }

assert.deepEqual(recordReadingTrailEntry(entries, current), [current, entries[0]])
})

test("bounds the trail to the current note and seven prior notes", () => {
const entries: readonly ReadingTrailEntry[] = Array.from(
{ length: READING_TRAIL_LIMIT },
(_, index) => ({ path: `/note-${index}`, title: `Note ${index}`, visitedAt: index }),
)
const current = { path: "/current", title: "Current", visitedAt: 100 }
const updated = recordReadingTrailEntry(entries, current)

assert.equal(updated.length, READING_TRAIL_LIMIT)
assert.deepEqual(updated[0], current)
assert.equal(
updated.some((entry) => entry.path === "/note-7"),
false,
)
})
})
Loading