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:
1 parent
39148fa7f0
commit
35415e774d
10 files changed
+64
-56
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user