docs: no em dashes in what ae966e8 added
The rule is none in replies, commits, issues, docs or site copy, and the last commit put 40 of them into five documents, two scripts and two comments. Nothing reached the site: dist/ has none, and the content validator refuses U+2014 in src/data. Rewritten by sentence rather than substituted, so the punctuation carries the same meaning it did. scripts/lib/css-audit.js is a copy of another repository's file and its 20 were repunctuated with it. Its header now says so, because "lifted verbatim" would otherwise be a claim a byte diff disproves. The logic is untouched, and the sweep still reports zero findings on all ten devices. The four Batch 19 issue bodies were edited in the tracker for the same reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ae966e8f56
commit
12f1fd54df
|
|
@ -227,7 +227,7 @@ still report a scrollWidth equal to the viewport. Eight kinds of finding, of
|
|||
which `clipped`, `past_viewport` and `document_scrolls` are blocking. Exit 0
|
||||
clean, 1 findings, 2 nothing swept.
|
||||
|
||||
It exists because #214 — the header CTA clipped at iPad portrait — was fixed,
|
||||
It exists because #214, the header CTA clipped at iPad portrait, was fixed,
|
||||
checked in a desktop window sized to 768, and released still broken. A desktop
|
||||
window at 768 has a scrollbar, so the layout viewport was ~753px and the `md`
|
||||
breakpoint the fix was about never engaged. A device profile has no scrollbar
|
||||
|
|
|
|||
|
|
@ -826,7 +826,7 @@ The build refuses copy that breaks any of this: see `scripts/lib/content.js`.
|
|||
- Mobile section padding: `py-16` (64px)
|
||||
- Desktop section padding: `py-24` (96px)
|
||||
- Card padding: `p-4 md:p-6`
|
||||
- **Standalone link lists: `space-y-4`, not `space-y-2`** — see the tap-target
|
||||
- **Standalone link lists: `space-y-4`, not `space-y-2`.** See the tap-target
|
||||
rule below. This is a change from what the rest of this file describes, made
|
||||
2026-09-10; it affects the footer columns, the privacy contents and the
|
||||
related-links lists.
|
||||
|
|
@ -839,7 +839,7 @@ takes those 8px back out of the layout, so the line the link sits on does not
|
|||
move. **It only works if the list leaves 16px between rows**: the negative
|
||||
margin does not shrink the box, only its effect on layout, so at `space-y-2`
|
||||
each link's box reached 8px into a gap its neighbour was already reaching 8px
|
||||
into. They overlapped, and `getBoundingClientRect` still read 33px — a target
|
||||
into. They overlapped, and `getBoundingClientRect` still read 33px: a target
|
||||
that measured right and was not there.
|
||||
|
||||
Three rules follow from that:
|
||||
|
|
@ -860,7 +860,7 @@ Three rules follow from that:
|
|||
768 to 1023 gets the `Sheet` menu. The desktop row cannot fit 768: brand, six
|
||||
nav links and the CTA want 787px of natural width against 736px of container,
|
||||
so flex shrank the CTA and wrapped its label and it *still* overflowed. The
|
||||
menu is the better tablet experience regardless — 44px rows instead of 17px
|
||||
menu is the better tablet experience regardless: 44px rows instead of 17px
|
||||
ones, and the Services and Industries submenus are reachable, where the desktop
|
||||
dropdowns open on hover and a touch device has no hover.
|
||||
|
||||
|
|
|
|||
|
|
@ -46,10 +46,10 @@ sequence would have implied more.
|
|||
|
||||
## Entries
|
||||
|
||||
### 2026-09-10 — A device sweep, and a fix that was released without ever being exercised
|
||||
### 2026-09-10: a device sweep, and a fix that was released without ever being exercised
|
||||
|
||||
Every page rendered on ten emulated phones and tablets — real Playwright device
|
||||
profiles rather than a resized window — and measured. **190 page loads, 21
|
||||
Every page rendered on ten emulated phones and tablets, on real Playwright
|
||||
device profiles rather than a resized window, and measured. **190 page loads, 21
|
||||
blocking and 530 high findings** on pages that had already passed Batch 11 and
|
||||
Batch 16. Three defects, all live in production. Filed as #238 to #241 under
|
||||
Batch 19.
|
||||
|
|
@ -76,7 +76,7 @@ of being quietly squeezed past the edge.
|
|||
|
||||
**#239, the Cisco mark.** It ships in a 700x700 canvas it fills 66% of, so
|
||||
`object-contain` rendered it small beside 8x8's and both pages compensated with
|
||||
`scale-[1.5]` and `scale-[2]` inside `overflow-hidden` — cutting 13px off the
|
||||
`scale-[1.5]` and `scale-[2]` inside `overflow-hidden`, cutting 13px off the
|
||||
trademark on `/` and 24px on `/about`. Cropping the asset's own `viewBox` to the
|
||||
artwork and dropping the scale renders it at 62x46 on `/`, the same size as the
|
||||
8x8 logo beside it, with nothing clipped.
|
||||
|
|
@ -90,8 +90,8 @@ whichever painted last. Both versions measure 33px and neither is 33px. **A
|
|||
target that measures right and is not there is worse than one that measures
|
||||
wrong.** The rule that survived is in `design/OVERHAUL_PLAN.md`: `.tap-target`
|
||||
is `inline-block`, and every list that uses it moves to `space-y-4`. Then a
|
||||
second surprise — a 39x17 link grown to 39x36 stops being row-shaped and starts
|
||||
being judged as a compact target wanting 44px — so footer links also take
|
||||
second surprise: a 39x17 link grown to 39x36 stops being row-shaped and starts
|
||||
being judged as a compact target wanting 44px, so footer links also take
|
||||
`block` and the whole row becomes the target.
|
||||
|
||||
**#241 is the instrument.** `scripts/device-sweep.mjs` plus
|
||||
|
|
@ -103,7 +103,7 @@ the viewport while content is sliced off the right edge. Not wired into
|
|||
a guard that cannot run on a clean clone is one that gets skipped. Worth
|
||||
recording that the original in the other repository declares device profiles and
|
||||
then only calls `setViewportSize`, so its `isMobile` and `deviceScaleFactor`
|
||||
never take effect — it is a width sweep wearing a phone's clothes, which is the
|
||||
never take effect. It is a width sweep wearing a phone's clothes, which is the
|
||||
same blind spot in a different form.
|
||||
|
||||
**Proven, not assumed.** After the fixes the sweep reports zero blocking and
|
||||
|
|
@ -112,7 +112,7 @@ sweep reported it again: `clipped x1` and `media_overflow x1` for the logo,
|
|||
`past_viewport x19` for the header, `touch_target x342` for the footer. The
|
||||
first attempt at the header proof reverted only the nav's breakpoint and left
|
||||
the CTA at `lg`, so the element that overflows was not on screen and the sweep
|
||||
correctly reported nothing — a wrong mutation, not a blind checker, and worth
|
||||
correctly reported nothing. A wrong mutation, not a blind checker, and worth
|
||||
writing down because it looks identical to a checker that has stopped working.
|
||||
Exit codes were proven separately: 2 for an unreachable origin and for an
|
||||
unknown `--devices` name, because "nothing was swept" must never read as
|
||||
|
|
|
|||
|
|
@ -99,8 +99,8 @@ and both were invisible to the instrument being used.
|
|||
|
||||
**A window sized to 768 is not 768.** It has a scrollbar, so the layout viewport
|
||||
is about 753, so the `md` breakpoint never engages and the desktop layout the
|
||||
check is about is never on screen. That is how #214 — the header CTA clipped at
|
||||
iPad portrait — was fixed, checked at "768", released, and was still 25px past
|
||||
check is about is never on screen. That is how #214, the header CTA clipped at
|
||||
iPad portrait, was fixed, checked at "768", released, and was still 25px past
|
||||
the right edge on every page. A device profile has no scrollbar inset. Check a
|
||||
breakpoint on a device, or on an emulated one; never on a window you dragged.
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
// node scripts/device-sweep.mjs --devices "iPhone SE,iPad Mini"
|
||||
// node scripts/device-sweep.mjs --report /tmp/sweep.md --shots /tmp/sweep
|
||||
//
|
||||
// Exit codes: 0 nothing found. 1 findings. 2 NOTHING WAS SWEPT — playwright
|
||||
// Exit codes: 0 nothing found. 1 findings. 2 NOTHING WAS SWEPT: playwright
|
||||
// missing, chromium unlaunchable, or no sitemap. Two is not a pass.
|
||||
//
|
||||
// ## Which incident motivated it
|
||||
|
|
@ -171,8 +171,8 @@ if (!loads) {
|
|||
process.exit(2)
|
||||
}
|
||||
|
||||
// The same defect on six devices is one defect. Group on what identifies it —
|
||||
// route, element, text — and keep the device list, because "only iPad Mini"
|
||||
// The same defect on six devices is one defect. Group on what identifies it
|
||||
// (route, element, text) and keep the device list, because "only iPad Mini"
|
||||
// versus "all ten" is the difference between a breakpoint bug and a layout bug.
|
||||
const groups = new Map()
|
||||
for (const finding of findings) {
|
||||
|
|
@ -203,7 +203,7 @@ if (REPORT) {
|
|||
for (const row of group) {
|
||||
lines.push(
|
||||
`- **${row.route}** ${row.kind}: ${row.says || ''}`,
|
||||
` - \`${row.path}\`${row.text ? ` — text: ${JSON.stringify(String(row.text).slice(0, 60))}` : ''}`,
|
||||
` - \`${row.path}\`${row.text ? `, text: ${JSON.stringify(String(row.text).slice(0, 60))}` : ''}`,
|
||||
` - on ${[...row.devices].join(', ')}`,
|
||||
row.detail ? ` - \`${JSON.stringify(row.detail)}\`` : '',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
//
|
||||
// It measures rather than guesses. Every box is compared against its nearest
|
||||
// CLIPPING ancestor instead of document.scrollWidth, which lies the moment any
|
||||
// container carries overflow-x: hidden or clip — and this site's body does, so
|
||||
// container carries overflow-x: hidden or clip, and this site's body does, so
|
||||
// a page can slice content off its right edge and still report a scrollWidth
|
||||
// equal to the viewport. That is exactly how the header CTA at iPad portrait
|
||||
// survived a fix and a release.
|
||||
|
|
@ -21,7 +21,9 @@
|
|||
// where the thresholds were argued out and where the comments explaining each
|
||||
// one were written. Copied rather than shared because the two repositories have
|
||||
// no common package; if a threshold changes in one, it does not change in the
|
||||
// other. The driver here differs from that one in a way that matters: css-qc
|
||||
// other. The logic is that file's, unchanged. The prose is not byte-identical:
|
||||
// this repository does not use em dashes, so the comments and the two report
|
||||
// strings were repunctuated. Diff it on words, not bytes. The driver here differs from that one in a way that matters: css-qc
|
||||
// declares Playwright device profiles but only ever calls setViewportSize, so
|
||||
// its deviceScaleFactor, isMobile and hasTouch fields never take effect and it
|
||||
// is a width sweep wearing a phone's clothes.
|
||||
|
|
@ -74,7 +76,7 @@ export const audit = function audit() {
|
|||
/**
|
||||
* Inside a closed disclosure, and therefore not on screen at all.
|
||||
*
|
||||
* Chrome does not `display: none` a closed `<details>` — it skips the
|
||||
* Chrome does not `display: none` a closed `<details>`. It skips the
|
||||
* subtree with `content-visibility`, and the descendants keep reporting
|
||||
* layout boxes at their unconstrained size. The signature form in the
|
||||
* documents table measured 149px wide at x=255 on a 320px screen while the
|
||||
|
|
@ -149,7 +151,7 @@ export const audit = function audit() {
|
|||
if (scrollerOf(el)) continue;
|
||||
|
||||
// Contained by something that clips: the reader does not see this past the
|
||||
// edge, they see it cut off — which check 2 reports, with the clipper named.
|
||||
// edge, they see it cut off, which check 2 reports, with the clipper named.
|
||||
// Reporting it here as well was the single largest source of noise in the
|
||||
// first run: every `truncate` in the admin has a child span whose rect runs
|
||||
// off the viewport by design, ellipsis and all.
|
||||
|
|
@ -223,7 +225,7 @@ export const audit = function audit() {
|
|||
const side = worst.rect.right - box.rect.right >= box.rect.left - worst.rect.left ? "right" : "left";
|
||||
|
||||
// `text-overflow: ellipsis` is a container saying "I will cut text off and
|
||||
// show that I did". That is an affordance, not silent loss — the reader can
|
||||
// show that I did". That is an affordance, not silent loss: the reader can
|
||||
// see there is more. It stops being one the moment something interactive or
|
||||
// replaced is inside, because a button behind an ellipsis is still a button
|
||||
// nobody can press.
|
||||
|
|
@ -238,14 +240,14 @@ export const audit = function audit() {
|
|||
* The first version of this asked whether a control was present at all, and
|
||||
* that is too coarse for the commonest shape in the admin: a truncated cell
|
||||
* whose text *is* a link. `span.block.truncate > a` reports the anchor's
|
||||
* full 189px box against a 144px cell, so the anchor counted as swallowed —
|
||||
* full 189px box against a 144px cell, so the anchor counted as swallowed,
|
||||
* while on screen 161px of it is visible, ellipsised, and perfectly
|
||||
* clickable. Three blocking findings on the projects board, all of them the
|
||||
* repository link reading `null/Privacy-Period-Tr...`, none of them a fault.
|
||||
*
|
||||
* What the rule is really protecting against is a control the clip puts out
|
||||
* of reach, so measure that: how much of it is left inside the box. Below
|
||||
* the 24px WCAG floor — or its own width, for a control smaller than that —
|
||||
* the 24px WCAG floor (or its own width, for a control smaller than that)
|
||||
* there is nothing to press and the ellipsis is not an affordance any more.
|
||||
*/
|
||||
const MIN_AIMABLE = 24;
|
||||
|
|
@ -288,7 +290,7 @@ export const audit = function audit() {
|
|||
},
|
||||
says:
|
||||
`${leaves.length} element(s) cut off by ${Math.round(escape(worst))}px past the ${side} edge ` +
|
||||
`of an overflow-x:${box.cs.overflowX} box — no scrollbar, no hint`,
|
||||
`of an overflow-x:${box.cs.overflowX} box, with no scrollbar and no hint`,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -318,7 +320,7 @@ export const audit = function audit() {
|
|||
if (!rec) continue;
|
||||
|
||||
// A control inside a <label> is aimed at through the label, so the label is
|
||||
// what gets measured. Skipping it outright — which this did first — means
|
||||
// what gets measured. Skipping it outright, which this did first, means
|
||||
// wrapping a 16px checkbox in a label silences the check without making the
|
||||
// target any bigger, and the fix for the one real instance of that was
|
||||
// exactly such a wrapper. A checker you can satisfy by adding an element is
|
||||
|
|
@ -335,7 +337,7 @@ export const audit = function audit() {
|
|||
|
||||
// Two rules, because a link and a button are not the same shape of target.
|
||||
//
|
||||
// A control must be at least **32px thick and 44px long** — not 44x44.
|
||||
// A control must be at least **32px thick and 44px long**, not 44x44.
|
||||
//
|
||||
// 44x44 is WCAG 2.5.5, the AAA figure, and applying it to anything matching
|
||||
// `button` produced ninety findings that all said the same thing: the admin
|
||||
|
|
@ -352,14 +354,14 @@ export const audit = function audit() {
|
|||
// and 32 rather than 24 so there is margin above the AA floor.
|
||||
//
|
||||
// A text link is judged on height and on its smaller dimension only. The
|
||||
// first version demanded 44px in both axes and duly reported "FAQ" — 27px
|
||||
// wide, 44px tall, with 24px of gap either side — as a defect on nine
|
||||
// first version demanded 44px in both axes and duly reported "FAQ", 27px
|
||||
// wide, 44px tall, with 24px of gap either side, as a defect on nine
|
||||
// routes. Padding a three-letter word out to 44px to satisfy a checker is
|
||||
// the checker driving the design. WCAG 2.5.8 sets 24px as the floor and
|
||||
// exempts inline links in text for exactly this reason; the axis that is
|
||||
// actually scarce in a horizontal nav row is the vertical one.
|
||||
//
|
||||
// A link inside a sentence is exempt, and this is not a loophole — WCAG
|
||||
// A link inside a sentence is exempt, and this is not a loophole: WCAG
|
||||
// 2.5.8 says so in as many words. Its height is the line height of the
|
||||
// prose around it; the only way to give it 44px is to break the paragraph.
|
||||
// Nine of these were being reported on the FAQ and the legal pages.
|
||||
|
|
@ -391,7 +393,7 @@ export const audit = function audit() {
|
|||
short = thickness < 32 - EPS;
|
||||
narrow = length < 44 - EPS;
|
||||
} else if (isControl) {
|
||||
// A compact control — an icon button, a checkbox. You aim at a point, so
|
||||
// A compact control: an icon button, a checkbox. You aim at a point, so
|
||||
// it needs the full 44 in both axes. The 32px relaxation above is for
|
||||
// rows and must not leak here: it would accept `size="icon"` back at
|
||||
// 40x44, which is the exact defect this caught a few commits ago.
|
||||
|
|
@ -404,7 +406,7 @@ export const audit = function audit() {
|
|||
narrow = false;
|
||||
} else {
|
||||
// A link that is a word in a nav. Judged on height, because the vertical
|
||||
// axis is the scarce one in a horizontal row, and on 24px of thickness —
|
||||
// axis is the scarce one in a horizontal row, and on 24px of thickness.
|
||||
// demanding 32 here would report "FAQ" at 27px wide, which is the
|
||||
// checker driving the design again.
|
||||
short = rect.height < 44 - EPS;
|
||||
|
|
@ -420,7 +422,7 @@ export const audit = function audit() {
|
|||
text: text(el),
|
||||
detail: { width: Math.round(rect.width), height: Math.round(rect.height) },
|
||||
says:
|
||||
`${Math.round(rect.width)}x${Math.round(rect.height)}px — ` +
|
||||
`${Math.round(rect.width)}x${Math.round(rect.height)}px, ` +
|
||||
(short
|
||||
? `thinner than the ${isControl && !rowShaped ? 44 : rowShaped ? 32 : 44}px a touch target needs`
|
||||
: `shorter than the ${isControl ? 44 : 24}px minimum`),
|
||||
|
|
@ -440,7 +442,7 @@ export const audit = function audit() {
|
|||
if (size >= 12 - 0.01) continue;
|
||||
|
||||
// Visually hidden. `sr-only` clips text to a 1px box so a screen reader
|
||||
// still reads it and nobody sees it — its font-size is not a legibility
|
||||
// still reads it and nobody sees it, so its font-size is not a legibility
|
||||
// question, and 28 of the 87 findings here were the Ripley quote's
|
||||
// `sr-only` companion saying the same thing on every admin screen.
|
||||
const box = info.get(el).rect;
|
||||
|
|
@ -450,7 +452,7 @@ export const audit = function audit() {
|
|||
// Small uppercase tracked text is a label, not prose.
|
||||
//
|
||||
// This codebase writes badges, eyebrows and machine values as 10-11px mono
|
||||
// uppercase with positive letter-spacing — a deliberate typographic
|
||||
// uppercase with positive letter-spacing: a deliberate typographic
|
||||
// register, used in over two hundred places. Reporting every one of them at
|
||||
// "high" produces a list nobody will ever work through, which is how a
|
||||
// report stops being read. What actually harms a reader is small *prose*,
|
||||
|
|
@ -520,8 +522,8 @@ export const audit = function audit() {
|
|||
// covers it, in both axes.
|
||||
//
|
||||
// Vertically, a quarter is enough: what a stuck header loses first is its
|
||||
// top, which is where its title is. A 50% threshold — the first version
|
||||
// of this — let the real case through, because the project header is
|
||||
// top, which is where its title is. A 50% threshold, which this used
|
||||
// first, let the real case through, because the project header is
|
||||
// 294px tall and the bar over it is 132px, so it was "only" 45% hidden.
|
||||
//
|
||||
// Horizontally is not optional. The admin sidebar is `fixed inset-y-0
|
||||
|
|
@ -577,11 +579,11 @@ export const audit = function audit() {
|
|||
//
|
||||
// There was, and it could never fire: `getComputedStyle` resolves `100vh` to
|
||||
// a pixel value, so nothing in the browser can tell it apart from a height
|
||||
// that was written in pixels. A check that cannot fail is worse than no check
|
||||
// — it reads as coverage. The rule it was reaching for ("use dvh, because
|
||||
// 100vh is the viewport with the mobile URL bar hidden") is a property of the
|
||||
// source, so it lives in `tests/responsive-guards.test.ts` where a source
|
||||
// grep is the honest instrument.
|
||||
// that was written in pixels. A check that cannot fail is worse than no
|
||||
// check, because it reads as coverage. The rule it was reaching for ("use dvh,
|
||||
// because 100vh is the viewport with the mobile URL bar hidden") is a property
|
||||
// of the source, so it lives in `tests/responsive-guards.test.ts` where a
|
||||
// source grep is the honest instrument.
|
||||
|
||||
return findings;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ const Figure = ({ block }) =>
|
|||
// space-y-4 and tap-target, not the space-y-2 this had: a standalone link in a
|
||||
// list is a tap target, and .tap-target (index.css) needs 16px between rows to
|
||||
// keep neighbouring hit boxes from overlapping. LINK_CLASS itself must stay
|
||||
// padding-free — it is also used inside sentences.
|
||||
// padding-free, because it is also used inside sentences.
|
||||
const LinkList = ({ block }) => (
|
||||
<ul className="mt-4 space-y-4">
|
||||
{block.items.map((item) => (
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ a:hover {
|
|||
* spacing these lists used to have, each link's box reached 8px into a gap the
|
||||
* neighbour was already reaching 8px into. They overlapped, hit-testing gave
|
||||
* the whole gap to whichever painted last, and getBoundingClientRect still
|
||||
* read 33px — a checker satisfied by a target that was not really there. Every
|
||||
* read 33px: a checker satisfied by a target that was not really there. Every
|
||||
* caller therefore pairs this with space-y-4 or gap-y-4, which makes the row
|
||||
* pitch 33 and the boxes tile exactly.
|
||||
*
|
||||
|
|
|
|||
Loading…
Reference in New Issue