The markup rules

What "pristine HTML" means here, stated precisely enough for a validator.

The class rule

A class earns its place only when the element and its position cannot carry the information. class="section" on a <section> says nothing the element did not already say, and it says nothing at all to a screen reader.

Three tiers, applied in order:

  1. Nothing. The element and its position identify it. main > section, section > header, header > h2 + p.
  2. An attribute with a value, where the thing is an enumerated property of the element rather than a name. data-band="alt", data-cols="3".
  3. A class, where a genuine variant has to be named and no structure implies it. ghost on a link that must not look like the primary action.

Tier two is not a place to hide classes under a different syntax. Swapping class="card-grid" for data-card-grid gains nothing. The win is deleting the hook, not renaming it.

Why this is available here and not elsewhere

Structural selectors are fragile on a hand-authored site: someone wraps a heading in a div, header > h2 + p stops matching, and the page quietly loses its lead paragraph styling.

Nothing here is hand-authored. Every element in public/ comes from a component template, the component set is closed, and the checker fails a build whose structure does not match what the stylesheet expects. The fragility that makes these selectors a bad idea in general is the exact thing this system removes.

The corollary is a real constraint: the lean stylesheet assumes generated markup. A site still hand-writing its HTML cannot adopt it safely, so it ships with the engine rather than ahead of it.

Accessibility and class count push the same way

A <div class="card-grid"> tells CSS three things and assistive technology nothing. A <ul> tells CSS the same three things and announces "list, 3 items" to a screen reader. Every class removed in the audit below was removed by reaching for the element that already meant it, so the markup got more informative and shorter at once.

<small> is the clearest case. It is the element for fine print, which is exactly what .fine and .fineprint were painting.

The audit

Representative section, as v2 renders it today:

<section class="section section-alt">
  <div class="wrap">
    <div class="section-head">
      <span class="tag">Foundations</span>
      <h2>Tokens</h2>
      <p class="lead">Every colour in the system is a custom property.</p>
    </div>
    <div class="card-grid cols-3">
      <a class="card card-link" href="/tokens/">
        <div class="card-icon" aria-hidden="true"><svg>…</svg></div>
        <h3>Neutrals</h3>
        <p>Backgrounds, body text, hairlines.</p>
        <span class="more">Read the tokens &rarr;</span>
      </a>
    </div>
  </div>
</section>

The same section under these rules:

<section data-band="alt">
  <header>
    <p>Foundations</p>
    <h2>Tokens</h2>
    <p>Every colour in the system is a custom property.</p>
  </header>
  <ul data-list="cards" data-cols="3">
    <li>
      <a href="/tokens/">
        <svg aria-hidden="true">…</svg>
        <h3>Neutrals</h3>
        <p>Backgrounds, body text, hairlines.</p>
        <span>Read the tokens &rarr;</span>
      </a>
    </li>
  </ul>
</section>

Twelve classes and two wrapper divs become two attributes. The list is now a list.

What goes, and to what

The audit
v2 Replaced by
`.section`, `.section-tight` `main > section`, spacing from `data-band`
`.wrap` Gone. The section is a grid with a centred content track, so the wrapper div goes with it
`.section-head` `section > header`
`.tag` `header > p:first-child` (an eyebrow precedes the heading; a lead follows it)
`.lead` `header > :is(h1, h2) + p`
`.steps`, `.step` `ol`, `ol > li`. A sequence is an ordered list
`.ticks` `ul`, `ul > li`. The default list
`.checks` `ul[data-list="checks"]`
`.card-grid`, `.card`, `.card-link` `ul[data-list="cards"]`, `li`, `li > a`
`.card-icon`, `.more` `li > a > svg`, `li > a > span:last-child`
`.cols-2/3/4` `data-cols`
`.section-alt`, `.on-dark` `data-band="alt"`, `data-band="dark"`
`.hero`, `.proof`, `.cta-band` `data-band="dark"` plus their own structure
`.site-header`, `.site-footer`, `.site-nav` `body > header`, `body > footer`, `header > nav`
`.brand` `header > a:first-child`
`.footer-row`, `.footer-grid`, `.footer-legal`, `.footer-copy` Structure within `footer`
`.fine`, `.fineprint` ``
`.split`, `.split-panel` `[data-layout="split"]`, its child divs
`.feature-row`, `.copy`, `.media`, `.uneven`, `.flip` `[data-layout="features"]` with modifiers in the value
`.skip` `body > a:first-child`
`.theme-toggle` `footer button`
`.legal` The layout, which the renderer already knows
`.btn`, `.btn-amber` `[data-actions] > a`, first child is the primary ask

