diff --git a/.claude/skills/frontend-quality/SKILL.md b/.claude/skills/frontend-quality/SKILL.md index e8f2877..fd5f440 100644 --- a/.claude/skills/frontend-quality/SKILL.md +++ b/.claude/skills/frontend-quality/SKILL.md @@ -25,11 +25,11 @@ The product is **reading long-form fiction and essays on a phone, often at night ## 2. Performance (mobile-first static) -- Astro ships **zero client JS** by default -- keep it that way. Any new `` or HTML + * comment openers in content break out of the block. */ +const safeJson = (value: unknown) => + JSON.stringify(value).replaceAll('<', '\\u003c'); + +const ldBlocks = [websiteLd, ...structuredData]; --- @@ -52,15 +68,28 @@ const jsonLd = { + { + publishedTime && ( + + ) + } {title} - + + + {/* ambient candlelight: fixed, decorative, behind everything */} + + + {/* paper grain: SVG turbulence, no asset, ~600 bytes */} + +
-
@@ -179,221 +239,279 @@ const history = chapter.filePath ) } - + { + (prev || next) && ( + + ) + } - - + } + + diff --git a/src/pages/rss.xml.ts b/src/pages/rss.xml.ts index 448cf02..de1abd4 100644 --- a/src/pages/rss.xml.ts +++ b/src/pages/rss.xml.ts @@ -3,6 +3,17 @@ import { getCollection } from 'astro:content'; import type { APIContext } from 'astro'; import { bookSlug, chapterSlug, withBase } from '../lib/shelf'; +/** Plain-text excerpt of a chapter body for the feed item description. */ +const excerpt = (body: string | undefined, words = 40) => { + const text = (body ?? '') + .replaceAll(/[#>*_`[\]]/g, '') + .replaceAll(/\s+/g, ' ') + .trim(); + if (!text) return undefined; + const sliced = text.split(' ').slice(0, words).join(' '); + return sliced.length < text.length ? `${sliced}...` : sliced; +}; + export async function GET(context: APIContext) { const books = await getCollection('books'); const chapters = (await getCollection('chapters')).filter( @@ -28,6 +39,7 @@ export async function GET(context: APIContext) { return { title: `${titleOf(slug)}: ${chapter.data.title}`, pubDate: chapter.data.publishedOn!, + description: excerpt(chapter.body), link: withBase(`/books/${slug}/${chapterSlug(chapter.id)}`), }; }), diff --git a/src/styles/global.css b/src/styles/global.css index 8aae0de..7502ef3 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1,27 +1,65 @@ -/* sagas -- candlelit library. Dark-first, light honored via prefers-color-scheme. */ +/* sagas -- candlelit library. Dark-first, light honored via prefers-color-scheme. + * + * Token tiers (pattern from the kalchar design system): + * 1. Primitives: raw OKLCH values, font stacks + * 2. Semantic: surfaces, ink, accent, lines (consumed by components) + * 3. Component: radius, elevation, motion, layout + * + * Elevation is tinted with the warm ink triplet, not black, so depth reads + * as candlelit paper rather than gray murk. Motion is always gated behind + * prefers-reduced-motion (see the block at the end of this file). + */ :root { color-scheme: dark light; - /* ink surfaces tinted toward candle gold (hue 80) */ - --surface: light-dark(oklch(0.96 0.012 80), oklch(0.17 0.012 80)); + /* -- TIER 1 + 2: ink surfaces tinted toward candle gold (hue 80) -- */ + --surface: light-dark(oklch(0.96 0.012 80), oklch(0.16 0.012 80)); --surface-raised: light-dark(oklch(0.99 0.01 80), oklch(0.21 0.016 80)); - --surface-sunken: light-dark(oklch(0.92 0.014 80), oklch(0.14 0.01 80)); - --ink: light-dark(oklch(0.24 0.02 80), oklch(0.88 0.02 80)); - --ink-soft: light-dark(oklch(0.45 0.02 78), oklch(0.68 0.018 78)); - --ink-faint: light-dark(oklch(0.62 0.015 78), oklch(0.5 0.015 78)); + --surface-sunken: light-dark(oklch(0.92 0.014 80), oklch(0.13 0.01 80)); + --ink: light-dark(oklch(0.24 0.02 80), oklch(0.89 0.02 80)); + --ink-soft: light-dark(oklch(0.45 0.02 78), oklch(0.7 0.018 78)); + --ink-faint: light-dark(oklch(0.62 0.015 78), oklch(0.52 0.015 78)); --line: light-dark(oklch(0.85 0.02 80), oklch(0.3 0.018 80)); --line-soft: light-dark(oklch(0.9 0.015 80), oklch(0.24 0.014 80)); /* one accent: candle flame */ --flame: light-dark(oklch(0.55 0.13 65), oklch(0.78 0.13 75)); --flame-dim: light-dark(oklch(0.55 0.06 65), oklch(0.78 0.06 75)); - --flame-wash: light-dark(oklch(0.55 0.13 65 / 0.08), oklch(0.78 0.13 75 / 0.1)); + --flame-wash: light-dark( + oklch(0.55 0.13 65 / 0.08), + oklch(0.78 0.13 75 / 0.1) + ); + --gold-leaf: light-dark(oklch(0.62 0.1 85), oklch(0.8 0.11 88)); --font-display: 'Eczar Variable', Georgia, serif; --font-body: 'Literata Variable', Georgia, serif; - /* 4pt scale */ + /* -- Shadow tint: warm ink component triplet (L C H), fed to oklch(.../a). + Depth reads as warm brown on the candlelit ground, never flat black. -- */ + --shadow-ink: light-dark(0.3 0.03 70, 0 0 0); + + /* -- TIER 3: elevation ladder (kalchar pattern). + hairline = resting frame edge; e1 = resting card; e2 = card hover; + e3 = sticky chrome; e4 = floating affordance; e5 = overlay panel. -- */ + --shadow-hairline: 0 0 0 1px oklch(var(--shadow-ink) / 0.08); + --shadow-e1: + 0 1px 2px oklch(var(--shadow-ink) / 0.12), + 0 1px 1px -0.5px oklch(var(--shadow-ink) / 0.08); + --shadow-e2: + 0 1px 2px oklch(var(--shadow-ink) / 0.1), + 0 3px 6px -1px oklch(var(--shadow-ink) / 0.12), + 0 6px 10px -2px oklch(var(--shadow-ink) / 0.08); + --shadow-e3: + 0 2px 4px oklch(var(--shadow-ink) / 0.1), + 0 8px 14px -3px oklch(var(--shadow-ink) / 0.14), + 0 16px 24px -6px oklch(var(--shadow-ink) / 0.1); + --shadow-e4: + 0 2px 4px oklch(var(--shadow-ink) / 0.09), + 0 10px 16px -3px oklch(var(--shadow-ink) / 0.12), + 0 22px 32px -8px oklch(var(--shadow-ink) / 0.14); + + /* 4pt spacing scale */ --space-2xs: 0.25rem; --space-xs: 0.5rem; --space-sm: 0.75rem; @@ -40,16 +78,15 @@ --measure: 65ch; - /* two-tier width system (patterns from kalchar + ledger-sync): - browse/grid surfaces run wide; prose is capped to a reading measure. */ + /* two-tier width system: browse surfaces run wide; prose is capped. */ --gutter: clamp(1.25rem, 4vw, 2.5rem); - --wrap-wide: 75rem; /* homepage + book browsing ceiling (1200px) */ - --wrap-read: 44rem; /* long-form reading measure (~704px) */ - --wrap: var(--wrap-wide); /* default; reader pages opt into --wrap-read */ + --wrap-wide: 75rem; + --wrap-read: 44rem; + --wrap: var(--wrap-wide); --radius: 12px; --radius-sm: 6px; - /* named motion tokens -- no magic timings (kalchar house rule) */ + /* named motion tokens -- no magic timings */ --duration-fast: 160ms; --duration-base: 220ms; --duration-slow: 380ms; @@ -57,12 +94,7 @@ --ease-out: cubic-bezier(0.16, 1, 0.3, 1); --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); - --shadow: 0 1px 2px light-dark(oklch(0.5 0.02 80 / 0.08), oklch(0 0 0 / 0.3)); - --shadow-glass: 0 8px 30px - light-dark(oklch(0.5 0.02 80 / 0.12), oklch(0 0 0 / 0.4)); - - /* glassy surfaces: translucent + blurred, tinted to the candle palette. - Used on chrome and cards, not everywhere (refined, not slop). */ + /* glass chrome: translucent + blurred, candle-tinted. Chrome + cards only. */ --glass: light-dark(oklch(0.99 0.01 80 / 0.6), oklch(0.23 0.016 80 / 0.5)); --glass-strong: light-dark( oklch(0.98 0.012 80 / 0.82), @@ -71,6 +103,9 @@ --glass-border: light-dark(oklch(1 0 0 / 0.5), oklch(0.9 0.02 80 / 0.12)); --blur: 14px; + /* sticky header height; keeps anchor/focus targets clear of the bar */ + --header-h: 3.75rem; + /* reader preference tokens (overridden from localStorage, see Base.astro) */ --reader-size: 1.0625rem; --reader-leading: 1.75; @@ -99,11 +134,11 @@ --flame: oklch(0.5 0.14 45); --flame-dim: oklch(0.5 0.07 45); --flame-wash: oklch(0.5 0.14 45 / 0.1); + --gold-leaf: oklch(0.58 0.1 80); + --shadow-ink: 0.35 0.05 55; --glass: oklch(0.96 0.028 75 / 0.7); --glass-strong: oklch(0.95 0.03 75 / 0.85); --glass-border: oklch(0.5 0.05 60 / 0.15); - --shadow: 0 1px 2px oklch(0.4 0.04 55 / 0.12); - --shadow-glass: 0 8px 30px oklch(0.4 0.04 55 / 0.14); } /* reader font choice */ @@ -115,6 +150,13 @@ --reader-font: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace; } +/* Native hidden always wins over component display rules. Without this, + `display: grid` on a [hidden] element renders it -- the exact bug that + showed collapsed panels open and an empty Continue card. */ +[hidden] { + display: none !important; +} + /* shared glass surface */ .glass { background: var(--glass); @@ -122,7 +164,7 @@ -webkit-backdrop-filter: blur(var(--blur)) saturate(1.3); border: 1px solid var(--glass-border); border-radius: var(--radius); - box-shadow: var(--shadow-glass); + box-shadow: var(--shadow-e2); } * { @@ -136,6 +178,9 @@ html { font-size: 100%; -webkit-text-size-adjust: 100%; scroll-behavior: smooth; + /* keep anchor/skip-link/focus targets clear of the sticky header */ + scroll-padding-top: calc(var(--header-h) + var(--space-md)); + overflow-x: clip; } body { @@ -165,7 +210,7 @@ a { color: var(--flame); text-decoration-color: var(--flame-dim); text-underline-offset: 0.2em; - transition: text-decoration-color 180ms var(--ease-out); + transition: text-decoration-color var(--duration-fast) var(--ease-out); } a:hover { @@ -183,6 +228,114 @@ a:hover { color: var(--surface); } +/* skip to content -- visible only when focused (WCAG 2.4.1) */ +.skip-link { + position: fixed; + top: 0.5rem; + left: 0.5rem; + z-index: 100; + padding: 0.6em 1.1em; + border-radius: var(--radius-sm); + background: var(--surface-raised); + color: var(--ink); + border: 1px solid var(--line); + font-family: var(--font-display); + font-size: var(--text-sm); + font-weight: 600; + text-decoration: none; + transform: translateY(-200%); + transition: transform var(--duration-fast) var(--ease-out); +} + +.skip-link:focus { + transform: translateY(0); +} + +#main { + scroll-margin-top: calc(var(--header-h) + var(--space-md)); +} + +/* -- Ambient candlelight backdrop (adapted from portfolio-react's aurora + layer): a fixed, non-interactive stack of one warm radial glow, a faint + dot lattice, and an edge vignette. Transform/opacity only; the drift + animation is removed entirely under reduced motion. -- */ +.ambient { + position: fixed; + inset: 0; + z-index: -1; + overflow: hidden; + pointer-events: none; +} + +.ambient-glow { + position: absolute; + top: -28%; + left: 50%; + width: min(70rem, 120vw); + aspect-ratio: 11 / 8; + translate: -50% 0; + border-radius: 50%; + background: radial-gradient( + ellipse at center, + light-dark(oklch(0.75 0.1 75 / 0.14), oklch(0.78 0.13 75 / 0.09)), + light-dark(oklch(0.75 0.1 75 / 0.05), oklch(0.78 0.13 75 / 0.03)) 45%, + transparent 70% + ); + filter: blur(60px); + animation: ambient-drift 26s var(--ease-in-out) infinite; +} + +@keyframes ambient-drift { + 0%, + 100% { + transform: translate(-4%, 0) scale(1); + } + 50% { + transform: translate(4%, 3%) scale(1.06); + } +} + +.ambient-dots { + position: absolute; + inset: 0; + background-image: radial-gradient( + light-dark(oklch(0.3 0.02 80 / 0.06), oklch(0.9 0.02 80 / 0.045)) 1px, + transparent 1px + ); + background-size: 26px 26px; + mask-image: radial-gradient( + ellipse 85% 65% at 50% 30%, + black 25%, + transparent 78% + ); + -webkit-mask-image: radial-gradient( + ellipse 85% 65% at 50% 30%, + black 25%, + transparent 78% + ); +} + +.ambient-vignette { + position: absolute; + inset: 0; + background: radial-gradient( + ellipse 120% 90% at 50% 40%, + transparent 55%, + light-dark(oklch(0.9 0.014 80 / 0.5), oklch(0.12 0.01 80 / 0.75)) 100% + ); +} + +/* -- Paper grain (kalchar pattern): SVG turbulence, multiply-blended so it + reads as paper fiber on light themes and ink-fiber depth on dark. -- */ +.grain { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + mix-blend-mode: multiply; + opacity: 0.5; +} + /* shared page wrapper -- fluid gutter, capped at the wide browse width */ .wrap { width: 100%; @@ -198,6 +351,8 @@ main { padding-inline: var(--gutter); padding-block: var(--space-2xl) var(--space-3xl); flex: 1; + position: relative; + z-index: 1; } /* reader pages opt into the narrow reading measure */ @@ -205,7 +360,6 @@ main.read { max-width: var(--wrap-read); } -/* a prose block stays at reading width even inside a wide container */ .readable { max-width: var(--wrap-read); } @@ -242,7 +396,6 @@ main.read { background: var(--ink-faint); } -/* status-specific dots */ .pill[data-status='ideation']::before { background: var(--ink-faint); } @@ -344,7 +497,7 @@ main.read { } .rise { - animation: rise 700ms var(--ease-out) backwards; + animation: rise var(--duration-enter) var(--ease-out) backwards; } .rise:nth-child(2) { @@ -362,3 +515,33 @@ main.read { .rise:nth-child(6) { animation-delay: 450ms; } + +/* -- Reduced motion: honor the OS setting everywhere (WCAG 2.3.3). + Entrances become instant, ambient drift and shimmer freeze to their + resting state, and programmatic/anchored scrolling jumps. -- */ +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + .rise { + animation: none; + } + + .skeleton { + animation: none; + background: var(--surface-sunken); + } + + .ambient-glow { + animation: none; + } + + *, + *::before, + *::after { + transition-duration: 1ms !important; + animation-duration: 1ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/tests/e2e/reader.spec.ts b/tests/e2e/reader.spec.ts new file mode 100644 index 0000000..3055381 --- /dev/null +++ b/tests/e2e/reader.spec.ts @@ -0,0 +1,86 @@ +import { expect, test } from '@playwright/test'; + +/** + * Reader regression suite. The library launches with zero books, so these + * tests discover the first available chapter and skip cleanly when the + * shelves are still empty. The moment a real chapter lands, they activate + * without any edits. + */ + +const HOME = '/sagas/'; + +async function firstChapterUrl(page: import('@playwright/test').Page) { + await page.goto(HOME); + const book = page.locator('a.entry').first(); + if ((await book.count()) === 0) return null; + await book.click(); + const chapter = page.locator('.chapter-link').first(); + if ((await chapter.count()) === 0) return null; + await chapter.click(); + return page.url(); +} + +test.describe('chapter reader', () => { + test('collapsed panels are actually collapsed', async ({ page }) => { + const url = await firstChapterUrl(page); + test.skip(!url, 'no chapters published yet'); + + for (const selector of ['#reader-prefs', '[data-ra-panel]']) { + const el = page.locator(selector); + if ((await el.count()) === 0) continue; + const display = await el.evaluate((n) => getComputedStyle(n).display); + expect(display, `${selector} must be display:none while hidden`).toBe( + 'none', + ); + } + }); + + test('saved reading progress survives a reload', async ({ page }) => { + const url = await firstChapterUrl(page); + test.skip(!url, 'no chapters published yet'); + + const main = page.locator('main.read'); + const book = await main.getAttribute('data-book'); + const chapter = await main.getAttribute('data-chapter'); + await page.evaluate( + ([b, c]) => { + localStorage.setItem( + `sagas:progress:${b}`, + JSON.stringify({ _last: c, [c!]: { pct: 50, at: 123 } }), + ); + }, + [book, chapter], + ); + await page.reload(); + // The previously-saved position must not be clobbered on load. + const stored = await page.evaluate( + (b) => JSON.parse(localStorage.getItem(`sagas:progress:${b}`) || '{}'), + book, + ); + expect(stored[chapter!]?.pct).toBe(50); + }); + + test('arrow keys on focused controls do not navigate away', async ({ + page, + }) => { + const url = await firstChapterUrl(page); + test.skip(!url, 'no chapters published yet'); + + await page.locator('[data-prefs-toggle]').focus(); + await page.keyboard.press('ArrowRight'); + expect(page.url()).toBe(url); + }); + + test('theme preference persists across reloads', async ({ page }) => { + const url = await firstChapterUrl(page); + test.skip(!url, 'no chapters published yet'); + + await page.locator('[data-prefs-toggle]').click(); + await page.getByRole('button', { name: 'Sepia' }).click(); + await page.reload(); + const theme = await page.evaluate( + () => document.documentElement.dataset.theme, + ); + expect(theme).toBe('sepia'); + }); +}); diff --git a/tests/e2e/site.spec.ts b/tests/e2e/site.spec.ts new file mode 100644 index 0000000..8d19296 --- /dev/null +++ b/tests/e2e/site.spec.ts @@ -0,0 +1,106 @@ +import { expect, test } from '@playwright/test'; + +const HOME = '/sagas/'; + +test.describe('home page', () => { + test('renders the library shell', async ({ page }) => { + await page.goto(HOME); + await expect(page).toHaveTitle(/living library/); + await expect( + page.getByRole('heading', { level: 1, name: /living library/i }), + ).toBeVisible(); + // three shelves, each with its heading + for (const shelf of ['Original Fantasy', 'Fan Fiction', 'Essays']) { + await expect( + page.getByRole('heading', { level: 2, name: shelf }), + ).toBeVisible(); + } + }); + + test('empty shelves show the crafted zero-book state', async ({ page }) => { + await page.goto(HOME); + const empties = page.locator('.shelf-empty'); + // With zero books every shelf shows its promise; with books this + // assertion still passes as long as bookless shelves are crafted. + const shelves = await page.locator('.shelf').count(); + const entries = await page.locator('a.entry').count(); + expect((await empties.count()) + entries).toBeGreaterThanOrEqual(shelves); + }); + + test('continue-reading card stays hidden without history', async ({ + page, + }) => { + await page.goto(HOME); + const cont = page.locator('[data-continue]'); + await expect(cont).toBeHidden(); + // regression: [hidden] must actually compute to display:none + const display = await cont.evaluate((el) => getComputedStyle(el).display); + expect(display).toBe('none'); + }); + + test('no horizontal overflow at any project viewport', async ({ page }) => { + await page.goto(HOME); + const overflow = await page.evaluate( + () => + document.documentElement.scrollWidth - + document.documentElement.clientWidth, + ); + expect(overflow).toBeLessThanOrEqual(1); + }); + + test('skip link jumps to main content', async ({ page }) => { + await page.goto(HOME); + await page.keyboard.press('Tab'); + const skip = page.locator('.skip-link'); + await expect(skip).toBeFocused(); + await skip.press('Enter'); + await expect(page).toHaveURL(/#main$/); + }); + + test('decorative layers never intercept input', async ({ page }) => { + await page.goto(HOME); + for (const selector of ['.ambient', '.grain']) { + const pe = await page + .locator(selector) + .evaluate((el) => getComputedStyle(el).pointerEvents); + expect(pe).toBe('none'); + } + }); +}); + +test.describe('feeds and metadata', () => { + test('rss feed is valid and carries the base path', async ({ request }) => { + const res = await request.get('/sagas/rss.xml'); + expect(res.ok()).toBeTruthy(); + const body = await res.text(); + expect(body).toContain(' { + const res = await request.get('/sagas/sitemap-index.xml'); + expect(res.ok()).toBeTruthy(); + }); + + test('404 page renders with skip target', async ({ page }) => { + const res = await page.goto('/sagas/definitely-not-a-page'); + expect(res?.status()).toBe(404); + await expect(page.locator('#main')).toBeAttached(); + }); +}); + +test.describe('reduced motion', () => { + test('entrance and ambient animation are disabled', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto(HOME); + const riseAnimation = await page + .locator('.rise') + .first() + .evaluate((el) => getComputedStyle(el).animationName); + expect(riseAnimation).toBe('none'); + const glowAnimation = await page + .locator('.ambient-glow') + .evaluate((el) => getComputedStyle(el).animationName); + expect(glowAnimation).toBe('none'); + }); +});