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
8 changes: 8 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,14 @@ 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.

### ReadingComfort

- **Structure**: One 40px `Aa` action joins the existing reader-action row and opens a compact three-button stepper for smaller, default, or larger article text. It changes only the current article body and headings; metadata, toolbars, sidebars, code blocks, and page width keep their existing metrics.
- **Scale**: Offer four bounded levels: 90%, 100%, 110%, and 120%. The smaller and larger commands clamp at the bounds, the center command restores 100%, and the current percentage is announced in the panel and trigger label.
- **Storage**: Persist only the selected percentage in `localStorage.reading-comfort`; unknown or unavailable values fall back to 100%. Storage failure leaves the selected size active for the current page and reports the non-persistent state without blocking reading.
- **Accessibility**: Use native buttons with localized names, visible focus rings, disabled boundary states, a polite value/status region, Escape and outside-click dismissal, and focus restoration. Cross-tab changes update the rendered article without navigation.
- **Motion**: Reuse the 150ms reader-control hover and press feedback. Reduced-motion mode removes spatial feedback, and print hides the control while preserving the selected article scale.

## 6. Motion & Interaction

Motion is quiet utility feedback, not brand theater.
Expand Down
8 changes: 8 additions & 0 deletions quartz/components/renderPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,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 fallbackReadingComfort = TRANSLATIONS[defaultTranslation].components.readingComfort
const readingComfort = i18n(pageLocale).components.readingComfort ?? fallbackReadingComfort
// 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 +412,12 @@ export function renderPage(
data-note-share-shared={noteShare.shared}
data-note-share-copied={noteShare.copied}
data-note-share-failed={noteShare.failed}
data-reading-comfort-title={readingComfort.title}
data-reading-comfort-smaller={readingComfort.smaller}
data-reading-comfort-reset={readingComfort.reset}
data-reading-comfort-larger={readingComfort.larger}
data-reading-comfort-value={readingComfort.value}
data-reading-comfort-failed={readingComfort.failed}
>
{frame.css && <style dangerouslySetInnerHTML={{ __html: frame.css }} />}
<div id="quartz-root" class="page" data-frame={frame.name}>
Expand Down
53 changes: 53 additions & 0 deletions quartz/components/scripts/readingComfort.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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 {
READING_COMFORT_DEFAULT,
parseReadingComfortLevel,
readingComfortBootstrapScript,
readingComfortScript,
stepReadingComfortLevel,
} from "./readingComfort"

describe("reading comfort levels", () => {
test("accepts only supported stored levels", () => {
assert.equal(parseReadingComfortLevel("90"), 90)
assert.equal(parseReadingComfortLevel("120"), 120)
assert.equal(parseReadingComfortLevel(null), READING_COMFORT_DEFAULT)
assert.equal(parseReadingComfortLevel("110.5"), READING_COMFORT_DEFAULT)
assert.equal(parseReadingComfortLevel("Infinity"), READING_COMFORT_DEFAULT)
assert.equal(parseReadingComfortLevel("not-a-level"), READING_COMFORT_DEFAULT)
})

test("steps between levels and clamps at both ends", () => {
assert.equal(stepReadingComfortLevel(100, -1), 90)
assert.equal(stepReadingComfortLevel(100, 1), 110)
assert.equal(stepReadingComfortLevel(90, -1), 90)
assert.equal(stepReadingComfortLevel(120, 1), 120)
assert.equal(stepReadingComfortLevel(999, 1), 110)
})
})

test("reading-comfort scripts compile and follow the Quartz lifecycle", () => {
assert.doesNotThrow(() => new Function(readingComfortBootstrapScript))
assert.doesNotThrow(() => new Function(readingComfortScript))
assert.match(
readingComfortScript,
/document\.addEventListener\("nav", initializeReadingComfort\)/,
)
assert.match(
readingComfortScript,
/document\.addEventListener\("render", initializeReadingComfort\)/,
)
assert.match(readingComfortScript, /window\.addCleanup\(cleanupReadingComfort\)/)
assert.match(readingComfortScript, /window\.addEventListener\("storage", syncStoredLevel\)/)
assert.match(readingComfortScript, /event\.key === "Escape"/)
})