What stays a class

ghost, ink, and teal on an action link, because a secondary ask is a named choice with no structural signal. visually-hidden, which is a utility with no element. tld and badge, which are spans inside a larger thing and have nothing to be derived from.

That is roughly sixty classes down to under ten, and the ten left are the ones that name something real.

One thing the structural rules make stricter

[data-actions] > a:first-child being the primary ask means the design guide's "aim for one primary per view" stops being advice. A second amber button now requires a second actions group, which is visible in the markup and in review. Rules the stylesheet enforces cost nothing to follow.

Enforcing it

Three checks, all cheap:

  • Element-duplicating class. A class whose name matches the element it sits on fails. .section on <section>, .nav on <nav>, .footer on <footer>.
  • The allowlist, already specified: a class the CSS does not define fails. Together with the rule above, the vocabulary can only shrink or be deliberately extended.
  • A class budget per component. Each schema.yml declares how many classes its template may emit. The check counts. "Minimal" that nobody measures drifts within a release; a number in a file does not.

Semantic element preference is the fourth check, softer: a div where section, nav, figure, ul, ol, header, footer, aside, or small fits is reported. Some of it is judgment, so it warns rather than fails, and the list of accepted exceptions lives in the repo.

What v3 has to add, not only remove

The class rule is about deleting hooks. Two gaps run the other way, both found by the allowlist the moment a component needed them, and both system-level rather than one site's business.

What v3 has to add, not only remove
Missing from v2 Found by Why it is shared
`img { height: auto }` comparing a rebuilt page to the live one v2 sets `max-width: 100%` and never `height: auto`, so any image with width and height attributes distorts. The engine emits those attributes on every image to prevent layout shift, which makes this a correctness bug rather than a nicety. The highest priority item on this list
`.plain-summary` the `plain` block The WCAG 3.1.5 supplement. Any site with prose above lower secondary level needs one
Table styling of any kind the `table` block Any site with data has tables. v2 styles none, so every table is browser-default
Callout the `callout` block 9 of 17 surveyed systems ship one. The most common component this design system lacks
Quotation the `quote` block A blockquote with an attribution is general content
Definition list the `defs` block `dl` is unstyled in v2
Logo row the `logos` block Including the 44px target a logo link needs, which is what made this an accessibility fix rather than a styling one

These live in pending-v3.css, shipped by the engine and linked between base.css and a site's own sheet, so the rules exist once rather than being copied into every site. The file is the v3 backlog in executable form: when v3 lands it empties.

Under the class rule neither needs a new class in v3. A disclosure is details, and a scrolling table wrapper is figure:has(> table). Both are carried as classes only for as long as v2 is the target.

A margin worth knowing about

--fp-accent (#006060) clears AAA on --fp-bg-alt (#f8f9fb) by 0.014: the measured ratio is 7.0141:1 against a 7.0 requirement. The stylesheet's own comment rounds it to "7.0:1", which is true and hides how little room there is.

Any future change to --fp-bg-alt, however small, can break it. A ground 0.004 perceptual units away, which no one could see, puts the same accent at 6.96:1 and fails. This was found by the ingest theme deriver sampling a near-identical grey off a live page and repairing the accent to compensate, which is the deriver behaving correctly.

Two consequences. v3 should give the accent more headroom rather than sitting on the line. And the engine's token test should assert the measured ratio, not the rounded one in a comment, because a comment cannot fail a build.

Separately, the comment on --fp-ink reads "16.9:1 on --fp-bg". The measured value is 17.76:1. Harmless, but it is the same class of problem: a number maintained by hand next to a value maintained by code.

Status

This is a proposal for v3 of the shared stylesheet, not a description of what is deployed. v1 and v2 stay live and unchanged, per that repo's versioning policy, and no site moves without changing its own link.

v3 belongs in fivepaths-cdn and that repo needs permission before it is touched. It also cannot ship before the engine does, since its selectors assume generated markup. The two land together or not at all.

Next

Continue with accessibility.