Skip to content

Blog SVGs: raise six muted-grey text classes to the WCAG AA floor for their rendered size - #188

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-166-muted-grey-contrast
Sep 3, 2026
Merged

hotlong merged 3 commits into
mainfrom
claude/issue-166-muted-grey-contrast

Conversation

@hotlong

@hotlong hotlong commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #166

Nineteen text nodes across five blog SVGs rendered below WCAG 3:1 on light grounds. Six class fills, five files, six lines changed. No hue, font size, geometry, ground colour or wording changed anywhere.

These are not the #140/#167 family. Every one is a class that declares its own muted grey and gets it — nothing in the cascade is eating anything. They were recorded rather than swept because re-picking them is a palette decision. The card was taken up on the seat's ruling that .none at 1.70:1 does not read as intentional de-emphasis, it reads as a rendering fault.

The rule applied

Keep the hue, change the lightness, by the smallest step that clears the AA floor for the rendered size.

  • Text rendered under 18px (or under 14px bold): 4.5:1 against its measured ground. Text at or above: 3:1.
  • Size is read per node from getComputedStyle().fontSize at native viewBox scale, not inferred from the class name. It mattered: .axis is 15px at weight 640 and .footdim is 16px, so both take the 4.5 floor rather than the 3.0 one a "large label" reading would have given them.
  • The step is searched in HSL with hue and saturation held exactly, lightness moving down, and each candidate quantised to 8-bit sRGB before its contrast is scored — so the step reported is the smallest one available in the output space, not in a float space. Hue drift across all six is 0.00–2.31 degrees, entirely 8-bit rounding; saturation moves by at most 0.0032.
  • The floor must be cleared on the worst ground the text meets — the darkest of several grounds where a class serves more than one node, and the worst point of a single node's own ground where that ground is not uniform. .cap binds on #fffefa, not on the #ffffff six of its seven nodes sit on. .footdim binds on the dark end of the gradient it crosses, not on the middle of it.

Before / after — every node changed

Ratios are against the worst ground pixel under each node's own glyph ink (see How it was measured).

File class size fill before fill after ground ratio before ratio after nodes
enterprise-ontology-platform-comparison/cover-en.svg .footdim 16px w400 #8494b0 #5d6f90 #eff3f8 2.754 4.550 1
enterprise-ontology-platform-comparison/read-write-line-en.svg .axis 15px w640 #8494b0 #5e7293 #f3f6fb 2.833 4.504 2
forward-deployed-engineer-vs-solutions-architect/cover-en.svg .eyebrow 12px w700 #8a9a93 #6a7a73 #ffffff 2.948 4.522 3
ontology-vs-semantic-layer-vs-knowledge-graph/cover-en.svg .cap 12px w700 #8aa096 #647b71 #ffffff 2.782 4.552 6
ontology-vs-semantic-layer-vs-knowledge-graph/cover-en.svg .cap 12px w700 #8aa096 #647b71 #fffefa 2.756 4.510 1
ontology-vs-semantic-layer-vs-knowledge-graph/cover-en.svg .muted 14px w400 #a2b3ab #657b71 #ffffff 2.194 4.541 2
ontology-vs-semantic-layer-vs-knowledge-graph/decision-table-en.svg .none 15px w400 #b3c0b9 #60746a #fdf1e8 1.696 4.502 4

Nineteen nodes, every one clearing its floor on the worst point of its own ground, none by more than 0.06 — the margins run from 0.002 (.none) to 0.052 (.cap).

.footdim is scored twice, and the darker reading is the one it was solved against. Its ground is the only non-uniform one in the set: a 1200-wide background ramping #f3f6fb to #eef2f8, under a card shadow that has almost faded by the footer. Under the node's own ink the worst pixel is #eff3f8 at native size and #eef2f8 — the gradient's own dark stop — at 0.667x and 2x, both of which the site renders. The smallest hue-and-saturation-preserving 8-bit step from the authored #8494b0 that clears 4.5:1 against #eef2f8 is #5d6f90, and that is the value here: 4.513 on #eef2f8, 4.550 on #eff3f8, 4.590 on the old median ground. Hue drifts 0.64 degrees, saturation 0.2178 to 0.2152.

