feat(site): the start page's gate is a plain page script, no React

Gate.tsx (an island that rendered nothing) becomes scripts/gate.ts with its key
handling and readout text as pure functions. With the switch already plain, the
start page has no island and loads none of the React runtime.

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:49:38 +01:00
1 parent 35415e774d
commit fdad3e86a0
10 files changed
+235 -108

No files matched your search

+6 -2
View File
@@ -4,7 +4,8 @@ How `portfolio-next` is built today. Update in the same commit as any structural
## Overview
A two-gate personal site (work / off the clock) rendered to static HTML by Astro, with React only for
small islands. One container runs `bun src/cli.ts serve`: it polls a separate content repo
small islands. The start page loads no React at all: its behaviour and the preference switch are plain page scripts
(spec 006). One container runs `bun src/cli.ts serve`: it polls a separate content repo
(`portfolio-next-content`), builds the site in the background, swaps the finished build in atomically
and serves it. Ghost posts, Trakt and Hardcover are fetched once per build. Behaviour: `docs/specs/001-site.md`;
content format: `docs/CONTENT-CONTRACT.md`; deployment: `docs/DEPLOY.md`.
@@ -15,7 +16,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/` (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/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`, `gate.ts` for the start page), `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 |
@@ -51,6 +52,9 @@ Hardcover ──────────┘ │ writes <cache>/sources.
an element and attribute allowlist over a real tokenizer; links only http(s)/mailto/tel/relative; media
`preload="none"`; iframes and images from hosts other than `GHOST_URL`'s become plain links.
- Every page is complete HTML without JavaScript; islands only enhance. Nothing moves until the visitor acts.
- The preference: the inline head script in `Base.astro` sets `data-pref` on `<html>` before first paint; the switch's
inline `PAINT_SWITCH` presses the stored choice right after its markup; choosing fires `lw:pref` on `window`, which
the switch, `FilterNote` and the React `ProjectList` follow.
- No invented facts about the owner: unknown stays out or `draft: true`; an unknown AI level is shown as unknown.
- The AI filter contract: `data-ai` only on elements whose level is not null; never `display: … !important` on `[data-ai]`.
- The interest filter contract: every wall card carries `data-interests` (space-separated ids, primary first; live cards
+21
View File
@@ -346,3 +346,24 @@ which Ghost 6 is believed to cap at 100 posts.
`fetchGhost` asks for `limit=100&page=N` and follows `meta.pagination.next`, up to 50 pages.
**Alternatives:** an inline-limit function that only exempts fonts (nothing else here is small enough to matter).
**Consequences:** Every imported asset is its own request; a test fails if a stylesheet contains `url(data:`.
## 2026-11-11 — The start page ships no React; one plain preference switch
**Status:** accepted
**Context:** Spec 006, from the 2026-11-11 audit (finding 3). The start page downloaded the React runtime
(`client.*.js`, 213 KB, 66 KB gzip) for two islands that needed none of it: `Gate.tsx` rendered nothing, and
`PreferenceSwitch.tsx` was three buttons whose pressed state flickered from "any way" to the stored choice until
hydration.
**Decision:** The gate's behaviour is a plain page script (`src/site/scripts/gate.ts`, loaded by a processed
`<script>` in `index.astro`), with its key handling and readout text as pure, unit-tested functions. The switch is
`PreferenceSwitch.astro` on every page that has it (start page footer, work top bar): static markup rendered with
"any way" pressed and the 2021 line `hidden`, then an inline script (`PAINT_SWITCH`, a string in `pref-switch.ts`,
no imports) that presses the stored choice from the `data-pref` the head script already set, before first paint; a
page script handles clicks and follows `lw:pref`. Buttons carry `data-pref-value`. A unit test runs `PAINT_SWITCH`
against a fake document and checks it draws what `paintSwitch` draws.
**Alternatives:** a plain switch on the gate and the React one on `/work/` (two copies would drift); pressed styles
keyed on `html[data-pref]` in CSS (no inline script, but every pressed-state rule written twice and `aria-pressed`
still wrong until a script runs); the 2021 line inserted by script rather than present and `hidden` (a bigger inline
script).
**Consequences:** The 2021 line is in the HTML of every page with the switch, hidden unless "by hand only" (`hidden`
keeps it out of view, the tab order and the accessibility tree). The start page's JS is a few small first-party
modules. Pages that keep React islands still load the runtime.
+1 -1
View File
@@ -28,7 +28,7 @@ Small, specific, independently actionable. Remove only when actually done.
frame; a transform/clip-path lean would run on the compositor. Hover lab (local only): `.tmp/real-site/hover-lab.html`
switches off each effect and shows fps; baseline `.tmp/real-site/fps.html`.
- [ ] The strip's Listening cell replaced the newest pinned song (owner: "for now, then we can look at alternatives later"). The pinned song's note ("why ↘") now has no spot in the strip; look at alternatives.
- [ ] Revisit the start page's "or wander through everything at once" pill (and its G shortcut). Hidden for now on the owner's request: `SHOW_EVERYTHING_PILL` in `src/site/pages/index.astro`. Its CSS, the seam positioning in `Gate.tsx` and the seam check in `e2e/gate.spec.ts` are kept; `/everything/` is still reachable from `/simple`.
- [ ] Revisit the start page's "or wander through everything at once" pill (and its G shortcut). Hidden for now on the owner's request: `SHOW_EVERYTHING_PILL` in `src/site/pages/index.astro`. Its CSS, the seam positioning in `src/site/scripts/gate.ts` and the seam check in `e2e/gate.spec.ts` are kept; `/everything/` is still reachable from `/simple`.
- [ ] Personal wall: a chip filter is not reflected in the URL, so a filtered view can't be shared.
- [ ] Film soundtrack, alternative design: mockup B (the soundtrack as a record tucked behind the poster, slides out on hover; `mockups/shots/spec-002-r1-cards.png`). The owner kept it as an idea for later or as an alternative to the tray.
- [ ] Single-cover comic, alternative design: mockup G (ink-framed cover beside the text). J (cover leaning on the paper with the one-liner) "could be interesting but doesn't work yet". I (cover plus two panels) only with real, different page images — today's panel strip already does that when an item has three images.
+1 -1
View File
@@ -29,7 +29,7 @@ Commands: `bun run typecheck`, `bun test`, `bunx playwright test`.
`src/site/components/PreferenceSwitch.astro`, use it in `gate/GateFooter.astro` and `work/TopBar.astro`, delete
`PreferenceSwitch.tsx`; update `e2e/helpers.ts` (`openWork` no longer waits for a PreferenceSwitch island). Watch it
pass. Docs: TODO flicker item, ARCHITECTURE map. Commit.
- [ ] 3. Gate without React. Build test: the fixture start page has no `astro-island` and none of its scripts
- [x] 3. Gate without React. Build test: the fixture start page has no `astro-island` and none of its scripts
(followed through their imports) is the React runtime; `/work/` still has islands with the React renderer. Unit tests
for the pure parts of the gate (`readoutText`, `keyAction`) in `test/site/gate.test.ts`. Watch them fail; add
`src/site/scripts/gate.ts`, load it from `index.astro`, delete `Gate.tsx`; adapt `e2e/gate.spec.ts` (no island to
+4 -3
View File
@@ -1,5 +1,8 @@
import { expect, test } from '@playwright/test';
import { blockThirdParty, hydrated } from './helpers.ts';
import { blockThirdParty } from './helpers.ts';
// The gate's behaviour is a plain module script (spec 006); module scripts have run by the time page.goto
// resolves on `load`, so there is no island to wait for.
test.beforeEach(async ({ page }) => {
await blockThirdParty(page);
@@ -18,7 +21,6 @@ test('the everything pill is hidden for now, and G does nothing', async ({ page,
await page.goto('/');
await expect(page.locator('#garden-link')).toHaveCount(0);
test.skip(!!isMobile, 'keyboard shortcut');
await hydrated(page, 'Gate');
await page.keyboard.press('g');
await page.waitForTimeout(300);
await expect(page).toHaveURL(/\/$/);
@@ -27,7 +29,6 @@ test('the everything pill is hidden for now, and G does nothing', async ({ page,
test('arrows lean and Enter enters', async ({ page, isMobile }) => {
test.skip(!!isMobile, 'keyboard shortcut');
await page.goto('/');
await hydrated(page, 'Gate');
await expect(async () => {
await page.keyboard.press('ArrowRight');
await expect(page.locator('#gate')).toHaveClass(/lean-art/, { timeout: 500 });
-97
View File
@@ -1,97 +0,0 @@
import { useEffect } from 'react';
// Adds behaviour to the start page; the HTML already works as two plain links without it.
// Arrow keys lean toward a side, Enter goes in, Esc lets go, G opens everything. Under (hover: hover)
// the work side shows a crosshair readout. The song sleeve plays only after the visitor presses play.
export default function Gate() {
useEffect(() => {
const gate = document.getElementById('gate');
if (!gate) return;
const tech = document.getElementById('tech');
const art = document.getElementById('art');
const garden = document.getElementById('garden-link') as HTMLAnchorElement | null;
const hrefOf = (side: string) => gate.querySelector<HTMLAnchorElement>(`[data-enter="${side}"]`)?.href;
let lean: 'work' | 'personal' | null = null;
function setLean(s: typeof lean) {
lean = s;
gate!.classList.toggle('lean-tech', s === 'work');
gate!.classList.toggle('lean-art', s === 'personal');
}
function onKey(e: KeyboardEvent) {
const t = e.target as HTMLElement | null;
if (t?.closest?.('input, textarea, select') || e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === 'ArrowLeft') setLean('work');
else if (e.key === 'ArrowRight') setLean('personal');
else if (e.key === 'Escape') setLean(null);
else if (e.key === 'Enter' && lean && !t?.closest?.('a, button')) {
const href = hrefOf(lean);
if (href) location.href = href;
} else if ((e.key === 'g' || e.key === 'G') && garden) location.href = garden.href;
}
document.addEventListener('keydown', onKey);
// The garden pill sits on the seam, wherever the sides have leaned to.
function placeGarden() {
if (!garden || !tech) return;
garden.style.left = window.innerWidth > 820 ? `${tech.offsetWidth}px` : '';
}
placeGarden();
const ro = typeof ResizeObserver === 'undefined' || !tech ? null : new ResizeObserver(placeGarden);
if (tech) ro?.observe(tech);
window.addEventListener('resize', placeGarden);
// Crosshair readout, only for a visitor who hovers.
const cleanups: Array<() => void> = [];
if (tech && window.matchMedia('(hover: hover)').matches) {
const h = document.getElementById('xh-h');
const v = document.getElementById('xh-v');
const ro2 = document.getElementById('readout');
for (const el of [h, v, ro2]) el?.removeAttribute('hidden');
const move = (e: MouseEvent) => {
const r = tech.getBoundingClientRect();
const x = Math.round(e.clientX - r.left);
const y = Math.round(e.clientY - r.top);
if (h) h.style.top = `${y}px`;
if (v) v.style.left = `${x}px`;
if (ro2) ro2.innerHTML = `x ${String(x).padStart(3, '0')} · y ${String(y).padStart(3, '0')}<br>cell ${Math.floor(x / 24)},${Math.floor(y / 24)}`;
};
tech.addEventListener('mousemove', move);
cleanups.push(() => tech.removeEventListener('mousemove', move));
}
// Song sleeve: the play button exists in the HTML but stays hidden until this runs.
const plays = art ? Array.from(art.querySelectorAll<HTMLButtonElement>('[data-play]')) : [];
for (const btn of plays) {
btn.hidden = false;
const sleeve = btn.closest('.tape');
const audio = sleeve?.querySelector('audio');
if (!sleeve || !audio) continue;
const sync = () => {
const on = !audio.paused;
sleeve.classList.toggle('playing', on);
btn.textContent = on ? '❚❚' : '▶';
btn.setAttribute('aria-label', on ? 'Pause' : 'Play');
};
// play() rejects when the file is missing or the browser refuses; put the button back to "Play".
const click = () => {
if (audio.paused) audio.play().catch(() => { audio.pause(); sync(); });
else audio.pause();
};
btn.addEventListener('click', click);
audio.addEventListener('play', sync);
audio.addEventListener('pause', sync);
audio.addEventListener('ended', sync);
cleanups.push(() => btn.removeEventListener('click', click));
}
return () => {
document.removeEventListener('keydown', onKey);
window.removeEventListener('resize', placeGarden);
ro?.disconnect();
cleanups.forEach((f) => f());
};
}, []);
return null;
}
+6 -3
View File
@@ -1,7 +1,6 @@
---
import Base from '../layouts/Base.astro';
import AiMark from '../components/AiMark.astro';
import Gate from '../components/gate/Gate.tsx';
import GateSide from '../components/gate/GateSide.astro';
import GateFooter from '../components/gate/GateFooter.astro';
import FilterNote from '../components/FilterNote.astro';
@@ -39,7 +38,7 @@ const newestEntry = personal[0];
const [first, ...rest] = site.name.split(' ');
const surname = rest.join(' ');
// The "or wander through everything at once" pill (and its G shortcut, which reads the pill's link) is
// The "or wander through everything at once" pill (and its G shortcut in scripts/gate.ts, which reads the pill's link) is
// hidden for now, on the owner's request; see docs/TODO.md. /everything/ itself stays up.
const SHOW_EVERYTHING_PILL = false;
---
@@ -135,5 +134,9 @@ const SHOW_EVERYTHING_PILL = false;
</main>
<GateFooter email={site.links.email} github={site.links.github} linkedin={site.links.linkedin} />
<Gate client:idle />
<script>
// The gate's keys, crosshair and cassette (spec 006: a plain page script, no React on the start page).
import { initGate } from '../scripts/gate.ts';
initGate();
</script>
</Base>
+117
View File
@@ -0,0 +1,117 @@
// Adds behaviour to the start page; the HTML already works as two plain links without it (spec 006: a plain page
// script, no React). Arrow keys lean toward a side, Enter goes in, Esc lets go, G opens everything (only while its
// pill is on the page). Under (hover: hover) the work side shows a crosshair readout. The song sleeve plays only
// after the visitor presses play.
export type Lean = 'work' | 'personal' | null;
export type GateKey = {
key: string;
metaKey: boolean;
ctrlKey: boolean;
altKey: boolean;
/** Focus is in an input, textarea or select. */
inField: boolean;
/** Focus is on a link or button (Enter belongs to it). */
onControl: boolean;
};
export type GateAction = { lean: Lean } | { go: 'work' | 'personal' | 'everything' } | null;
export function keyAction(k: GateKey, lean: Lean, hasEverything: boolean): GateAction {
if (k.inField || k.metaKey || k.ctrlKey || k.altKey) return null;
if (k.key === 'ArrowLeft') return { lean: 'work' };
if (k.key === 'ArrowRight') return { lean: 'personal' };
if (k.key === 'Escape') return { lean: null };
if (k.key === 'Enter') return lean && !k.onControl ? { go: lean } : null;
if ((k.key === 'g' || k.key === 'G') && hasEverything) return { go: 'everything' };
return null;
}
/** The crosshair readout: position in the work half, padded, and the 24px grid cell under the pointer. */
export function readoutText(x: number, y: number): string {
const pad = (n: number) => String(n).padStart(3, '0');
return `x ${pad(x)} · y ${pad(y)}<br>cell ${Math.floor(x / 24)},${Math.floor(y / 24)}`;
}
/* ---------- browser ---------- */
export function initGate() {
const gate = document.getElementById('gate');
if (!gate) return;
const tech = document.getElementById('tech');
const art = document.getElementById('art');
const garden = document.getElementById('garden-link') as HTMLAnchorElement | null;
const hrefOf = (side: 'work' | 'personal') => gate.querySelector<HTMLAnchorElement>(`[data-enter="${side}"]`)?.href;
let lean: Lean = null;
function setLean(s: Lean) {
lean = s;
gate!.classList.toggle('lean-tech', s === 'work');
gate!.classList.toggle('lean-art', s === 'personal');
}
document.addEventListener('keydown', (e) => {
const t = e.target as HTMLElement | null;
const action = keyAction(
{
key: e.key, metaKey: e.metaKey, ctrlKey: e.ctrlKey, altKey: e.altKey,
inField: !!t?.closest?.('input, textarea, select'), onControl: !!t?.closest?.('a, button'),
},
lean,
!!garden,
);
if (!action) return;
if ('lean' in action) setLean(action.lean);
else {
const href = action.go === 'everything' ? garden?.href : hrefOf(action.go);
if (href) location.href = href;
}
});
// The garden pill sits on the seam, wherever the sides have leaned to.
function placeGarden() {
if (!garden || !tech) return;
garden.style.left = window.innerWidth > 820 ? `${tech.offsetWidth}px` : '';
}
placeGarden();
if (tech && typeof ResizeObserver !== 'undefined') new ResizeObserver(placeGarden).observe(tech);
window.addEventListener('resize', placeGarden);
// Crosshair readout, only for a visitor who hovers.
if (tech && window.matchMedia('(hover: hover)').matches) {
const h = document.getElementById('xh-h');
const v = document.getElementById('xh-v');
const readout = document.getElementById('readout');
for (const el of [h, v, readout]) el?.removeAttribute('hidden');
tech.addEventListener('mousemove', (e) => {
const r = tech.getBoundingClientRect();
const x = Math.round(e.clientX - r.left);
const y = Math.round(e.clientY - r.top);
if (h) h.style.top = `${y}px`;
if (v) v.style.left = `${x}px`;
if (readout) readout.innerHTML = readoutText(x, y);
});
}
// Song sleeve: the play button exists in the HTML but stays hidden until this runs.
const plays = art ? Array.from(art.querySelectorAll<HTMLButtonElement>('[data-play]')) : [];
for (const btn of plays) {
btn.hidden = false;
const sleeve = btn.closest('.tape');
const audio = sleeve?.querySelector('audio');
if (!sleeve || !audio) continue;
const sync = () => {
const on = !audio.paused;
sleeve.classList.toggle('playing', on);
btn.textContent = on ? '❚❚' : '▶';
btn.setAttribute('aria-label', on ? 'Pause' : 'Play');
};
// play() rejects when the file is missing or the browser refuses; put the button back to "Play".
btn.addEventListener('click', () => {
if (audio.paused) audio.play().catch(() => { audio.pause(); sync(); });
else audio.pause();
});
audio.addEventListener('play', sync);
audio.addEventListener('pause', sync);
audio.addEventListener('ended', sync);
}
}
+40 -1
View File
@@ -1,6 +1,6 @@
import { beforeAll, describe, expect, test } from 'bun:test';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { dirname, 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';
@@ -161,3 +161,42 @@ describe('preference switch', () => {
for (const file of htmlFiles(out)) expect(readFileSync(file, 'utf8')).not.toMatch(/component-url="[^"]*PreferenceSwitch/);
});
});
describe('start page without React (spec 006)', () => {
// Every JS file a page loads: its module scripts and island URLs, followed through their imports.
function scriptsOf(root: string, path: string): Map<string, string> {
const html = page(root, path);
const queue = [
...[...html.matchAll(/<script\b[^>]*\bsrc="(\/[^"]+)"/g)].map((m) => m[1]!),
...[...html.matchAll(/\b(?:component|renderer)-url="(\/[^"]+)"/g)].map((m) => m[1]!),
];
const seen = new Map<string, string>();
while (queue.length) {
const url = queue.shift()!;
if (seen.has(url)) continue;
const src = readFileSync(join(root, url), 'utf8');
seen.set(url, src);
for (const m of src.matchAll(/(?:\bfrom|\bimport)\s*\(?\s*["'](\.{1,2}\/[^"']+\.js)["']/g)) queue.push(join(dirname(url), m[1]!));
}
return seen;
}
const isReact = (src: string) => /react\.(transitional\.)?element|Minified React error/.test(src);
test('the start page has no island and loads nothing of the React runtime', () => {
const html = page(out, 'index.html');
expect(html.match(/<astro-island\b[^>]*>/g) ?? []).toEqual([]);
expect(html.includes('renderer-url')).toBe(false);
const scripts = scriptsOf(out, 'index.html');
expect(scripts.size).toBeGreaterThan(0); // its behaviour comes from plain page scripts
for (const [url, src] of scripts) {
expect(url).not.toMatch(/\/(client|react|jsx-runtime)\./);
expect(isReact(src), url).toBe(false);
}
});
test('the work page still hydrates its islands with React', () => {
const html = page(out, 'work/index.html');
expect(html).toMatch(/<astro-island\b[^>]*component-url="[^"]*ProjectList/);
expect([...scriptsOf(out, 'work/index.html').values()].some(isReact)).toBe(true);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { expect, test } from 'bun:test';
import { keyAction, readoutText, type GateKey } from '../../src/site/scripts/gate.ts';
const key = (k: string, more: Partial<GateKey> = {}): GateKey => ({
key: k, metaKey: false, ctrlKey: false, altKey: false, inField: false, onControl: false, ...more,
});
test('arrows lean a side, Esc lets go', () => {
expect(keyAction(key('ArrowLeft'), null, false)).toEqual({ lean: 'work' });
expect(keyAction(key('ArrowRight'), 'work', false)).toEqual({ lean: 'personal' });
expect(keyAction(key('Escape'), 'personal', false)).toEqual({ lean: null });
});
test('Enter goes into the leaned side, unless nothing leans or focus is on a link or button', () => {
expect(keyAction(key('Enter'), 'work', false)).toEqual({ go: 'work' });
expect(keyAction(key('Enter'), 'personal', false)).toEqual({ go: 'personal' });
expect(keyAction(key('Enter'), null, false)).toBeNull();
expect(keyAction(key('Enter', { onControl: true }), 'work', false)).toBeNull();
});
test('G opens everything only while its pill is on the page', () => {
expect(keyAction(key('g'), null, true)).toEqual({ go: 'everything' });
expect(keyAction(key('G'), 'work', true)).toEqual({ go: 'everything' });
expect(keyAction(key('g'), null, false)).toBeNull();
});
test('typing in a field or holding a modifier does nothing', () => {
expect(keyAction(key('ArrowLeft', { inField: true }), null, true)).toBeNull();
for (const mod of ['metaKey', 'ctrlKey', 'altKey'] as const) {
expect(keyAction(key('ArrowLeft', { [mod]: true }), null, true)).toBeNull();
expect(keyAction(key('g', { [mod]: true }), null, true)).toBeNull();
}
expect(keyAction(key('x'), 'work', true)).toBeNull();
});
test('the crosshair readout pads the position and names the 24px cell', () => {
expect(readoutText(7, 120)).toBe('x 007 · y 120<br>cell 0,5');
expect(readoutText(1234, 0)).toBe('x 1234 · y 000<br>cell 51,0');
});