Skip to content

First batch of accessibility improvements - #191

Merged
KubaO merged 75 commits into
twinbasic:mainfrom
KubaO:staging
Sep 20, 2026
Merged

KubaO merged 75 commits into
twinbasic:mainfrom
KubaO:staging

Conversation

@KubaO

@KubaO KubaO commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator
  • System/Light/Dark theme switcher (previously Light/Dark)
  • Fix light theme syntax highlighter font size; it was too small.
  • Add Axe accessibility scanning into the check/build flow and CI
  • Fix the "easy pickings" accessibility issues: aria- attributes, contrast, and Tab-navigability
  • For accessible use, move the section links into a collapsible section at the bottom of the page. That way each section doesn't require two Tab stops - the anchor, and the section itself.

The screen reader can be used on the site now to access all content, but the experience is not ideal yet.

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.
… 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.
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: ![d](x.svg)` 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.
@KubaO
KubaO merged commit 57523ba into twinbasic:main Sep 20, 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.

1 participant