Three notes on what the diff deliberately does not touch:

  • #8494b0 survives twice in the same two files. In cover-en.svg it is also .no (17px bold, read-only), which sits on the dark #1c3258 row at 4.157:1 against a 3.0 floor and passes; in read-write-line-en.svg it is also the arrowhead path in the arrow marker, which is not text. Only the two failing class declarations moved, so .footdim is now slightly darker than .no and the axis labels slightly darker than the arrow they sit on.
  • .cap and .muted land one 8-bit step apart (#647b71 / #657b71), which is not a typo. .muted started lighter than .cap but sits only on #ffffff, while .cap must also clear the darker #fffefa; the floor is the same, so the two converge. Their roles stay distinguished by size, weight, case and letter-spacing.
  • The three .cap nodes on the dark ontology card are untouched. They carry an inline style="fill:#9dc4b6" that already wins the cascade and reads at 5.748:1. The measurement confirms it: they appear in neither the recolour list nor the drift list.

No collateral change — measured, not asserted

The full sweep was re-run at b2f772f and compared node-by-node against origin/main at 435ff61, keyed on file plus DOM position plus text content:

nodes compared: 2003 | unmatched keys: 0
INTENDED recolours (computed fill differs): 19
UNINTENDED colour drift: 0
position drift (glyph box moved): 0
font size/weight drift: 0
untouched nodes verified byte-identical: 1984
ALL 19 RECOLOURED NODES CLEAR THEIR FLOOR ON THE WORST GROUND.
  margin over floor: min 0.002 max 0.052

The comparison checks computed fill, sampled ground, contrast ratio, glyph box and font size/weight on every node. Drift count 0 on all of them; the 1,984 nodes this PR does not recolour come back identical. The sampled grounds are unchanged too, which is the expected result of a text-fill-only diff and worth having as evidence rather than as an assumption.

The corpus is 2,003 nodes rather than the 1,944 of the previous round because main moved under the branch: #182, #186 and #187 landed, and one of them brought a new post with two SVGs. None of the three touches these five files, and the merge was clean.

Corpus-wide, on the same statistic used throughout this PR, nodes below 3:1 go from 28 to 9. Of the nine remaining, two are the accent-gold pair filed as #181 and seven are the text-over-graphic collisions filed as #193 — a class no fill value fixes, and which is invisible to the median statistic this PR replaces. On the old median statistic, for comparison with the card, the count goes from 21 to 2, which is exactly the population #166 recorded.

How it was measured

text{…} cascades cannot be read off the source, so measurement runs in the shipping renderer, the method #167 established. Chromium loads each asset at its native viewBox size; for every text/tspan leaf it reads getComputedStyle().fill, the computed font size and weight, and the glyph box. The ground is sampled from a pixel rather than walked out of the DOM: each asset is rasterised a second time with text{visibility:hidden} injected as an SVG style element on the root. (addStyleTag cannot be used — an SVG document has no head element.) The hide is verified per file: the raster is rejected if any text node still computes to visible.

The ground statistic is the worst point, not the middle one. The previous round took the median by luminance of nine samples across the glyph box. That is the right statistic for a flat ground and the wrong one for a gradient: WCAG is satisfied at the worst point a string crosses, not at the midpoint of the range it crosses. The ground is now the sample with the lowest contrast against that node's own fill — the darkest pixel on a light ground, the lightest on a dark one — taken:

  1. over every ground pixel under the node's glyph ink, not nine sample points. The ink mask is the per-pixel difference between the text-visible and text-hidden rasters, restricted to the node's box and matched to the node's own fill colour. Both restrictions are load-bearing: a glyph bounding box is not glyph ink, and taking the minimum over the whole box turns every box that overhangs an adjacent card or badge into a phantom failure — measured, ten of them corpus-wide, including a dark-on-white label scoring 1.01 because its box clipped the corner of a dark card. Matching the mask to the node's own fill drops three more, where the box overlapped a neighbouring text node's glyphs.
  2. at raster scales 1x, 0.667x and 2x. A gradient's realised darkest value under a given box depends on where that box lands on the pixel grid. Under .footdim it is #eff3f8 at native size and #eef2f8 at the other two — a 0.037 spread in the resulting ratio, straddling the floor.

The old median is still recorded on every node, so the two statistics can be compared directly rather than argued about; every number in this PR that is labelled "median" comes from that column.

Renders — looked at

All five touched assets were re-rendered at native size in the same engine and looked at, before and after, one by one:

  • decision-table-en.svg — the four — none — cells were washing out into the #fdf1e8 verb-gap band; they now read, and are still the faintest thing in the table (4.50 against .idea at 7.09 and the mono cells at 11.8), so the "deliberately absent" reading survives.
  • ontology-…/cover-en.svg — UNIT / ANSWERS / PRIMITIVES on the two light cards now match the weight of their counterparts on the dark card, which is how the design reads them. no actions · no permissions is legible and still visibly secondary to the metric · dimension · join line above it.
  • forward-deployed-engineer-…/cover-en.svg — the three eyebrows now read like UNTIL IT RUNS on the dark card, and still sit below the role names in prominence.
  • enterprise-ontology-platform-comparison/cover-en.svg — the two-tier footer survives the extra step. The sourcing footnote at #5d6f90 reads 4.550:1 on its worst ground; the .foot line directly above it, untouched at #566480, reads 4.936:1 on its own worst ground (#e7eaf0 — darker than the footnote's, because the card's drop shadow has not finished fading at that line). The footnote is still the lighter of the two by relative luminance, 0.1571 against 0.1265, and the two crops at 2x are visually indistinguishable from the previous round's. Nothing about the tier reading changed; one 8-bit step is not a perceptible move.
  • read-write-line-en.svg — DESCRIBE and ACT read as axis labels, still lighter than the heading.

Nothing became body text; every darkened label still reads as de-emphasised.

Gates

Run through the shared verification lock at head b2f772f (git rev-parse --short HEAD), each exit code captured by redirecting to a file before any pipe, with git status --porcelain empty afterwards.

Gate Exit Verdict line it printed at b2f772f
pnpm content:lint 0 ✓ content lint passed (335 files, 44 glossary terms checked)
pnpm content:lint --published 0 ✓ content lint passed (335 files, 44 glossary terms checked)
pnpm check 0 Result (135 files): · - 0 errors · - 0 warnings · - 0 hints
pnpm build 0 [build] 867 page(s) built in 36.98s · [build] Complete!
pnpm seo:smoke 0 SEO smoke test passed (866 HTML pages checked)

Lock verdict: VERDICT command-exit 0 · held the lock 50s · waited 0s. pnpm build runs gen:zh-hant first — ✓ zh-Hant: generated 46, kept 0 hand-maintained, 5 with body links pointed at /zh-Hant/ — and the tree was clean afterwards, so no index.zh-Hant.mdx drift rides along. No changeset — this repo has none.

Notes on the card's premises

  • The card's 19 reproduce exactly. My sweep found the same 19 nodes with the same fills, grounds and ratios — .footdim 1, .axis 2, .eyebrow 3, .cap 7, .muted 2, .none 4. Blog SVGs: give dark-card text an explicit fill so it stops rendering dark-on-dark #167 landing in between moved none of them, which is the expected result given it only touched dark-ground nodes.
  • "Any Chinese sibling carrying the same class" resolves to none. Three of the four named directories are English-only — enterprise-ontology-platform-comparison, forward-deployed-engineer-vs-solutions-architect and ontology-vs-semantic-layer-vs-knowledge-graph contain no non--en SVG at all. The classes involved do appear in other posts (.cap in eight other files, .accent in two), and every one of those instances was measured and passes.
  • The card's suggested value was close. It guessed #8aa096 → "roughly #6b8177" for 4.5:1 on white; the measured smallest step is #647b71. The guess lands at 4.19:1 — under the floor, which is why the ruling's instruction to re-measure rather than trust it was the right call.
  • My sweep found one pair the card did not, both below 3:1 on a light ground: the 40px accent-gold ? on enterprise-ontology-race-open-vs-closed/cover{,-en}.svg, at 1.79:1. It is not fixed here and it is not a muted grey — it is the composition's emphasis mark, and .accent also fills five padlock rect shapes, so recolouring the class would change non-text geometry while an inline override would split the accent into two golds 28px apart inside one composition. Both routes are palette decisions of exactly the kind the ruling fenced off, so it is filed as The accent-gold ? on enterprise-ontology-race-open-vs-closed/cover{,-en}.svg reads at 1.79:1 — but the mechanical darkening rule cannot be applied to it #181 with the measurement and the three real options.
  • The two files Blog SVGs: a .mono class on a group element never reaches its text — code blocks render in the sans-serif house face #165 owns were flagged by neither pass. ai-ontology-open-protocol/one-definition-four-surfaces{,-en}.svg and objectos-automation-engine/run-record-en.svg have no node below 3:1 and none below its AA floor, so there was nothing to report or defer on them.

Out of scope, filed

All filed unassigned. Triage labels are the PM's to place.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr

Generated by Claude Code

Nineteen text nodes across five blog SVGs rendered below WCAG 3:1 on light
grounds. Each is a class that declares its own muted grey and gets it, so
nothing in the cascade was eating anything -- but `.none` at 1.70:1 reads as
a rendering fault rather than as intentional de-emphasis.

Keeps the hue, changes only the lightness, by the smallest 8-bit step that
clears the AA floor for the node's rendered size (4.5:1 under 18px, or under
14px bold; 3:1 at or above). Six class fills, five files, six lines. No hue,
font size, geometry, ground colour or wording changed anywhere.

