First batch of accessibility improvements - #191
Merged
Merged
Conversation
Phase 1: Theme toggle span->button with aria-label, nav/menu expanders aria-pressed->aria-expanded, SVG controls a->button with keyboard zoom + focus trap, btn-reset/search/copy-code focus-visible indicators. Phase 2: Search combobox+listbox ARIA pattern, aria-live status announcements, th scope=col, admonition role=note/alert, contentinfo footer landmark, prefers-reduced-motion override. Phase 4: axe-core post-build check (scripts/check_a11y.mjs) integrated into check.bat.
check_a11y.mjs scanned _site/ over file://, where the root-absolute asset URLs never resolve -- every page was audited unstyled, so every contrast result was a meaningless black-on-white pass. It now scans _site-offline/ and covers both themes at two viewports. Light: site-footer, main-footer secondary text (theme-blind utility class replaced by .text-muted), admonition titles (the gem ships GitHub's dark palette on a white page). Dark: link colour (4.13:1 on every link on every page), .btn-reset keeping the UA's black buttontext on SVG diagram controls. Syntax tokens are clamped to 4.5:1 at emit time in highlight-theme.mjs so the vendored IDE .theme files stay faithful to the IDE. Also WCAG 2.1.1: code blocks scroll horizontally on narrow viewports, so div.highlight is now focusable with a visible focus ring.
Body text: $grey-dk-100 #5c5962 -> #54515a. One token covers body prose, code-block text, child nav links, the sidebar footer and footer meta -- 6.35/6.86:1 becomes 7.20/7.77:1. Dark body text was already 11.66:1. Links: light $purple-000 -> $purple-200 (5.03/4.66/4.30 -> 9.46/8.76/ 8.09:1); dark #58a6ff -> #8cc2ff (5.94 -> 8.06:1 on the body background). The old light purple did not even reach AA at the opaque end of the active-nav gradient. Also picks up a stray #7253ed in the light span rule that still coloured breadcrumbs. Secondary text: dark #959396 -> #b6b4b7 (4.92 -> 7.30:1). Hierarchy now comes from font-weight 350 -- a real face on all three system stacks -- rather than from a dimmer colour, in both themes. Unifies the dark $code-background-color to #212121. #31343f was lighter than the .language-tb override, so the token colours clamped against #212121 in 4de23ef failed on js/json/yaml/html blocks and on inline code inside links -- 17 real violations on Documentation/Development/Builder. Dark inline-code chips are now 1.072:1 against the page, matching the light theme's shipped 1.080:1. Syntax tokens stay clamped at 4.5:1; 7:1 would flatten the IDE palette.
…ection heading fix, breadcrumb separator
… guide Public developer docs (docs/Documentation/) updated for the WCAG/theme work since c9f2dfe: - New check_a11y.mjs documented; check.bat gains an accessibility stage (Tools, Building); axe-core dependency and the Chromium requirement for check.bat added (Builder, Building). - theme-switch.js -> theme-toggle.js rename corrected; 3-state toggle note added (Builder); stale jtd README reference fixed. - highlight-theme contrast clamp and headingLevelNormalizePlugin documented (Builder, Pipeline-Stages, Extending). - New Authoring.md contributor guide (frontmatter, heading levels, formatting, prose style, attribution, linking); linked from the section index and nav; WIP.md points to it as the public subset.
…own concurrency group
…cost is quadratic
… Color2 construction, not D2
The keyboard and screen-reader equivalent of the chain icons the previous commit hid. renderSectionLinks emits one <details> at the end of <main> listing every heading on the page; 707 of 869 pages carry it (the rest have fewer than two headings). A closed <details> subtree is notRendered, so it costs one tab stop and nothing at all in the accessibility tree, and expands on demand to the full set. On tB/Gloss.html, the worst case at 172 headings: 166 links exposed closed, 338 open -- against 172 permanent links-list entries and 172 tab stops before the pair of commits. Emitted from the template rather than the markdown render, which is what keeps it out of the PDF book and the search index without a marker or a downstream strip -- both read page.renderedContent, upstream of this. book.html holds zero occurrences and the index is unchanged at 3,595 entries. Two near-misses worth the comments they carry: a local min-height on the summary loses silently to the same-specificity, later `.main-content summary`, and `display: flex` on a <summary> makes Chrome drop the disclosure triangle.
Two dividers (the <hr> plus the section-links border-top), five stacked
<p>, and a 16px "Back to top" among 12px siblings -- 224px of page bottom
for four short lines. Now 109px, one <hr>, one type size.
The section-links disclosure moves from the end of <main> into the
footer, where it belongs with the other per-page utilities; being inside
the existing contentinfo landmark also makes it easier to find without
adding a landmark of its own. Back-to-top, the edit link and the
downloads become one wrapped flex row, copyright and licence another.
Size comes from the footer rather than a text-small utility on each
element, which is what let "Back to top" drift in the first place.
Three things that were not obvious:
- The combined stylesheet ends with a bare `span { color: #4e26af }`
from Jekyll's callouts output, emitted after custom.scss, so the new
legal spans came out link-purple until opted back into inheritance.
- `.main-content > hr` (0,1,1) loses to the dark compilation's
`html[data-theme=dark] hr` (0,1,2): the tightened margin applied in
light and silently did not in dark. `#main-content > hr` clears it.
- target-size. #back-to-top and #edit-this-page were scraping through on
the spacing exception with a safe diameter of exactly 24px against a
24px floor, on a measurement that moves with the resolved system-ui
font -- how the heading-link box passed locally and failed on CI. And
the section links, once the disclosure is open, failed outright at
69.6x14. Both now pass on size via a font-independent min-height.
That last one is invisible to check.bat: axe skips the closed disclosure
as notRendered, so the open state is unaudited. Verified here by hand
across 4 pages x open/closed x both themes at the mobile viewport.
"Download Offline Copy or PDF." sat 4px above "Back to top". Two causes, both from the styling caring which element an item is. min-height was on `.footer-actions > a`, so the anchors became 24px boxes with centred text while the plain-span items did not -- and flex's default `align-items: stretch` pulled those spans to the same 24px but left their text at the top of it. The selector is now `> *`: every item in the row is the same shape whatever element it is, which is the thing that could not drift. Measured spread across the row is 0. inline-block rather than inline-flex, because these items carry phrase content -- "Download <a>Offline Copy</a> or <a>PDF</a>." -- and a flex container turns each run of words into a flex item and eats the spaces between them. The switch away from inline-flex then exposed the same class of problem in the section list: an inline-block sits on the baseline and reserves descender space, which stretched the <li> to 31px and left the bullet 3.5px above its own link text. vertical-align: top on the anchor makes the two boxes exactly concentric. target-size still passes on size, not spacing, everywhere: re-audited 4 pages x open/closed x both themes at the mobile viewport.
Follow-up to 9dd8fdb, which made the styling element-agnostic but left the markup mixed. The mix was not actually forced: the actions row held a <span> only to carry the framing sentence of "Download <a>Offline Copy</a> or <a>PDF</a>.", and that lone odd element out was what let the baselines drift in the first place. Dropping the sentence makes every child of .footer-actions an <a>, and every child of .footer-legal a <span> -- verified across all 869 pages. The rule each row follows is now statable: actions are links, legal is prose that may contain a link. Two consequences worth noting: - The download links are relabelled "Download offline copy" / "Download PDF". Their old text was "Offline Copy" and "PDF", which only made sense with the sentence around them and read as bare nouns in a screen reader's links list. - The last-modified stamp moves to the legal row. It is not an action, and it was the other <span> in the actions row. No page carries last_modified_date today, so nothing renders differently. The CSS keeps selecting `> *` rather than `> a`: the invariant is real now, but the selector should not be what breaks if someone adds to the row later.
A closed <details> subtree is notRendered, so axe never walks it. That is the property that makes the disclosure cheap -- one tab stop, nothing in the links list -- and it is also why the construct went out on 707 pages with target-size violations on every link inside it (69.6x14 against a 24px floor) while check.bat reported a clean pass. The state a reader sees after one click was not audited at all. STATE_AUDITS layers entries onto the page x theme x viewport matrix that apply a DOM mutation from PAGE_STATES after navigation and before the audit. Four audits on top of 44, +7% of the sample's audit time. Demonstrated rather than assumed, because the fingerprint gate's documented blind spot is exactly this -- it cannot see a change in which DOM is walked: - the open audit walks 57 more nodes than the closed one on the same page; - with the fix reverted in-page, the open audit reports target-size x14 where the closed audit reports nothing; - the applier throws on a page with no disclosure, and on one whose disclosure has been emptied; - --baseline production --candidate production is 48/48 identical, so the new audits are deterministic. A state that silently no-ops would be a slower green scan covering nothing, so every PAGE_STATES function asserts it found what it expected and returns how much it exposed; check_a11y.mjs prints that count. Menu/Window hosts it rather than Pipeline-Stages: 14 items at 0.41s per audit against 72 at 1.28s, and the extra items buy no coverage. The defect class is per-link geometry, identical for every item -- spacing between consecutive items does not change with how many follow, and the long labels only wrap at mobile, which makes a target taller. Both catch the reverted fix at both viewports.
Twelve of the twenty substitution points in `plain-color-fields` passed no expected count, so `substitute()` asserted only "at least one". An axe-core bump that rebound Babel's duplicate private-field names (`_r`, `_r2`) to a different class would have rewritten that class consistently, left Color2's WeakMaps in place, passed check_axe_patch_equiv.mjs -- which only reads Color2 values -- and silently given back the 26 %. Measured against axe-core 4.13.0, identical for all six fields: one `_classPrivateFieldGet`, two `_classPrivateFieldSet`. Both pinned. Verified: check_axe_patch_equiv.mjs and check_a11y.mjs unchanged; a deliberately wrong count throws naming `plain-color-fields/set_r`.
SCHEMES.production described itself as "what check_a11y.mjs runs today" and
carried no patches, while check_a11y.mjs never called getScheme at all -- it
declared its own AXE_PATCHES, and sweep_a11y.mjs held a third copy. The
fingerprint gate's A/A control was therefore validating stock minified axe
against itself rather than the bundle that ships, and a `configure:` added to
the scheme would have reached nothing.
Adds DEFAULT_PATCHES to the registry, inherited by every scheme so a
config-lever comparison holds the bundle fixed. check_a11y.mjs and
sweep_a11y.mjs now take runOptions, configure and patches from
getScheme("production"); --stock-axe overrides the scheme's list rather than
being a second source of truth.
--patches keeps its meaning -- stock baseline, patched candidate -- because
that is the axe-upgrade obligation and would otherwise become a no-op.
Verified: A/A over the shipped bundle is 48/48 identical; check_a11y.mjs
reports the same 0 violations / 34 incomplete as before; --patches still
measures -28 % against stock, so it is comparing two different bundles.
The family regex was /class="[^"]*(?:note|important|warning)/, which matches class="footnote" and class="reversefootnote". The only page reaching min: 2 did so on three footnote hits and zero callouts, so --check printed "every construct family in use is covered" while markdown-alert-important (49 site-wide) and -tip (16) -- each with its own tint and title colour per theme -- had never been colour-contrast-audited anywhere. Keys on `class="markdown-alert markdown-alert-<variant>`, which is what render.mjs emits and matches nothing else, and splits the family per variant: a page carrying two NOTEs is not coverage of IMPORTANT. min: 1, because one real admonition exercises that variant's colours exactly as fifty would. --check then failed, as it should have all along. Closed with /Features/Language/Generics.html -- the only page in the site carrying both an IMPORTANT and a TIP, so one page load covers both gaps. Measured 485/454/398/399 ms over the four combinations, +1,736 ms (+10.8 %) on a 16,007 ms sample. --propose names two cheaper pages (1,591 ms); the 145 ms difference is 0.9 % of the scan and the single page keeps the matrix smaller. Also corrects the `table` and `th` why-strings, which named scope-attr-valid and empty-table-header. Both are best-practice-only and outside the scan's tag set, so a --check failure was telling a maintainer to feed a rule that is switched off. Verified: 12 pages x 2 themes x 2 viewports + 4 state audits, 0 violations.
entryKey keys on entry.target; settleFragments keyed on p.target, which is entry.resolved -- the same reference after the .html / index fallback. Both key sets merge in joinChunks, so one broken cross-page fragment counted once or twice depending on whether the target page happened to share a chunk with a referrer. brokenUnique is a count of distinct findings and must not depend on the chunk arrangement; build.bat printed "2 broken" where the standalone script printed "1". The pending record now carries `href` (the pre-resolution target) alongside `target` (the resolved one, which settleFragments still needs to look up the id set), and settleFragments builds its key through entryKey so there is one key format in one place. Verified with a three-page scratch fixture -- target.html with no #missing id, two referrers -- over all three chunk arrangements: 1/1/1 after, 1/1/2 before. check_links_diff.mjs --a script --b fused reports no difference.
checkSitemap and checkSearch exempted only book.html and 404.html, while sitemap.mjs skips `sitemap: false` and search.mjs skips `search_exclude: true` (and any page with no title). Any page using either key failed --check immediately, with no hint why -- and Pipeline-Stages.md documents `sitemap: false` as supported, so the first author to use it would have hit a build failure over a working page. The rule both checks enforce is "every page the generator was asked to emit", so the opt-out has to come from the generator. Each generator now exports its filter as a predicate (sitemapIncludes, searchIncludes), uses it itself, and linkJoin builds the rel sets from the same predicates and passes them through aux. Also fixes checkSearch's URL normalisation, which applied only stripHtmlSuffix: a permalink ending /index.html yielded '/probe/index' against deriveUrlPath's '/probe/'. Both checks now share normalizeUrlPath, which handles /index.html first. checkSitemap escaped this by accident because sitemap.mjs strips /index.html before emitting. The standalone script cannot do the opt-out half -- nothing in the built HTML records a deliberate omission -- and now says so where the flags are defined. Verified on a two-page probe tree: with both keys, 0 integrity findings; with the opt-out sets disabled, sitemap-missing and search-missing both return. With a /index.html permalink, 0 findings; with the normaliser reverted, two search-missing. Full build clean; check_links_diff --a script --b fused reports no difference.
sitemapIssues / searchIssues / canonicalIssues stay null when a precondition fails, and formatReport's `if (!issues || !issues.length) continue` prints `0 integrity` for null and [] alike. A check that quietly stopped running therefore read exactly like one that ran and passed. Only the harness's deliberate null-vs-[] distinction would have caught it, and that is a manual step. The standalone script has always printed a warning for the same situation. Each null now carries a reason, printed as a WARN line in the script's shape, and carried in --check-findings so a gate can assert a check ran rather than only that it found nothing. Separately, findingsFor.integrityFailed was integrityCount > 0 and omitted r.errors, unlike formatReport -- so --check-findings could report false for a run that exited 2. Corrected. Verified by provoking all five preconditions on a scratch tree: missing sitemap.xml, missing search-data.json, unparseable search-data.json, no canonical in any page, and an errored chunk. Each names the skip, and the chunk error now agrees across formatReport, findingsFor and the exit code. Full build clean; check_links_diff --a script --b fused reports no difference.
--check-audit-index was parsed and invoked by nothing: no .bat, no workflow, no npm script. It is the sole guard on the module builder/check-tree.mjs itself calls "the piece most able to fail silently", and the direction it covers lost its previous coverage when b97c75f removed CI's FsOracle pass. A spurious deriveTreeRels entry makes IndexOracle answer "exists" for a path that 404s in production, and every link to it passes. Now baked into build.bat and both CI build steps. Cost is one readdir per tree; the result already feeds integrityFailed. check.bat read whatever docs/_site-offline/ held. Edit, run check.bat without rebuilding, and it audited the previous build and passed. CI is unaffected because it builds in the same job; a dev box hits it whenever the two commands run out of order. scripts/check_tree_fresh.mjs compares the newest mtime under docs/ and builder/, less the output trees, against the built tree's index.html, and refuses with a message naming build.bat. Also installs fonts-liberation explicitly in both workflows. target-size measures rendered boxes and an inline element's height is its font's content area, so the 24px floor moves with whatever system-ui resolves to -- 15px for Liberation Sans against 19px for Segoe UI at the mobile h3 size. ubuntu-latest migrates to Ubuntu 26 on 19 October 2026; installing the font removes the variable before it moves on its own. Verified: a clean build prints "0 on disk but not indexed, 0 indexed but not on disk" for both trees; auditIndex reports the missing entry when one rel is withheld and the spurious entry when a ghost is added. The freshness gate refuses after a touch and passes after a build; both workflow files still parse.
IndexOracle.indexKey normalises separators and trailing slashes but never
case; FsOracle inherits NTFS's case-insensitivity, so
statSync("docs/_site/tb/gloss.html") is true for the file tB/Gloss.html. A
wrong-case link passed the script on Windows and 404s on GitHub Pages --
and because check_links_diff.mjs called the script "the oracle of record",
the harness would have reported the fused side, which is correct, as the
one with the extra finding.
--oracle now defaults to index on win32 and fs elsewhere, which needs no
per-path readdir. The platform difference is documented beside the flag,
because a release-zip check on Windows is exactly the case the standalone
script exists for.
The harness's two sides are pinned to their oracles explicitly rather than
inheriting the default, which would have made --a script --b index compare
nothing on Windows, and the "oracle of record" description is corrected --
on this one question the index side is the right one.
Verified on a two-file scratch tree with one wrong-case and one correct
link: --oracle fs reports 0 broken, --oracle index and the new default
report 1. The harness's fixture case and --self-test both still pass.
WIP.md and PLAN-sab-pull-scheduler.md say every merge-path skip that used to tolerate a missing piece now refuses to continue. Three survived: joinChunks's `if (!c) continue`, runChunkCheck's `items.filter(p => p.offlineHtml !== undefined)`, and flushJoin's `r?.written ?? 0`. None is live today -- the short-count guard and identical per-lane tree-key sets cover them -- but on this path "the piece is missing" is a bug, not a case to handle, and a tolerated one reports a pass over part of the site. joinChunks throws on a falsy chunk; linkJoin drops the optional chaining and names the chunk and tree itself, so the generic backstop is only reached if something else constructs the list; the offline filter and the flush-stats default become assertions. Verified by provoking each in turn against a real build: "link check: chunk 3 produced no result", "joinChunks(_site): a chunk produced no result", "flushJoin: flush:2 produced no write stats", and the offline one naming the page. None fires on a clean build -- 870 pages, 0 broken, 0 integrity, index audit clean on both trees.
Every green offline build printed "6 unresolved". HTML_COMBINED_RE's `\b(href|src)=` matched the tail of the `data-svg-src` attribute on the six inlined diagrams: a hyphen is a non-word character, so the word boundary succeeds there. All six files exist in _site-offline and zero root-absolute href / src survive -- the number was a regex artifact, and it was printed on every build for long enough that nobody read it. `(?<!-)\b` excludes a preceding hyphen. A colon is deliberately not excluded: xlink:href is a real URL attribute and the tree has 75,129 of them. The misses are now listed as well as counted, up to ten per file, by the file that produced them. A permanently-nonzero counter nobody can inspect is a counter nobody reads, which is how this survived. Verified: a clean build now reports 0 unresolved, with the forbidden-prefix rule on the offline tree still reporting nothing; a deliberately unresolvable link reports "1 unresolved" and names the URL and the page.
injectGanttChart rewrites BuildInfo.html in both trees after checkReport has already run, so the check reported on bytes that did not ship. Exposure is nil today because the SVG carries no links and no ids -- which is precisely the kind of thing that stops being true without anyone noticing. The injection cannot move before the check runs: it is rendered from that run's timings. So it moves before the check is REPORTED, and the patched pages go back through checkChunk against the same tree env. Only the delta against the pre-injection findings is printed, and it ORs into the exit code. Verified by injecting an SVG carrying a duplicate id, a remote <img> and a dead link: all three are reported, per tree, and the build exits 2. A clean build prints nothing extra.
def.submit ran outside every try/catch. _executeMainTask's try wrapped only execute(), and the worker-message path had no try at all and is called from worker-pool.mjs's listener. So the assertion in render:i.submit -- added by this range to make a missing page loud -- escaped as an uncaught exception: _abort never ran, the pool was never destroyed, and under --serve the dev server died with a raw Node stack instead of "task render:3 failed". The barrier invariant had two unbound halves: setDepCount(renderJoinIdx, N) and the expected-clone loop, 76 lines apart inside a 140-line dispatch.submit(). Nothing tied them together, verifySchedulerSAB iterates static taskDefs only and runs before dispatch.submit exists, and there are no tests -- so a third fan-out copying the dep-count block and missing the clone loop would reproduce the original renderJoin bug exactly. registerBarrier() now does both, and carries the reason. Also: writePdf.expected gains renderJoin (it reads renderedContent, which holds only transitively today through flush:i's lane pinning), and writeAssets.expected gains vendorAssets (listed for the same reason `dot` already is). Both are DONE by then, so neither costs anything. cpu-worker's post-before-SAB comment asserted the exact guarantee the renderJoin fix exists to deny. Rewritten to say what the ordering does and does not promise. Verified: with the race forced (SAB decrement moved ahead of the result post, 40ms delay) the build is clean with the pair list and fails on every one of three runs without it, naming 24-36 pages with no renderedContent. A throwing submit on the worker path reports "task render:2 failed" through _abort; on the main path, "task flushJoin failed".
The inventory row said `_sass/` was the gem's tree "byte-for-byte", and the re-vendoring procedure removed it wholesale. Following that procedure today silently reverts the AAA contrast work and the 1.39:1 .btn-reset fix. 30 files have diverged since the vendoring commit e7dd843. They are now grouped by what a re-vendor should do with each: 18 files of mechanical @use / deprecated-construct migration to re-derive, the deleted vendor syntax themes, and six files of accessibility patches that must be re-applied by hand, each named with its commit, the ratio it fixes and why. Two findings the plan for this work did not have: * c9f2dfe "WCAG 2.2 AA accessibility improvements" is a fourth accessibility commit, not a migration -- it restores suppressed focus outlines in buttons.scss, code.scss and search.scss. A re-vendor would have lost those too. * 1632d3d is not purely a theme deletion. color_schemes/dark.scss and light.scss each carried a @use pointing into the deleted directories, so procedure step 3 as written leaves two dangling @use statements and Dart Sass fails on a missing partial. Step 3 now says to remove both. Every ratio and value in the manifest re-checked against the current tree.
The aux nav's "twinBASIC Home" focus ring was clipped, exactly as #theme-toggle's was and for the same reason. Measured at 1280x900 in both themes: the nav's box is top 0 / bottom 59 and the link's is identical, so Chrome's UA `outline: auto` at +1px offset drew its top and bottom segments outside the scroll container's padding box. Replaced with an author ring drawn inside the border box, matching .btn-reset in width and colour. Verified: solid 2px at -2px, #4e26af light and #8cc2ff dark, no edge outside the clip box. `.section-links > ul`'s margin never applied -- not "reverts in dark", as the plan for this work had it. `.main-content ol, .main-content ul, ...` sets margin-top: 0.5em at the same (0,1,1) and later in the file, so the list has been taking the body-prose 6px in BOTH themes since it shipped. The remedy is the documented one anyway: `.main-content` in front for light, plus an explicit dark override, because the dark compilation re-emits the JTD rule at (0,2,2) and out-ranks (0,2,1). Now 4px in both. `.text-muted` has been dead since e045ab5 -- zero emissions in builder/, zero in the built tree -- and its comment described the old footer. Rule and dark twin deleted; confirmed gone from the compiled CSS. modules-dark.scss's header described an `html.dark-mode` wrapper that no longer exists anywhere in the build. Rewritten to describe the dark-theme mixin, and to say why it is what raises a JTD base rule to (0,1,2) -- the trap the other two fixes in this commit both walked into. build.bat and check.bat clean.
fetchToFile checked only res.ok and a non-empty body -- no content type, no magic bytes, no size floor -- and wrote straight to the final path, which present.has(name) then treats as fetched and done forever. A captive-portal HTML interstitial became yt-<id>.jpg with failed: 0 and a clean exit; the next run reported it present; CI-offline mode accepted it as satisfied. The same hole covered the documented YouTube trap, where maxresdefault 404s and hqdefault answers 200 with a 120x90 grey placeholder. fetchAttachment already gated on content type; the asymmetry was the bug. Now: content-type must be image/*, the body must clear a size floor and start with real JPEG or PNG magic bytes, a 120x90 SOF is rejected on the YouTube variants so the loop falls through to the next size, and the body is written to a temp path and renamed, so a rejected or partial one never occupies the final name. Separately, no fetch or arrayBuffer call in the module was wrapped. Only an HTTP non-2xx or an empty body took the soft path; a DNS failure, TLS error, reset or proxy refusal escaped vendorAssets.execute into scheduler.mjs and killed the build with a raw stack -- contradicting the module's own comment and WIP.md, both of which promise a warning. Every one now joins the failed++ / console.warn path. Verified against a stubbed fetch: an HTML body is rejected on content-type, and on magic bytes when served under a spoofed image/jpeg; a 120x90 placeholder falls through to the next variant and the real image lands; a fetch rejection and an arrayBuffer rejection each warn, count as failed, and do not throw. No file is left at the final path in any failing case. All 16 committed thumbnails re-parse as genuine 1280x720.
None of these changes a production result today. --theme and --viewport were unvalidated. `--theme drak` set data-theme="drak", which renders light, and labelled every line of the report `[drak, ...]` -- a full run of the light theme presented as a run of something else. Both are now checked against THEMES / VIEWPORTS, and an unrecognised argument is refused rather than ignored. --stock-axe switched the bundle to the minified build as well as dropping the patches, so the documented first-response diagnostic answered "the patch, or the minification?". It now changes only the patch list; a separate --minified asks the other question, and is refused without --stock-axe because the patches target the unminified source. A --pages narrowing dropped state audits silently, despite the load-time assertion beside SAMPLE_PAGES existing to prevent exactly that. It now names each one it drops and what stops being checked. gotoPage waited for domcontentloaded only, and no <img> in the tree carries dimensions, so the video card's target-size box depended on decode timing nothing waited for. It now awaits decode on any image that is not already complete. The A/A control was 48/48 before and is 52/52 after (the sample grew by a page), so this was a latent flake, not a live one. clampContrast returned a non-hex colour, or one it could not raise to 4.5:1, unchanged with no warning and nothing in the emitted CSS -- a below-threshold token would have shipped looking exactly like a passing one. Both cases now warn and are marked in the CSS. Verified by planting each in Light.theme: "rgb(200,200,0)" reports NOT CONTRAST-CHECKED and #ffff00 reports "1.01:1 ... BELOW 4.5:1". The real themes produce 19 `raised to 4.5:1` notes and zero of either marker. build.bat and check.bat clean.
check_links_diff.mjs dropped any case without a `fused` entry on every
--b fused run -- which is the `fixture` case, the only one that makes
more than one category non-empty. FIXTURE_EXPECTED was asserted only
inside the branch that was then never reached. So a regression in
builder/check.mjs that stopped REPORTING a category would have left every
gate green.
test/fixtures/check-src is a four-page markdown tree with its own minimal
_config.yml -- not docs/_config.yml, because a fixture that depends on
the real config breaks when the real config changes. Two cases read the
build's output: no single tree carries all nine categories, since the
online tree has the sitemap, search and canonical checks and the offline
tree is the only one with a forbidden prefix. Each asserts its counts on
both sides, including `null` for a check that is not enabled.
sitemap and search are asserted as 0, and that is the honest answer
rather than a gap: a correct build cannot omit a page from either index.
Both sides normalise the URL identically, and deriveSearchEntries pushes
a fallback entry for a page with no headings, so an indexable page always
gets one. Weird.md holds that claim to the awkward case -- a permalink
ending in the literal text `index.html`.
Three further things this commit fixes, all found by building the
fixture:
* checkReport did not depend on scss, so the index audit could run
before the stylesheet was written and report it as "indexed but not
on disk". On the real site scss finishes long first; on a three-page
build it does not, and --check-audit-index failed over nothing.
* check_links.mjs's selfTest() is exported and run by --self-test.
Its three regression guards sat inside the `isEntry` branch, and
since b97c75f nothing invokes the script as an entry point in CI --
they ran in no automated context at all.
* The fixture's `html` comment claimed one finding of each kind. Both
are `closed-early`: htmlparser2 force-closes any still-open ancestor
as soon as a later close tag matches further down the stack, and
both trees end with </body></html>, so `unclosed-tag` is
unreachable. Corrected, with the reason.
A bare invocation now refuses instead of printing "No differences across
6 case(s)" having compared nothing.
Verified: all 8 cases agree on --a script --b fused and --a script --b
index; deleting the dupIds and canonical reporting from
builder/check.mjs makes the harness fail with 6 differences, naming both
the count assertion and the diff. checks.yml gains the fused pair, per
Decision 1.
PAGE_STATES opened only details.section-links, so the content a reader sees after clicking a disclosure in the docs themselves was audited nowhere. content-details-open opens every non-section-links <details>, asserts it found one and that opening it revealed something, and reports what it exposed -- 222 elements on FAQ.html, and at the mobile viewport 118 colour-contrast nodes that nothing had looked at. The plan for this work expected the new state audit to be what catches a reverted target-size fix. It is not, and cannot be: measured with `.main-content summary`'s min-height reverted, FAQ reports target-size x16 CLOSED at mobile and ZERO open, because opening the disclosures pushes the summaries apart until the spacing allowance rescues them. The open state audits different surface, not more of the same. What that measurement did find is a bigger hole than the one it was testing: the site-wide <summary> min-height was guarded by nothing. FAQ.html stacks 30 disclosures against the next page's 4, and it is the only page in the site where the defect class is reachable at all -- and it was not in SAMPLE_PAGES. Reverting the fix produced a clean pass. FAQ.html is now in the sample (272/236/229/215 ms over the four combinations), and reverting the fix now fails the gate with 2 violations. pick_a11y_sample.mjs gains a `detailsStacked` family at min 8 so --check demands a page that stacks them; `details` at min 1 stays, for the disclosure semantics a single one exercises. The state-coverage line also stops labelling every count "link(s)" -- content-details-open counts elements. Verified: 13 pages x 2 themes x 2 viewports + 8 state audits, 0 violations; the applier throws both on a page with no content disclosure and on one whose disclosures reveal nothing; the A/A control is 60/60 identical; build.bat and check.bat clean.
All four have zero effect on the 1,159 pages the site builds today --
verified by hashing every one of them before and after -- which is the
argument for fixing them now rather than when a page first trips one.
The heading normaliser fired on "h1 present, h3 present, h2 absent" and
then raised EVERY level at or below h3 by one. `# / ### / #####` became
h1/h2/h4: the skip survived, one level over, and was now invisible in the
source. `# / ####` never triggered at all, and a raw <h2> HTML block was
not counted, so a page mixing one with `###` got its h3s promoted over
the author's structure. Each heading is now renumbered against a stack of
its still-open ancestors, which closes every gap rather than the first,
and a raw <h2> counts for the trigger check. Reference/Core/Open is the
one page in the site that uses anything beyond h1/h3, and it lands
h1/h2/h2/h3x6/h2 either way.
setClass replaced the whole class attribute, so
`{: .video .float-right }` rendered `class="video-link"` and dropped
`.float-right` without a word. It merges now -- and the `.video` marker,
which selected the link and matches nothing in the stylesheets, is
consumed explicitly rather than left in the output as a side effect of
merging. `{: .video .float-right }` gives `class="float-right
video-link"`, and the two Videos pages are unchanged.
The SVG paragraph unwrap hid the paragraph only for a lone image but the
renderer emitted the <div> wrapper on any matching src, so
`See this: ` produced a <div> inside a <p>. The core rule now
tags the image it actually unwrapped and the renderer checks that tag, so
the two cannot disagree; a mixed paragraph keeps its <p> and a plain
<img>. Splitting the paragraph around the image was the other option and
is a much larger change for a case no page uses.
remoteImagePlugin hooked only markdown-it's image rule, so a raw
<img src="https://github.com/user-attachments/..."> was downloaded by
vendor-assets -- whose scan explicitly covers both syntaxes -- and then
never rewritten, failing --check-remote-assets with nothing to say the
tag syntax was the reason. Raw <img> is now rewritten too, in both the
html_block and html_inline token paths. <iframe> is left alone, matching
the check's scope.
Verified: every page's HTML hashes identically before and after; the
seven svg-container and sixteen video-link emissions are unchanged; the
new shapes behave (h1/h3/h5 -> h1/h2/h3, two independent skips ->
h1/h2/h3/h2/h3, raw h2 respected, mixed SVG paragraph valid, raw <img>
rewritten standalone and mid-sentence, iframe untouched). build.bat and
check.bat clean.
Written last, against the code as it now is. Every number re-measured rather than carried over. Contributor docs. Authoring.md said literal en/em dashes are "rejected" by the build -- no such gate exists, the typographer passes a literal straight through, and 43 files under docs/ carry one today. It now says so and names the manual normaliser. Its two links attributing the remote-asset rule and dead-link catching to check.bat now point at build.bat, where both actually run. Building.md and Tools.md described check.bat as running two passes of scripts/check_links.mjs and gating the a11y scan on them. It runs neither: the link and integrity check is inside build.bat, and check.bat is a freshness gate plus three axe stages. Both rewritten. Tools.md's CLI table gains --check, --no-check, --check-audit-index and --check-findings, and entries for pick_a11y_sample.mjs and check_links_diff.mjs, which were missing from the catalogue entirely. Numbers. "six sample pages" (three places) and "eleven" are thirteen; "all 24 combinations" is 60 audits; "3,488 audits" is 3,476, in WIP.md and in axe-scan.mjs's own comment; the sample's cost is 18.7 s against 6.3 s for the original six, 2.96x, with Pipeline-Stages 26 % of it; the two trees are ~270 MB, not 230, in four files; the synthetic fixture is nine files, not six. "7,031 permalinks, 172 on Gloss" checked out exactly and is unchanged, except to say 867 of the 869 pages carry one. WIP.md. --check-remote-assets is not a tbdocs argument -- passing it throws; the build's check is unconditional and the flag belongs to the standalone script. Neither CI workflow invokes check_links.mjs directly any more; both reach it through check_links_diff.mjs, and only over fixtures. The section-links disclosure is at the top of the footer after </main> closes, not at the end of <main>, and 452 of 1,159 files omit it. "unclosed and badly nested tags" overstated the check: the unclosed shape is unreachable through the template. Builder.md gains a vendor-assets.mjs row and vendorAssets in the task list, names videoLinkPlugin / remoteImagePlugin / headingLevelNormalizePlugin / renderSectionLinks and injectAnchorHeadings(html, headingsOut), and shows axe-core's exact pin rather than a caret -- with the reason, since the patch is why. PLAN-sab-pull-scheduler.md's formatReport row claimed an errored chunk fails the run; TREES.pdf.noFail makes that false for the book, which is deliberate. The two sections spliced between steps 4 and 5 of its dispatch procedure are moved after it ends, so the procedure reads 1-5. PLAN-a11y.md and PLAN-axe-perf.md are phase logs, so their figures are marked as the matrix of their time rather than rewritten. Three rows the plan for this work listed need no edit, because the code was changed instead: the exact-occurrence-count claim, the aux-nav focus ring, and the merge-path tolerances. The aux-nav paragraph did need rewriting the other way -- it asserted the UA ring "renders in full", and it does not. perf/README.md's ab-css row named rouge.css and drop-rouge; the Shiki migration replaced both with tb-highlight.css and drop-highlight. MonacoArchitecture.md needed nothing: its comment already describes the dark export and the hand-darkened chips accurately, which the committed SVG confirms. build.bat and check.bat clean.
The plan was written forward; this turns it into a record without discarding the reasoning. Every section keeps its original text and each commit heading now carries the hash that implemented it, so a reader can go from a finding to the code. Eleven sections gain a `Landed` note. Five of those record the plan being wrong: the .section-links margin never applied in either theme rather than reverting in dark; the new open-disclosure audit cannot catch a reverted target-size fix because opening the disclosures is what removes it; the vendored _sass patches are four commits, not three, and the re-vendoring step as written breaks the Sass build; a build fixture can provoke seven of the nine categories, not nine; and setClass dropped both classes, with the obvious merge then leaking the .video build marker. The other six record a commit landing wider than planned. Two new sections at the end collect the corrections and the two defects the review did not have -- checkReport not depending on scss, and --pages narrowing dropping state audits. The decisions section records what was decided. Four went as recommended; Decision 4's preference for a state audit over a new sample page was reversed by the measurement it asked for. Coverage tables carry hashes, and three rows are corrected: the Monaco Mermaid comment needed no edit after all, the aux-nav row needed the opposite of no edit, and two items not in the review are listed. All 35 cited hashes verified to resolve.
The review is the record of what was observed at 1b6922b, so none of its observations are rewritten. Every line added afterwards begins with an arrow, and the banner says so, which keeps the two legible apart. All 21 numbered findings, both untiered lists and the five decisions now carry a pointer. Where the fix matched the suggestion the pointer is one line; where it did not, it says what was done instead and why. Five pointers record the review's diagnosis being wrong about the mechanism -- the finding was real every time, the cause was not: * 14, the section-links margin, does not revert in dark mode; it never applied in either theme, because .main-content ul wins at equal specificity. The suggested remedy is right anyway. * 4, a build fixture reaches seven of the nine categories; sitemap and search are not provokable from a correct build at all. * 19, the vendored _sass patches are six files across four commits, and the re-vendoring step as written breaks the Sass build. * Tier 3's setClass dropped both classes, and the obvious merge then leaks the .video build marker. * Decision 4's open-disclosure audit cannot guard a target-size fix -- opening the disclosures is what removes the defect. The real gap was FAQ.html not being sampled, which left the site-wide summary min-height guarded by nothing. Three of the Tier 4 figures had themselves gone stale between the review and the batch that fixed them, and the "~280 MB" row measures high -- 267.5 MB by byte sum. Both noted rather than silently corrected. All 30 cited hashes verified to resolve.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
aria-attributes, contrast, and Tab-navigabilityThe screen reader can be used on the site now to access all content, but the experience is not ideal yet.