diff --git a/README.md b/README.md index 96daf22..491b5fc 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ every consumer gets it on the next version bump. ## Install ```bash -npm i github:bitbaum/sitekit#v0.2.0 +npm i github:bitbaum/sitekit#v0.3.0 ``` ESM-only. `react >= 18` is a peer dependency of `sitekit/react`; the root diff --git a/package.json b/package.json index e81b03e..0f50ca6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sitekit", - "version": "0.2.0", + "version": "0.3.0", "description": "A website as data: a closed union of section shapes validated at runtime, one set of React renderers, and per-field provenance so a generated site can prove it fabricated nothing.", "license": "MIT", "author": "Mao Nakamoto", diff --git a/src/react/SiteChrome.tsx b/src/react/SiteChrome.tsx index c26cc7e..ac53869 100644 --- a/src/react/SiteChrome.tsx +++ b/src/react/SiteChrome.tsx @@ -30,8 +30,14 @@ export function SiteMasthead({ chrome, navItems, currentPath, Link = DefaultLink {/* One row at every width. Wrapping the nav onto a second line makes a sticky masthead eat a third of a phone screen, so on narrow viewports the nav scrolls sideways instead. */} -
- + {/* py-2, not py-4: the nav's items are now 44px tall (the touch floor), + and the row pays for that out of its own padding so the masthead + keeps the same height it always had. */} +
+ {chrome.name} diff --git a/src/react/SiteNav.tsx b/src/react/SiteNav.tsx index 9636d9e..ba717de 100644 --- a/src/react/SiteNav.tsx +++ b/src/react/SiteNav.tsx @@ -38,7 +38,16 @@ export function SiteNav({ items, currentPath, Link = DefaultLink }: Props) { href={href(item.path)} aria-current={isCurrent ? 'page' : undefined} className={[ - 'shrink-0 rounded px-2 py-1 font-mono text-xs uppercase tracking-caps transition-colors', + // min-h-11 is the 44px touch floor. `px-2 py-1` on text-xs came out + // around 28px, which is a fiddly target on a phone and below the + // floor the rest of the fleet holds. The masthead row drops from + // py-4 to py-2 to pay for it, so the sticky header does NOT get + // taller — see the comment in SiteChrome. + 'inline-flex min-h-11 shrink-0 items-center rounded px-2 font-mono text-xs uppercase tracking-caps transition-colors', + // A consumer's CSS reset can remove the UA focus ring, and this + // package shipped nothing to replace it — so keyboard users had no + // visible position in the nav on every site that installs it. + 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent', isCurrent ? 'text-fg-primary underline decoration-accent decoration-2 underline-offset-8' : 'text-fg-tertiary hover:text-fg-primary', diff --git a/test/nav-a11y.test.js b/test/nav-a11y.test.js new file mode 100644 index 0000000..b769c1b --- /dev/null +++ b/test/nav-a11y.test.js @@ -0,0 +1,71 @@ +/** + * The chrome must not ship defects its consumers cannot fix. + * + * A shared renderer is the one place where a mistake is not one bug in one repo + * but the same bug on every site that installs it — and unfixable downstream, + * because a consumer cannot patch markup it does not own. Two such defects + * shipped: nav links were `px-2 py-1` on `text-xs` (roughly a 28px target, below + * the 44px floor the rest of the fleet holds), and nothing in the package + * defined a focus style, so a consumer's CSS reset could remove the UA ring and + * leave keyboard users with no visible position in the nav. + * + * These assert the rendered markup, not the source, and import by package name + * so a broken `exports`/`files` map fails here rather than in a consumer. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createElement as h } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { SiteMasthead } from 'sitekit/react'; + +const CHROME = { name: 'Café Beispiel', tagline: 'Kaffee', footerNote: 'Run by its owners.' }; +const NAV = [ + { path: '', label: 'Home' }, + { path: 'menu', label: 'Menu' }, +]; + +const masthead = (currentPath = 'menu') => + renderToStaticMarkup(h(SiteMasthead, { chrome: CHROME, navItems: NAV, currentPath })); + +test('nav links meet the 44px touch floor', () => { + const html = masthead(); + assert.ok(html.includes('min-h-11'), 'nav links must carry the 44px minimum height'); + assert.ok( + !/class="[^"]*\bpx-2 py-1\b[^"]*font-mono text-xs uppercase/.test(html), + 'the old sub-floor px-2 py-1 target must be gone', + ); +}); + +test('nav links carry a visible focus style of their own', () => { + const html = masthead(); + assert.ok( + html.includes('focus-visible:outline-2'), + 'a consumer reset can remove the UA ring, so the package must ship one', + ); + assert.ok(html.includes('focus-visible:outline-offset-2')); +}); + +test('the wordmark is focusable with a visible ring and a real target', () => { + const html = masthead(); + // The home link sits before the nav; check the first anchor specifically. + const firstAnchor = html.slice(html.indexOf('')); + assert.ok(firstAnchor.includes('focus-visible:outline-2'), 'wordmark needs a focus ring'); + assert.ok(firstAnchor.includes('min-h-11'), 'wordmark needs a 44px target'); +}); + +test('the masthead does not grow to pay for the bigger targets', () => { + // The sticky masthead's own comment warns against eating a phone screen. The + // row gives up padding (py-4 -> py-2) so 44px items cost no extra height. + const html = masthead(); + assert.ok(html.includes('py-2'), 'row padding should be py-2'); + assert.ok( + !/justify-between gap-6 py-4/.test(html), + 'the row must not still be py-4, or the header got taller', + ); +}); + +test('the active item is still announced', () => { + // Guard against a refactor of these classes dropping what already worked. + assert.ok(masthead('menu').includes('aria-current="page"')); +});