Ratios re-measured in Chromium at native viewBox size, ground sampled from a
text-hidden raster of the same asset. Colour, ground, position and font drift
across the other 1,925 measured nodes in the corpus: 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr

hotlong commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

PATCH ROUND — one value. Everything else in this PR is accepted; do not touch it.

The gates are green (union I at 43ed2fd, all five exits 0, tree clean) and the method here is the best version of it this repo has had — measuring in the shipping renderer, rasterising a second time with text{visibility:hidden} to sample the ground rather than walking it out of the DOM, and verifying the hide per file. The 1,944-node drift comparison with 0 unintended recolours is exactly the evidence a palette change should carry. The three "deliberately not touched" notes are all correct, including leaving the inline-fill .cap trio on the dark card alone.

The defect is in one word of the method: median.

The ground under a node is taken as the median of nine samples across its glyph box. For a node on a flat ground that is the right statistic. For a node on a gradient, the median is the middle of the range the text crosses — and WCAG is not satisfied by the middle, it is satisfied by the worst point.

.footdim is the one node in this set that spans a gradient. It is a single 16px line starting at x=86 in a 1200-wide composition whose background runs #eef2f8 → #f3f6fb, so it sits over the dark end of that ramp.

Seat measurement, same engine, same visibility:hidden ground raster, but sampling at five points across the glyph box and scoring the worst rather than the median:

node fill ground (median, yours) ratio ground (worst, seat) ratio
.footdim #5e7092 #f0f4f9 4.518 #eff3f9 4.48 — under the floor

At the string's leftmost glyphs it is 4.48:1, and against the gradient's actual #eef2f8 stop it computes to 4.43:1. So the PR's own claim — "Nineteen nodes, every one clearing its floor" — is true of eighteen.

The other 21 measurements are unaffected, because every other touched node sits on a flat ground; re-scored worst-case they come back 4.50–4.55 exactly as you have them, and the dark-card .cap trio stays at 5.75. Only this one line moves.

What to do:

  1. Change the ground statistic from median to worst (darkest) sample across the glyph box wherever the ground is non-uniform — that is the rule this PR already states in prose ("the step must clear the floor on the darkest of them" for multi-ground classes); it just needs to apply within a node as well as across nodes.
  2. Re-run the search for .footdim under that rule and take the smallest 8-bit step, hue and saturation held, that clears 4.5:1 against the darkest ground the string actually crosses — #eef2f8, not the midpoint. (Do not hand-pick from this comment; re-measure. For orientation only: a step or two further down lands comfortably clear.)
  3. Re-run the 1,944-node drift comparison so the "0 unintended" line still stands at the new head, and confirm the other 18 nodes are unmoved.
  4. Re-render enterprise-ontology-platform-comparison/cover-en.svg at native size and look at it: the sourcing footnote must stay visibly lighter than the .foot line directly above it. The two-tier footer is the design; a footnote that reads as body text would be a different defect. Say in the report what the two ratios are.
  5. Re-run the five gates through the shared lock and quote each verdict line with git rev-parse --short HEAD.

Update the PR body's table and the "none by more than 0.06" line to match what you actually measure.