test("reading-comfort labels use the central locale catalog", () => {
assert.equal(enUs.components.readingComfort?.title, "Reading size")
assert.equal(zhCn.components.readingComfort?.larger, "放大正文")
assert.equal(zhTw.components.readingComfort?.failed, "已套用,但瀏覽器未能儲存偏好")
})
205 changes: 205 additions & 0 deletions quartz/components/scripts/readingComfort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
export const READING_COMFORT_LEVELS = [90, 100, 110, 120] as const
export const READING_COMFORT_DEFAULT = 100

export function parseReadingComfortLevel(raw: string | null): number {
if (raw === null || raw.trim().length === 0) return READING_COMFORT_DEFAULT

const level = Number(raw)
return READING_COMFORT_LEVELS.includes(level as (typeof READING_COMFORT_LEVELS)[number])
? level
: READING_COMFORT_DEFAULT
}

export function stepReadingComfortLevel(current: number, direction: -1 | 1): number {
const normalized = parseReadingComfortLevel(String(current))
const currentIndex = READING_COMFORT_LEVELS.indexOf(
normalized as (typeof READING_COMFORT_LEVELS)[number],
)
const nextIndex = Math.max(
0,
Math.min(READING_COMFORT_LEVELS.length - 1, currentIndex + direction),
)
return READING_COMFORT_LEVELS[nextIndex]
}

export const readingComfortBootstrapScript = `
const READING_COMFORT_LEVELS = ${JSON.stringify(READING_COMFORT_LEVELS)}
const READING_COMFORT_DEFAULT = ${READING_COMFORT_DEFAULT}
const parseReadingComfortLevel = ${parseReadingComfortLevel.toString()}
try {
const level = parseReadingComfortLevel(localStorage.getItem("reading-comfort"))
document.documentElement.dataset.readingComfort = String(level)
} catch {
document.documentElement.dataset.readingComfort = String(READING_COMFORT_DEFAULT)
}
`

