feat(site): preference switch as plain HTML, pressed from the first paint

The React PreferenceSwitch island is replaced by PreferenceSwitch.astro: static
markup, an inline script that presses the stored choice before first paint, and
a page script for clicks and lw:pref. Used on the start page and the work bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-11-11 09:38:57 +01:00
1 parent 39148fa7f0
commit 35415e774d
10 files changed
+64 -56

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@ content format: `docs/CONTENT-CONTRACT.md`; deployment: `docs/DEPLOY.md`.
| `src/core/` | Pure, dependency-free logic shared by the build and the browser: AI levels and visibility (`ai.ts`), durations, issue numbering (`issues.ts`), `lazyImages` for Markdown bodies (`post-html.ts`), media extensions (`media.ts`), showcase ids (`showcases.ts`) |
| `src/library/` | Reads and validates the content repo (`load.ts`, `frontmatter.ts`, `types.ts`, `errors.ts`); `RESERVED_PERSONAL_IDS` guards ids that would shadow a page |
| `src/sources/` | Ghost, Trakt and Hardcover clients plus a JSON cache (`cache.ts`); `loadSources` honours `SOURCES_FIXTURES`. Trakt: last film (`history/movies?limit=1` + `ratings/movies`) and recent ratings; no "watching now". ListenBrainz (`LISTENBRAINZ_USER`, no token): the week's top track, counted from the listens |
| `src/site/` | The Astro site: `pages/` (gate, `work/`, `off-the-clock/` with `issues/`, `everything`, `simple`, 404), `components/` (Astro parts, React islands, `showcases/`), `layouts/` (`Base`, `Work`, `Personal`), `styles/` (per-space CSS and tokens), `scripts/` (`preference.ts`), `data.ts` (`getSiteData`: content plus cached sources, never fetches), `post-html.ts` (`safePostHtml`, Ghost HTML allowlist on Bun's `HTMLRewriter`; build-time only) |
| `src/site/` | The Astro site: `pages/` (gate, `work/`, `off-the-clock/` with `issues/`, `everything`, `simple`, 404), `components/` (Astro parts, React islands, `showcases/`), `layouts/` (`Base`, `Work`, `Personal`), `styles/` (per-space CSS and tokens), `scripts/` (plain page scripts: `preference.ts`, `pref-switch.ts` for the plain-HTML `PreferenceSwitch.astro`, `filter-note.ts`), `data.ts` (`getSiteData`: content plus cached sources, never fetches), `post-html.ts` (`safePostHtml`, Ghost HTML allowlist on Bun's `HTMLRewriter`; build-time only) |
| `src/runtime/` | `build.ts` (validate, fetch sources once, run Astro, copy media), `refresh.ts` (poll git, timed rebuild, swap `live`), `serve.ts`, `static-server.ts` (`/status`, 404 page), `state.ts`, `git.ts` |
| `src/cli.ts` | Entry point: `validate`, `build`, `preview`, `refresh`, `serve` |
| `fixtures/` | Synthetic content (`content/`, `sparse/`) and source JSON (`sources/`); the only content in this repo |
+1 -1
View File
@@ -59,7 +59,7 @@ Site (work)
- [ ] Filter boxes ignore `data-show`; "Latest writing" considers only the newest 3; `Showcase.astro` branches over the showcase ids by hand (Astro needs static island imports).
Site (personal, issues, everything)
- [ ] Personal wall: year dividers ignore the chip filter and the AI filter; dense-grid order differs from DOM order; own-audio path is untested (no audio fixture); a chip-filtered summary does not follow "show all".
- [ ] `PreferenceSwitch`: the storage key is duplicated in the inline pre-paint script (on purpose, no imports there); the SSR pressed state flickers until hydration.
- [ ] Preference: the storage key is duplicated in the inline pre-paint script in `Base.astro`, and `PAINT_SWITCH` in `scripts/pref-switch.ts` mirrors `paintSwitch` (both on purpose, no imports there; a unit test keeps the painters in step). The pressed-state flicker until hydration is gone (spec 006: the switch is plain HTML painted before first paint).
Tests
- [ ] e2e: helpers hard-code `localhost:4378`; the third-party check is not re-run after the play click; "nothing animates" uses a `getAnimations` snapshot; the no-JS featured count is hard-coded to 2; sparse specs lack positive controls; the YouTube exemption matches by substring; `bun test` can contend with an overlapping Playwright server.
- [ ] Missing automated tests: sheet history, keys and focus; filter toggling on `/everything/`; dead-anchor and live-region regressions; a date-independent issue test; the CLI `build` path; the CSS filter contract and pre-paint script.
+1 -1
View File
@@ -23,7 +23,7 @@ Commands: `bun run typecheck`, `bun test`, `bunx playwright test`.
`paintSwitch` both press exactly the button for the preference and show the archive line only for by hand only.
Write the tests, watch them fail (module missing), add `src/site/scripts/pref-switch.ts` (`PREF_OPTIONS`,
`PAINT_SWITCH`, `paintSwitch`, `initPrefSwitches`), watch them pass. Commit.
- [ ] 2. Switch markup contract. Build test in `test/site/build-pages.test.ts`: every `.pref-switch` in the built
- [x] 2. Switch markup contract. Build test in `test/site/build-pages.test.ts`: every `.pref-switch` in the built
fixture pages has `role="group"`, `aria-label="Projects made"`, the label, three buttons with `aria-pressed` and
`data-pref-value`, the hidden archive line, and is followed by the paint script. Watch it fail; add
`src/site/components/PreferenceSwitch.astro`, use it in `gate/GateFooter.astro` and `work/TopBar.astro`, delete
+3 -2
View File
@@ -55,10 +55,11 @@ export async function hydrated(page: Page, ...names: string[]) {
}
}
// Open /work/ with the sheet, the list and the preference switch live.
// Open /work/ with the sheet, the list and the preference switch live. The switch is a plain module
// script (spec 006), which has run by the time goto resolves on `load`.
export async function openWork(page: Page) {
await page.goto('/work/');
await hydrated(page, 'PreferenceSwitch', 'ProjectSheet', 'ProjectList');
await hydrated(page, 'ProjectSheet', 'ProjectList');
await page.evaluate(() => scrollTo(0, 0));
}
+3 -1
View File
@@ -25,7 +25,9 @@ test('featured hidden panels show "show anyway"; archive link appears', async ({
await openWork(page);
await expect(page.locator('.filtered-note')).toHaveCount(2);
await expect(page.locator('.filtered-note').first()).toBeHidden();
await expect(page.locator('a[href="https://old.lucaswinther.info/"]')).toHaveCount(0);
// The archive line is in the HTML but hidden (spec 006): none of it may show, to the eye or to assistive tech.
await expect(page.locator('a[href="https://old.lucaswinther.info/"]:visible')).toHaveCount(0);
await expect(page.getByRole('link', { name: 'The 2021 site was all by hand →' })).toHaveCount(0);
await chooseByHand(page);
const visibleNotes = page.locator('.filtered-note:visible');
await expect(visibleNotes).toHaveCount(1); // only the some-ai featured project
@@ -0,0 +1,12 @@
---
// "projects made: any way / not fully by AI / by hand only" (spec 006): plain HTML, a first-paint inline script that
// presses the stored choice, and a page script for clicks and `lw:pref` (scripts/pref-switch.ts). Hidden by CSS
// without JavaScript (base.css). Rendered with "any way" pressed; the inline script corrects it before first paint.
import { PAINT_SWITCH, PREF_OPTIONS } from '../scripts/pref-switch.ts';
---
<div class="pref-switch" role="group" aria-label="Projects made"><span class="pref-label">projects made:</span><span class="pref-seg">{PREF_OPTIONS.map((o) => <button type="button" data-pref-value={o.value} aria-pressed={String(o.value === 'any')}>{o.label}</button>)}</span><p class="pref-archive" hidden>Prefer work made without AI? <a href="https://old.lucaswinther.info/">The 2021 site was all by hand →</a></p></div>
<script is:inline set:html={PAINT_SWITCH}></script>
<script>
import { initPrefSwitches } from '../scripts/pref-switch.ts';
initPrefSwitches();
</script>
-45
View File
@@ -1,45 +0,0 @@
import { useEffect, useState } from 'react';
import { parsePreference, type Preference } from '../../core/ai.ts';
import { applyPreference, browserStorage } from '../scripts/preference.ts';
const OPTIONS: { value: Preference; label: string }[] = [
{ value: 'any', label: 'any way' },
{ value: 'not-fully-ai', label: 'not fully by AI' },
{ value: 'by-hand-only', label: 'by hand only' },
];
export default function PreferenceSwitch() {
const [pref, setPref] = useState<Preference>('any');
useEffect(() => {
setPref(parsePreference(document.documentElement.dataset.pref));
// Other controls on the page (e.g. "show all" under the project list) may change it too.
const sync = (e: Event) => setPref(parsePreference((e as CustomEvent).detail));
window.addEventListener('lw:pref', sync);
return () => window.removeEventListener('lw:pref', sync);
}, []);
function choose(value: Preference) {
applyPreference(document.documentElement, browserStorage(), value);
setPref(value);
window.dispatchEvent(new CustomEvent('lw:pref', { detail: value }));
}
return (
<div className="pref-switch" role="group" aria-label="Projects made">
<span className="pref-label">projects made:</span>
<span className="pref-seg">
{OPTIONS.map((o) => (
<button key={o.value} type="button" aria-pressed={pref === o.value} onClick={() => choose(o.value)}>
{o.label}
</button>
))}
</span>
{pref === 'by-hand-only' && (
<p className="pref-archive">
Prefer work made without AI? <a href="https://old.lucaswinther.info/">The 2021 site was all by hand →</a>
</p>
)}
</div>
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
---
import PreferenceSwitch from '../PreferenceSwitch.tsx';
import PreferenceSwitch from '../PreferenceSwitch.astro';
interface Props { email: string; github: string; linkedin: string }
const { email, github, linkedin } = Astro.props;
---
@@ -7,5 +7,5 @@ const { email, github, linkedin } = Astro.props;
<span class="foot-links">
<a href={`mailto:${email}`}>{email}</a> · <a href={github}>GitHub</a> · <a href={linkedin}>LinkedIn</a> · <a href="/simple/">/simple</a>
</span>
<PreferenceSwitch client:idle />
<PreferenceSwitch />
</footer>
+2 -2
View File
@@ -1,5 +1,5 @@
---
import PreferenceSwitch from '../PreferenceSwitch.tsx';
import PreferenceSwitch from '../PreferenceSwitch.astro';
interface Props { name: string; onIndex: boolean; hasWriting: boolean; hasFeatured: boolean; hasList: boolean; hasCv: boolean }
const { name, onIndex, hasWriting, hasFeatured, hasList, hasCv } = Astro.props;
const at = (hash: string) => (onIndex ? hash : `/work/${hash}`);
@@ -17,6 +17,6 @@ const links = [
<a class="who" href="/work/">{name}</a><span>/ work</span>
</div>
<nav class="nav" aria-label="Sections">{links.map((l) => <a href={l.href}>{l.label}</a>)}</nav>
<div class="pref"><PreferenceSwitch client:idle /></div>
<div class="pref"><PreferenceSwitch /></div>
</div>
</header>
+39 -1
View File
@@ -1,9 +1,10 @@
import { beforeAll, describe, expect, test } from 'bun:test';
import { existsSync, readdirSync } from 'node:fs';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { issueId } from '../../src/core/issues.ts';
import { loadContent } from '../../src/library/load.ts';
import { buildFixtureSites, page } from './fixture-sites.ts';
import { PAINT_SWITCH } from '../../src/site/scripts/pref-switch.ts';
// Builds the fixture sites once and checks the issue pages, /everything/ and /simple/.
let out: string;
@@ -123,3 +124,40 @@ describe('issues, everything and simple', () => {
expect(html).not.toContain('undefined');
});
});
describe('preference switch', () => {
const htmlFiles = (root: string): string[] =>
(readdirSync(root, { recursive: true }) as string[]).filter((f) => f.endsWith('.html')).map((f) => join(root, f));
test('every .pref-switch has the markup contract and is painted before first paint', () => {
const withSwitch: string[] = [];
for (const file of htmlFiles(out)) {
const html = readFileSync(file, 'utf8');
const switches = [...html.matchAll(/<div\b[^>]*\bclass="pref-switch"[^>]*>[\s\S]*?<\/div>/g)];
if (switches.length === 0) continue;
withSwitch.push(file.slice(out.length + 1));
expect(switches.length).toBe(1);
const m = switches[0]!;
const sw = m[0];
expect(sw).toMatch(/^<div\b[^>]*\brole="group"/);
expect(sw).toMatch(/^<div\b[^>]*\baria-label="Projects made"/);
expect(sw).toContain('<span class="pref-label">projects made:</span>');
const buttons = [...sw.matchAll(/<button\b([^>]*)>([^<]*)<\/button>/g)];
expect(buttons.map((b) => b[2])).toEqual(['any way', 'not fully by AI', 'by hand only']);
expect(buttons.map((b) => b[1]!.match(/data-pref-value="([^"]+)"/)?.[1])).toEqual(['any', 'not-fully-ai', 'by-hand-only']);
for (const b of buttons) {
expect(b[1]).toContain('type="button"');
expect(b[1]).toMatch(/aria-pressed="(true|false)"/);
}
// The 2021 line is in the HTML but hidden; the paint script shows it for "by hand only".
expect(sw).toMatch(/<p\b[^>]*\bclass="pref-archive"[^>]*\bhidden\b[^>]*>Prefer work made without AI\? <a href="https:\/\/old\.lucaswinther\.info\/">The 2021 site was all by hand →<\/a><\/p>/);
// Right after the markup: the inline script that presses the stored choice before first paint.
const after = html.slice(m.index! + sw.length).match(/^\s*<script\b[^>]*>([\s\S]*?)<\/script>/);
expect(after?.[1]).toBe(PAINT_SWITCH);
}
expect(withSwitch).toContain('index.html');
expect(withSwitch).toContain(join('work', 'index.html'));
// The switch is no React island anywhere.
for (const file of htmlFiles(out)) expect(readFileSync(file, 'utf8')).not.toMatch(/component-url="[^"]*PreferenceSwitch/);
});
});