198 elements off the spacing grid, and 17 font sizes where there should have been 6
A design system audit on a live page: how the contract was written, how the violations were measured rather than guessed at, and what actually changed.
The probe came back with 198 elements sitting on spacing values that do not exist in the design system, and 17 distinct font sizes on a page whose type scale has six steps. Nobody had noticed either. That is the point: neither of those is visible one element at a time, and both of them together are why a layout stops reading as deliberate.
This is what the audit found on the home page of this site, how it was found rather than guessed at, and what changed as a result.
First the contract, then the CSS
The previous version of the page had no written rules, and it looked like several designs stacked on top of each other because that is what it was: a 130px bento row here, a 200px collection card there, a fixed 276px tile holding two words. So the rebuild started with eleven rules in a comment at the top of the stylesheet, before a single declaration was written. The four that most of the audit hangs off:
1 SIZE IS EARNED. No card, tile or panel declares a height, a min-height or
a fixed grid row. `grid-auto-rows` is `auto`, always. If a block looks too
small, the fix is to give it something to say — never to pad it out.
2 RADIUS. 14px on a card or tile, 22px on a panel, pill on an action.
Three values, no fourth.
3 SPACE. 4 · 8 · 12 · 16 · 24 · 32 · 48. Nothing between them.
7 ICONS. 22px inside a card, 26px inside a panel. A glyph is a label, not
an illustration.
Plus one type scale of six steps, one section rhythm (56px desktop, 40px phone), one action height (40px), one grid ladder (5/4/3/2/1 columns at 1280 · 940 · 700 · 560), and colour from tokens only.
A contract that is not checked is a comment. The checking is the part worth describing.
Measuring instead of eyeballing
Two probes, both loaded into the browser on the live page rather than run against source.
The first walks every visible element inside <main> and records its computed font size, its padding and margin values, its corner radius, and its cursor. Then it groups them. You do not need clever analysis for this — a frequency count is enough, because the failure mode is always the same shape: one value used forty times and three near-identical neighbours used once each.
The second is the accessibility audit that already existed on the project. It returns, for the current page at the current width and colour mode: every text/background pair below its WCAG AA threshold, every interactive target under 24px, every link that goes nowhere, and any horizontal overflow with the elements causing it. The important detail in it is that the background is composited through translucent ancestors:
function effBg(el) {
var st = [], n = el;
while (n && n.nodeType === 1) {
var c = parse(getComputedStyle(n).backgroundColor);
if (c && c.a > 0) st.push(c);
n = n.parentElement;
}
var base = document.documentElement.getAttribute('data-theme') === 'light'
? { r: 244, g: 247, b: 254, a: 1 } : { r: 11, g: 18, b: 32, a: 1 };
var o = base;
for (var i = st.length - 1; i >= 0; i--) o = over(st[i], o);
return o;
}
On a design made of translucent panels, reading backgroundColor directly gives you an rgba() and a meaningless ratio. It also refuses to guess: an element painted with a gradient or an image is listed separately as unmeasured rather than passed silently, because reporting a white label on a blue gradient pill as 1.04:1 is confident nonsense, and confident nonsense is worse than a gap.
What the numbers were
Seventeen font sizes
13 · 13.4 · 13.6 · 14 · 14.4 · 15 · 15.2 · 16 · 16.8 · 17 ·
22 · 23.2 · 24 · 26 · 28.8 · 32.8 · 67.2
Five of those sit inside 1.4px of each other. Nobody can see the difference between 13px and 13.6px, and that is exactly the problem: they do not read as a hierarchy, they read as a mistake. The stray decimals are rem values — 0.84, 0.85, 0.9, 0.95, 1.05, 1.45 — each one chosen while building a separate component, each one then a value somebody has to match the next time they add another.
:root{
--fs-caption:0.8125rem; /* 13 — meta, kickers, counts */
--fs-label:0.875rem; /* 14 — card copy, links, list */
--fs-body:1rem; /* 16 — body, card titles */
--fs-lead:1.125rem; /* 18 — panel lede */
--fs-title:1.375rem; /* 22 — panel titles */
--fs-h2:clamp(1.5rem, 1.3rem + 0.9vw, 1.875rem); /* 24 → 30 — section heads */
}
Seventeen down to six, outside the hero, which is out of scope on this project and keeps its own sizes.
198 elements off the spacing grid
120 elements at 3px, 68 at 6px, 10 at 14px. The contract says 4 · 8 · 12 · 16 · 24 · 32 · 48 and nothing between. Every one of the 198 went to the nearest step, and the repair is one short block of declarations in the stylesheet — card gaps at 8px, the trust strip at 8px 16px, directory link padding at 4px.
None of these are individually visible. The reason to fix them anyway is that half-steps are what makes a grid stop being a grid: once a component sits at 6px, the next person has no way to tell whether 6 or 8 is the house value, and the drift compounds.
A strip 131px out of alignment
Every section on the page centres a 1240px column inside the 1502px page shell, so the left edge lands at 168. The trust strip under the hero was at 37 — flush left, in the first row below the fold.
.trust__row{ margin-inline:auto; }
That is the entire fix. .trust__row is the .wrap element itself, and it opened with margin:0 to kill the browser’s default list margin. That margin:0 also killed .wrap { margin-inline:auto }. Resetting one axis is all it needed, and it is the kind of thing that is invisible in code review and obvious the instant anything is measured.
Nineteen Unicode characters used as icons
The page was drawing its icons with text, from six different Unicode blocks:
Geometric Shapes ◇ ◈ ▦ ▢ ◉ ◎ ▤ thin, hairline outlines
Miscellaneous Sym. ⛉ ⛁ heavy, and absent from most fonts
Dingbats ✓ ✦ ✺ ❖ solid, much darker than the rest
Arrows ⤓ ↩ ⇄ ⇌
Technical ⌘ ⌂ ⌕ ↯
Three problems, in order of severity. They are not one set — different stroke weights, different optical sizes, different metaphors, a hairline diamond next to a solid star next to a filled cylinder. They are not reliable: ⛉, ⛁, ⌂, ⌕ and ⌘ are missing from the default UI font on most Windows and Android installs, so they fall back to a different font or to a tofu box, and the site was rendering a different icon set per operating system with no way to know it. And they are text, so they inherit the font stack, get measured by the contrast audit as if somebody were meant to read them, and cannot be given a consistent stroke weight because they do not have one.
They became one drawn set: 37 icons, 24×24 viewBox, 1.75 stroke, round caps and joins, currentColor, no fills. Inline rather than a sprite, because the count is small and an inline SVG cannot arrive after the paint. The home page went from 3 drawn icons to 38, and from 19 Unicode icon characters to 0.
The migration detail that saved the most time: a mapping function from old character to new name, so the glyphs already saved in the database — the twelve service records each carry one — kept working without a data migration.
Seven clickable cards with an arrow cursor
Cards that carry a stretched link or a <summary> are <li> and <summary> elements, so the pointer stayed an arrow. A card that responds to a click and does not say so is a card people do not click. One selector list, one declaration.
Two things that were checked and were not problems
Worth reporting, because “found nothing” is a result and skipping it is how audits become theatre.
Line length. My estimate by eye was around 85 characters, comfortably over the 62 the contract allows. Measured properly in ch units, no line on the page exceeded 76 characters, and the existing max-inline-size:62ch was already doing its job. Nothing changed.
Corner radius. The probe reported five radii, which would have been two more than the contract allows. Two of them were inside the hero, which is out of scope. Outside it: 14 on cards, 22 on panels, pill on controls. Three values, as written.
What it added up to
| Measure | Before | After |
|---|---|---|
| Card fill, across the department tiles | 18–53% | 65–77% |
| Department section height at 1440, with a description added to every tile | 754px | 517px |
Internal links inside <main> |
~40 | 207 |
| Distinct font sizes outside the hero | 17 | 6 |
| Elements off the spacing grid | 198 | 0 |
| Unicode characters used as icons | 19, from 6 blocks | 0 |
| Drawn SVG icons on the home page | 3 | 38 |
| Clickable cards with no pointer cursor | 7 | 0 |
| Footer height on a 390px screen | 1,255px | 679px |
Two of those deserve a sentence each.
The department section got shorter while gaining content: 754px to 517px, and every tile now carries a line of description it did not have before. The old tile was designed to hold a name, a product count and a glyph, and the shop has no products, so the count printed the word “Explore” — a 723×276 rectangle with two words in it. You cannot shrink that tile without starving the grid and you cannot make it honest without giving it something true to say. The description is the same sentence at zero listings and at ten thousand.
The footer went from 1,255px to 679px on a 390px screen — it had been a quarter of the whole document, spent on a sitemap the visitor had scrolled past everything to reach. Each column is now a <details> that ships open in the markup and is closed by script below 640px:
var mq = window.matchMedia('(max-width:640px)');
var sync = function () {
Array.prototype.forEach.call(cols, function (c) { c.open = !mq.matches; });
};
sync();
if (mq.addEventListener) mq.addEventListener('change', sync);
Shipping open means the no-JavaScript failure mode is “long footer”, not “no footer”. <details> brings keyboard operation and screen-reader disclosure semantics with it, which is why this is four lines instead of forty. And only the breakpoint crossing re-syncs: re-running on every resize would snap a column shut the moment somebody opened it and rotated the phone. The links stay in the DOM either way, so nothing about crawling or internal linking depends on whether a visitor expanded anything.
The audit found faults in its own fixes
Two of them, on the pass immediately after:
- The department heading link in the new directory section measured 63×21px. The 24px minimum had been applied to the child links and not to the group heads — and those ten are the largest targets in the section.
- A footer
<summary>on desktop measured 171×20px. A summary is focusable and clickable at every width, not only where it has been styled to look like a control.
Both were mine, both were introduced by the fix for something else, and neither would have been found by looking at the page. That is the argument for running the probe after every change rather than at the end.
It also reports two things that are not bugs, and knowing which is which is part of owning a tool: the contact form’s honeypot input, which is 1×1 and invisible on purpose, and the shop’s filter checkboxes, which are 20×20 but sit inside a 44px <label> that toggles them — the effective target is compliant, and the probe reads the input’s rect rather than the label’s.
Where this is weaker than it looks
The font-size and spacing counts are computed values at one viewport width, in one browser, in one colour mode. A clamp() resolves differently at another width, so “six sizes” is six at the widths that were measured; the scale is written to hold, but the count is not a proof.
The card-fill figure — 18–53% to 65–77% — is the softest number in the set. It comes from a probe that compared the area a card’s content occupies against the area of the card, and the worklog does not record the exact formula it used. The direction is unambiguous and the mechanism is documented (a 723×276 tile holding two words became a 224×153 tile holding four lines), but I would not put much weight on the second digit.
Everything here was measured in Chrome. Contrast, dead links, horizontal scroll and target sizes came back at zero across the page types that were audited, in both colour modes, at 375, 768 and 1440 — but not in Safari or Firefox, and not with a real screen reader. A manual pass with the keyboard is still outstanding, because synthetic Tab events did not reach the page in this environment and pretending otherwise would have been the easiest lie in the report.
Comments
No comments yet — be the first to share what you think.