Note for the report: this is worth a sentence in its own right, because it generalises. Every ground-sampling check this repo grows from here — #181's accent gold, and #183 if it is ever taken up — will meet gradient grounds, and "median ground" will keep passing things by a hair. Nineteen nodes was small enough that the difference showed up as 0.04; over 217 nodes it would not have been noticed at all.

Serial constraints unchanged: #188 still owns these five SVGs alone. #182, #186 and #187 have landed on main since this branch was cut (82edbd2, e93195b, 435ff61) — none of them touches your files, so rebase or merge main as you prefer, but expect no conflicts.


Generated by Claude Code

… ground

The ground under a text node was taken as the median of nine samples across
its glyph box. That is the right statistic for a flat ground and the wrong
one for a gradient: WCAG is satisfied at the worst point a string crosses,
not at the middle of the range.

.footdim is the one node in this set on a gradient — a single 16px line in a
1200-wide composition whose background ramps #f3f6fb to #eef2f8. Median
ground #f0f4f9 scored 4.518; the worst ground under its own glyph ink is
#eff3f8 (4.478) at native size and #eef2f8 (4.441) at 0.667x and 2x, so the
line was under the 4.5 floor at every size the site actually renders it.

Re-running the smallest-step search from the authored #8494b0 — hue and
saturation held exactly, each candidate quantised to 8-bit sRGB before
scoring, against #eef2f8 — gives #5d6f90: 4.513 on #eef2f8, 4.550 on
#eff3f8, 4.590 on the old median ground. Hue drifts 0.64 degrees, saturation
0.2178 to 0.2152, both 8-bit rounding.

The other 21 touched occurrences sit on uniform grounds and are unmoved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr

hotlong commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

ACCEPT — the patch round did what it was sent back for, re-measured and looked at.

.footdim moved #5e7092 → #5d6f90. Re-measured by the seat with the corrected statistic — glyphs hidden, ground sampled across the whole run, worst point scored rather than the median:

fill worst ground worst ratio
before the patch round #5e7092 #eff3f9 4.48 ❌
after #5d6f90 #eff3f9 4.55 ✅

All 22 occurrences now clear AA at their worst point. The other 21 are unmoved at 4.50–4.55 and the inline-fill .cap trio on the dark card is still 5.75 — so the fix is the one line it should have been.

The thing that could have gone wrong didn't. The comparison cover carries a deliberate two-tier footer, and darkening the lower tier risked flattening it. Measured:

fill worst ratio
.foot — "The read–write line is the row that eliminates two of the five." #566480 5.07
.footdim — "Every cell sourced to the vendor's own docs or announcement." #5d6f90 4.55

The hierarchy survives, and it survives visually too. All five assets rendered at native size and looked at: the sourcing footnote still reads as secondary to the line above it; the four — none — cells are still the faintest thing in the decision table; no actions · no permissions is still visibly below metric · dimension · join; the three eyebrows still sit under their role names. Nothing became body text.

Union with current main (5d4b0b6 + this branch), all five gates through the shared lock: content:lint 0, content:lint --published 0, check 0, build 0 ([build] 867 page(s) built in 35.68s), seo:smoke 0 (SEO smoke test passed (866 HTML pages checked)), git status --porcelain 0 lines. PR check run green.

A note on reviewing this branch, for the record. A two-dot git diff origin/main against this head shows scripts/content-lint.mjs with 112 changed lines, which would read as a serious file-surface violation. It is an artifact: the branch was cut before #190 landed, so the two-dot diff renders #190's commit as a reversal on the branch side. Three-dot (origin/main...HEAD) gives the branch's own changes — 5 files, 6 insertions, 6 deletions — and the union merge carries only those six colour changes. Worth stating because the next reviewer of a branch that predates main's movement will see the same thing.

On the generalisation you were asked to draw. Median-ground was the right statistic for a flat ground and it passed 18 of 19 nodes; the one it missed, it missed by 0.04. Over the 217 nodes #183 would touch, that error would not have been visible at all. The corrected harness is what makes #181 and #183 safe to measure later, and that is worth more than the one hex value this round changed.

Landing on main.


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 3, 2026 02:54
@hotlong
hotlong merged commit c2b3171 into main Sep 3, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

19 muted-grey text nodes in blog SVGs sit below WCAG 3:1 on light grounds — a palette decision, not a cascade bug

2 participants