export const readingComfortScript = `
const READING_COMFORT_KEY = "reading-comfort"
const READING_COMFORT_LEVELS = ${JSON.stringify(READING_COMFORT_LEVELS)}
const READING_COMFORT_DEFAULT = ${READING_COMFORT_DEFAULT}
const parseReadingComfortLevel = ${parseReadingComfortLevel.toString()}
const stepReadingComfortLevel = ${stepReadingComfortLevel.toString()}

function getReadingComfortLabels() {
const {
readingComfortTitle: title,
readingComfortSmaller: smaller,
readingComfortReset: reset,
readingComfortLarger: larger,
readingComfortValue: value,
readingComfortFailed: failed,
} = document.body.dataset
if (!title || !smaller || !reset || !larger || !value || !failed) return undefined
return { title, smaller, reset, larger, value, failed }
}

function createReadingComfortButton(className, text, label) {
const button = document.createElement("button")
button.type = "button"
button.className = className
button.textContent = text
button.title = label
button.setAttribute("aria-label", label)
return button
}

let cleanupCurrentReadingComfort = () => {}
const cleanupReadingComfort = () => {
const cleanup = cleanupCurrentReadingComfort
cleanupCurrentReadingComfort = () => {}
cleanup()
}

function initializeReadingComfort() {
cleanupReadingComfort()
const titleElement = document.querySelector("h1.article-title")
const anchor = document.querySelector(".content-meta") ?? titleElement
const labels = getReadingComfortLabels()
if (anchor === null || titleElement === null || labels === undefined) return

const existingRoot = document.querySelector("[data-read-later-root]")
const root = existingRoot ?? document.createElement("div")
const ownsRoot = existingRoot === null
if (ownsRoot) {
root.className = "reading-comfort"
anchor.insertAdjacentElement("afterend", root)
}
root.classList.add("reading-comfort-host")

const trigger = createReadingComfortButton("reading-comfort-trigger", "Aa", labels.title)
trigger.setAttribute("aria-controls", "reading-comfort-panel")
trigger.setAttribute("aria-expanded", "false")

const panel = document.createElement("section")
panel.id = "reading-comfort-panel"
panel.className = "reading-comfort-panel"
panel.setAttribute("aria-label", labels.title)
panel.hidden = true

const value = document.createElement("span")
value.className = "reading-comfort-value"
value.setAttribute("aria-live", "polite")
const controls = document.createElement("div")
controls.className = "reading-comfort-controls"
const smaller = createReadingComfortButton("reading-comfort-smaller", "A-", labels.smaller)
const reset = createReadingComfortButton("reading-comfort-reset", "100%", labels.reset)
const larger = createReadingComfortButton("reading-comfort-larger", "A+", labels.larger)
controls.append(smaller, reset, larger)
const status = document.createElement("span")
status.className = "reading-comfort-status"
status.setAttribute("aria-live", "polite")
panel.append(value, controls, status)
root.prepend(trigger)
root.append(panel)

let current = parseReadingComfortLevel(document.documentElement.dataset.readingComfort ?? null)
const closePanel = (restoreFocus) => {
panel.hidden = true
trigger.setAttribute("aria-expanded", "false")
if (restoreFocus) trigger.focus()
}
const closeReadLaterPanel = () => {
const siblingPanel = root.querySelector(".read-later-panel")
const siblingTrigger = root.querySelector(".read-later-trigger")
if (siblingPanel instanceof HTMLElement) siblingPanel.hidden = true
if (siblingTrigger instanceof HTMLElement) siblingTrigger.setAttribute("aria-expanded", "false")
}
const render = () => {
document.documentElement.dataset.readingComfort = String(current)
value.textContent = labels.value.replace("{percent}", String(current))
trigger.dataset.level = String(current)
trigger.title = value.textContent
trigger.setAttribute("aria-label", value.textContent)
smaller.disabled = current === READING_COMFORT_LEVELS[0]
larger.disabled = current === READING_COMFORT_LEVELS[READING_COMFORT_LEVELS.length - 1]
reset.disabled = current === READING_COMFORT_DEFAULT
}
const persist = () => {
status.textContent = ""
try {
localStorage.setItem(READING_COMFORT_KEY, String(current))
} catch {
status.textContent = labels.failed
}
render()
}
const adjust = (direction) => {
current = stepReadingComfortLevel(current, direction)
persist()
}

trigger.addEventListener("click", () => {
const opening = panel.hidden
if (opening) closeReadLaterPanel()
panel.hidden = !opening
trigger.setAttribute("aria-expanded", String(opening))
if (opening) (smaller.disabled ? larger : smaller).focus()
})
smaller.addEventListener("click", () => adjust(-1))
reset.addEventListener("click", () => {
current = READING_COMFORT_DEFAULT
persist()
})
larger.addEventListener("click", () => adjust(1))
const dismissOutside = (event) => {
if (event.target instanceof Node && !panel.contains(event.target) && event.target !== trigger) {
closePanel(false)
}
}
const dismissWithKeyboard = (event) => {
if (event.key === "Escape" && !panel.hidden) closePanel(true)
}
const dismissForSibling = (event) => {
if (event.target instanceof Node && event.target.closest?.(".read-later-trigger")) {
closePanel(false)
}
}
const syncStoredLevel = (event) => {
if (event.key !== null && event.key !== READING_COMFORT_KEY) return
current = parseReadingComfortLevel(event.newValue)
status.textContent = ""
render()
}
document.addEventListener("pointerdown", dismissOutside)
document.addEventListener("keydown", dismissWithKeyboard)
document.addEventListener("click", dismissForSibling)
window.addEventListener("storage", syncStoredLevel)
render()

cleanupCurrentReadingComfort = () => {
document.removeEventListener("pointerdown", dismissOutside)
document.removeEventListener("keydown", dismissWithKeyboard)
document.removeEventListener("click", dismissForSibling)
window.removeEventListener("storage", syncStoredLevel)
trigger.remove()
panel.remove()
root.classList.remove("reading-comfort-host")
if (ownsRoot) root.remove()
}
window.addCleanup(cleanupReadingComfort)
}

document.addEventListener("nav", initializeReadingComfort)
document.addEventListener("render", initializeReadingComfort)
`
Loading