Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 8 additions & 2 deletions src/react/SiteChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */}
<div className="flex items-center justify-between gap-6 py-4">
<Link href={href()} className="shrink-0">
{/* 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. */}
<div className="flex items-center justify-between gap-6 py-2">
<Link
href={href()}
className="inline-flex min-h-11 shrink-0 items-center rounded focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
<span className="font-heading text-lg font-semibold tracking-display text-fg-primary">
{chrome.name}
</span>
Expand Down
11 changes: 10 additions & 1 deletion src/react/SiteNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
71 changes: 71 additions & 0 deletions test/nav-a11y.test.js
Original file line number Diff line number Diff line change
@@ -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('<a'), html.indexOf('</a>'));
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"'));
});