diff --git a/.gitattributes b/.gitattributes index 3a5d9bf5a..458c6be88 100644 --- a/.gitattributes +++ b/.gitattributes @@ -17,6 +17,10 @@ # cannot be run. wisdom/extract/workflow.mjs text eol=lf +# The help add-in's resources are checked out byte for byte: every build compares its +# copy of the symbol index with the LF file it writes (builder/addin-index.mjs). +add-in/Resources/** -text + # A bug's reproducer is checked out byte for byte: its .twinproj is packed # from its source tree, and the two must agree in every checkout. bugs/** -text diff --git a/.github/actions/run-gates/action.yml b/.github/actions/run-gates/action.yml index 92e15dfc8..dcd2021f1 100644 --- a/.github/actions/run-gates/action.yml +++ b/.github/actions/run-gates/action.yml @@ -96,6 +96,13 @@ runs: - name: Unit-test check_examples' batches (test/example-batches.test.mjs) shell: bash run: node --test test/example-batches.test.mjs + # Unit tests for scripts/lib/tb-ports.mjs, which claims the DevTools ports + # the harness's IDEs use: two claims at once are disjoint, a port in use + # and a live lock are passed over, a stale lock is taken over. Starts no + # IDE. No browser, no built tree, about 2 s. + - name: Unit-test the harness's port claims (test/ports.test.mjs) + shell: bash + run: node --test test/ports.test.mjs # A regex that backtracks exponentially does not fail a build, it stops # one: the corpus passes until a page happens to contain the trigger, and # then a render worker sits inside String.replace forever. Nothing else diff --git a/BOOKPLAN.md b/BOOKPLAN.md index 490dde92f..1a1d5a6b1 100644 --- a/BOOKPLAN.md +++ b/BOOKPLAN.md @@ -18,7 +18,7 @@ > > One entry under [Rendering gotchas](#build-environment) has stopped applying and is the one most likely to be acted on: **"PowerShell cannot invoke `npx` directly" no longer affects `book.bat`.** That batch file calls `node`, not `npx`, and a `.bat` is interpreted by `cmd.exe` whichever shell launches it — so no `.ps1` shim is reached and the execution policy is never consulted. The paged.js and CSS Paged Media gotchas above it still hold; the forked bundle lives at `book/lib/paged.browser.js`. > -> **Current documentation.** The two-stage pipeline, the renderer's flags and phases, and how to triage a failed render are on the site: [PDF Generation](docs/Documentation/PDF-Generation.md) and [Book Configuration](docs/Documentation/Book-Configuration.md), with the vendored-library patches in [Fixes-PagedJS.md](docs/Documentation/Fixes-PagedJS.md) and [Fixes-PDFLib.md](docs/Documentation/Fixes-PDFLib.md). The Phase 8 specification is [builder/PLAN-8.md](builder/PLAN-8.md), and the Jekyll era as a whole is recorded in [WIP.OldJekyll.md](WIP.OldJekyll.md). +> **Current documentation.** The two-stage pipeline, the renderer's flags and phases, and how to triage a failed render are on the site: [PDF Generation](docs/Documentation/PDF-Generation.md) and [Book Configuration](docs/Documentation/Book-Configuration.md), with the vendored-library patches in [Fixes-PagedJS.md](docs/Documentation/Fixes-PagedJS.md) and [Fixes-PDFLib.md](docs/Documentation/Fixes-PDFLib.md). The Phase 8 specification is [builder/PLAN-8.md](builder/PLAN-8.md). Currently `book.bat` produces a ~1500-page PDF that is a flat concatenation of every reference page. It works, but it doesn't read like a book — no front matter, no parts, no global TOC, every chapter is `

`, and "See Also" cross-references point at standalone `file://` URLs rather than jumping within the PDF. diff --git a/BUGS-TO-REPORT.md b/BUGS-TO-REPORT.md index ef05bce1c..7cc82db9e 100644 --- a/BUGS-TO-REPORT.md +++ b/BUGS-TO-REPORT.md @@ -347,7 +347,7 @@ Every one of those runs ends `... FAILED` and exits 0. Exporting `WebView2Packag What does not reproduce it: a long input path to `export`, and any output folder short enough that no file path reaches 260 characters and no folder path 248. - + --- @@ -888,7 +888,7 @@ Steps to reproduce the behavior: w.Boom 1 (early bound): error 5, ran 1 time(s), Property Get ran 0 ``` 3. See `Hello` run twice, and `Property Get Prop` run after the `Property Let` that failed. The error from the assignment is `&H80020009` (`DISP_E_EXCEPTION`) where the `Property Let` raised 5. -4. To see the second call, implement `IDispatch` in a `NotDispatchable` class (see `bugs/callbyname-membernotfound-retried/`, which has one, and whose `Invoke` can return another code) whose `Invoke` prints `wFlags`, `pDispParams.cArgs` and whether `pVarResult` is null, and returns a failure code. With `Fail3` returning `DISP_E_TYPEMISMATCH` and `Fail5` raising error 5: +4. To see the second call, implement `IDispatch` in a `NotDispatchable` class whose `Invoke` prints `wFlags`, `pDispParams.cArgs` and whether `pVarResult` is null, and returns a failure code. With `Fail3` returning `DISP_E_TYPEMISMATCH` and `Fail5` raising error 5: ``` o.Fail3 1 Invoke flags=1 cArgs=1 result=null, then Invoke flags=3 cArgs=0 result=set o.Fail3 = 5 Invoke flags=4 cArgs=1 result=null, then Invoke flags=3 cArgs=0 result=set @@ -914,41 +914,6 @@ The second call resembles VB's rule for `o.Member(args)` on a property that retu --- -## `CallByName` calls `Invoke` a second time, with no result, when the first call returns DISP_E_MEMBERNOTFOUND - -**Describe the bug** -`CallByName` on an object whose `IDispatch.Invoke` returns `DISP_E_MEMBERNOTFOUND` calls `Invoke` twice: first with a result variant (`pVarResult` supplied), then again with the same identifier and flags and a null `pVarResult`. A late-bound statement for the same member, `o.Anything`, calls it once. Seen with a class that implements `IDispatch` itself and counts the calls. - -**To Reproduce** -Steps to reproduce the behavior: -1. Open `callbyname-membernotfound-retried.twinproj` (attached as `callbyname-membernotfound-retried.zip`). Its one source file, `Startup.twin`, declares a copy of `IDispatch` (the `stdole` one cannot be called) and a `NotDispatchable` class `Recorder` that implements it. Its `GetIDsOfNames` returns 1 for any name, and its `Invoke` prints its `wFlags`, says whether `pVarResult` is null, counts the call, and returns `DISP_E_MEMBERNOTFOUND` with `Err.ReturnHResult`. -2. Run it. `Main` calls `CallByName o, "Anything", vbMethod` and then `o.Anything`, with `On Error Resume Next`, through `Dim o As Object = New Recorder`: - ``` - Invoke 1: wFlags 1, result supplied - Invoke 2: wFlags 1, result null - CallByName: error 438, Invoke called 2 time(s) - Invoke 1: wFlags 1, result null - o.Anything: error 438, Invoke called 1 time(s) - ``` - -**Expected behavior** -One `Invoke` per `CallByName`, as for the statement form. A method that did its work and then reported `DISP_E_MEMBERNOTFOUND` (a scripting host whose members are resolved inside `Invoke` is an example) runs again, and a call with arguments and side effects is repeated. - -**Desktop:** - - OS: Windows 10 Pro 22H2 (build 19045) - - twinBASIC compiler version: BETA 995 - -**Additional context** -Severity: low. It only shows with an `IDispatch` that returns `DISP_E_MEMBERNOTFOUND` after doing work. - -What does not reproduce it: `CallByName` with `vbGet` or `vbLet` against `DISP_E_TYPEMISMATCH`, `DISP_E_BADPARAMCOUNT`, `E_FAIL`, an error raised in `Invoke`, or success (one `Invoke` each); `Invoke` returning `DISP_E_MEMBERNOTFOUND` to a late-bound statement or to `x = o.Member` (one `Invoke` each). `vbGet`, `vbMethod` and `vbLet` all repeat after `DISP_E_MEMBERNOTFOUND`. - -A separate behaviour, in its own entry, repeats a failed late-bound statement that passes arguments as a property read: see the entry "A late-bound call that passes arguments and fails is issued a second time, without them". The two are not the same: that one is for calls with arguments and needs no `DISP_E_MEMBERNOTFOUND`, and this one is `CallByName` with or without arguments. - - - ---- - ## A late-bound call to a member that does not exist raises &H80020006, and `CallByName` raises &H80004005, where VB6 raises 438 **Describe the bug** @@ -1020,6 +985,12 @@ Steps to reproduce the behavior: **Expected behavior** The call goes through the interface: `GetTypeInfoCount` returns the count (1 for a twinBASIC class), and a wrong number or type of argument is a compile error, as for any other interface method. If `stdole.IDispatch` is meant to be an alias of `Object`, `d.GetTypeInfoCount` should still not compile, or the type should not list four methods that cannot be called. A project's own declaration of the interface, with the same `[InterfaceId]`, calls them correctly. +VB6 refuses the declaration itself. The same program in VB6 (attached as `stdole-idispatch-late-bound-vb6.zip`), with a reference to OLE Automation, does not build: +``` +Compile Error in File '...\Module1.bas', Line 16 : Function or interface marked as restricted, or the function uses an Automation type not supported in Visual Basic +``` +`Dim d As IDispatch` is refused the same way. With `Dim d As Object` in its place, VB6 runs `d.Hello` and raises 438 for the other three statements, so twinBASIC's `stdole.IDispatch` behaves as VB6's `Object` does, apart from the error number (see the entry about `&H80020006` and 438). + **Desktop:** - OS: Windows 10 Pro 22H2 (build 19045) - twinBASIC compiler version: BETA 995 @@ -1029,7 +1000,7 @@ Severity: low, since a project can declare its own copy of the interface, but th What was tried: `GetIDsOfNames` and `Invoke` with zero arguments, and `GetTypeInfo` with a null pointer, each end the run the same way; calls with arguments of the wrong type compile (`d.GetTypeInfoCount "a"`, `d.GetTypeInfo "a", "b", "c"`, `d.Invoke "a", "b", "c", "d", "e", "f", "g", "h"` all compile). An unhandled `Err.Raise 5` in the same harness ends the run the same way, so the silent end is how an unhandled error shows there, not a separate fault. Running the built exe with `--exe` was not possible on this machine (the harness could not start it). - + --- @@ -1053,6 +1024,16 @@ Steps to reproduce the behavior: **Expected behavior** `DISP_E_BADINDEX` (`&H8002000B`), which the `IDispatch::GetTypeInfo` documentation gives for an index that is not valid, for an index the object does not have. `E_UNEXPECTED` (*Catastrophic failure*) is the code for a call made at a time when the object cannot take it, and it reads as a fault in the object. +VB6 does not return `E_UNEXPECTED`. The same calls on a VB6 class, made through `DispCallFunc` (the VB6 project is attached as `gettypeinfo-badindex-vb6.zip`), return `TYPE_E_ELEMENTNOTFOUND` (`&H8002802B`) for every index but 0: +``` +GetTypeInfoCount = 1 (HRESULT 0) +GetTypeInfo(0): HRESULT 0, pointer returned True +GetTypeInfo(1): HRESULT 8002802B, pointer returned False +GetTypeInfo(2): HRESULT 8002802B, pointer returned False +GetTypeInfo(-1): HRESULT 8002802B, pointer returned False +``` +Either `DISP_E_BADINDEX` or VB6's `TYPE_E_ELEMENTNOTFOUND` says that the index names nothing; `E_UNEXPECTED` does not. + **Desktop:** - OS: Windows 10 Pro 22H2 (build 19045) - twinBASIC compiler version: BETA 995 @@ -1060,7 +1041,7 @@ Steps to reproduce the behavior: **Additional context** Severity: minor. A caller that checks for `DISP_E_BADINDEX` to learn that an object has no more type information gets a different code; `GetTypeInfo(0)` and `GetTypeInfoCount` are right. - + --- @@ -1182,16 +1163,18 @@ Steps to reproduce the behavior: **Additional context** Severity: low in practice, since a program rarely advises a null sink, but a COM client written in any language can send one, and the connection point crashes the process that hosts the class instead of refusing the call. -What was tried: an object that is not a sink gives an ordinary error (`E_NOINTERFACE`, `&H80004002`, see the entry about `Advise` and `Unadvise` error codes), so only a null pointer crashes. The address of the crash is the same in the standalone probe and in this project. +What was tried: an object that is not a sink gives an ordinary error (`E_NOINTERFACE`, `&H80004002`), so only a null pointer crashes. The address of the crash is the same in the standalone probe and in this project. + +VB6 crashes too. The same call on the connection point of a VB6 class with an event, made through `DispCallFunc` (the VB6 project is attached as `advise-nothing-crashes-vb6.zip`), ends the exe with an access violation (`0xC0000005` in `MSVBVM60.DLL`, in the Application event log) after it writes `before`. A fix therefore costs no compatibility: no program depends on the crash. - + --- -## `Advise` with a sink that lacks the outgoing interface fails with `E_NOINTERFACE`, and `Unadvise` with a cookie that names no connection succeeds +## `Unadvise` with a cookie that names no connection succeeds, where VB6 returns CONNECT_E_NOCONNECTION **Describe the bug** -On the connection point of a twinBASIC class that has an `Event`, two calls return a different code from the one the COM contract names. `Advise` with a sink that does not answer `QueryInterface` for the outgoing interface fails with `E_NOINTERFACE` (`&H80004002`), where the contract returns `CONNECT_E_CANNOTCONNECT` (`&H80040201`). `Unadvise` with a cookie that names no connection (0 and 99 were tried) succeeds silently and does nothing, where the contract returns an error. +On the connection point of a twinBASIC class that has an `Event`, `Unadvise` with a cookie that names no connection (0 and 99 were tried) succeeds silently and does nothing. The COM contract returns an error, and VB6 returns `CONNECT_E_NOCONNECTION` (`&H80040200`). **To Reproduce** Steps to reproduce the behavior: @@ -1210,17 +1193,26 @@ Steps to reproduce the behavior: ``` **Expected behavior** -`Advise` fails with `CONNECT_E_CANNOTCONNECT`. The Windows SDK page for [IConnectionPoint::Advise](https://learn.microsoft.com/en-us/windows/win32/api/ocidl/nf-ocidl-iconnectionpoint-advise) lists it as "The sink does not support the interface required by this connection point", and says to implementers: "The connection point must query the *pUnkSink* pointer for the correct outgoing interface. If this query fails, this method must return CONNECT_E_CANNOTCONNECT." `Unadvise` reports the bad cookie: the page for [IConnectionPoint::Unadvise](https://learn.microsoft.com/en-us/windows/win32/api/ocidl/nf-ocidl-iconnectionpoint-unadvise) lists `E_POINTER` for "The value in *dwCookie* does not represent a valid connection". It is not `S_OK`. +`Unadvise` reports the bad cookie, as VB6 does. The page for [IConnectionPoint::Unadvise](https://learn.microsoft.com/en-us/windows/win32/api/ocidl/nf-ocidl-iconnectionpoint-unadvise) lists `E_POINTER` for "The value in *dwCookie* does not represent a valid connection", and `CONNECT_E_NOCONNECTION` is the code the `CONNECT_E_` range has for it. It is not `S_OK`. + +The same calls on the connection point of a VB6 class with an event, made through `DispCallFunc` (the VB6 project is attached as `advise-unadvise-hresults-vb6.zip`): +``` +Advise, a sink without the outgoing interface: error=80004002 + cookie=-1 +Unadvise 99: error=80040200 +before Unadvise 0 +``` +`Unadvise 0` comes last because it ends the VB6 exe with an access violation (`0xC0000005` in `MSVBVM60.DLL`). twinBASIC's 0 for that cookie is no worse than VB6's crash; the cookie 99 is the clean comparison. **Desktop:** - OS: Windows 10 Pro 22H2 (build 19045) - twinBASIC compiler version: BETA 995 **Additional context** -Severity: low. A client that tests for `CONNECT_E_CANNOTCONNECT` to tell a refused sink from other failures never sees it, and a client that releases a connection twice, or with a wrong cookie, is told it worked. +Severity: low. A client that releases a connection twice, or with a wrong cookie, is told it worked. -What was tried: a sink that is an ordinary twinBASIC class gives the same `E_NOINTERFACE` whether it has no members or implements `IDispatch` (the outgoing interface's identifier is generated for each build, so a twinBASIC class cannot answer for it). Nothing is connected afterwards, and no event reaches the sink. Calling `Unadvise` a second time with the cookie of the failed `Advise`, which is 0, also returns without an error. - +`Advise` with a sink that does not answer `QueryInterface` for the outgoing interface fails with `E_NOINTERFACE` (`&H80004002`), where the Windows SDK page for [IConnectionPoint::Advise](https://learn.microsoft.com/en-us/windows/win32/api/ocidl/nf-ocidl-iconnectionpoint-advise) says the method "must return CONNECT_E_CANNOTCONNECT" (`&H80040201`). VB6 returns `E_NOINTERFACE` too, so that is not reported here; the reproducer prints it as a control. A sink that is an ordinary twinBASIC class gives the same `E_NOINTERFACE` whether it has no members or implements `IDispatch` (the outgoing interface's identifier is generated for each build, so a twinBASIC class cannot answer for it). Nothing is connected afterwards, and no event reaches the sink. Calling `Unadvise` a second time with the cookie of the failed `Advise`, which is 0, also returns without an error. + --- diff --git a/README.md b/README.md index 8cd0773f4..9076e15ab 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ book.bat # renders the PDF book; run build.bat first examples.bat # compiles the twinBASIC code samples in the pages (Windows + a twinBASIC install) addin-test.bat # tests IDE add-ins by operating an IDE (Windows + a twinBASIC install) ide-test.bat # tests the IDE itself by operating it (Windows + a twinBASIC install) +try-help-addin.bat # opens an IDE with the help add-in, to try it by hand (Windows + a twinBASIC install) ``` A clean `build.bat && check.bat` is the bar for "ready to commit"; add `test.bat` when the change touched anything outside `docs/`. Each wrapper names the gates it runs, in order, on [Tools and Scripts](https://docs.twinbasic.com/Documentation/Development/Tools). On Linux or macOS, run the `node` command inside each batch file directly --- they are thin wrappers. `examples.bat`, `addin-test.bat` and `ide-test.bat` are the exceptions to both: they drive the twinBASIC IDE, so they are Windows-only and deliberately outside every gate and outside CI. diff --git a/WIP.A11y.md b/WIP.A11y.md index 102deca59..d2c708c6e 100644 --- a/WIP.A11y.md +++ b/WIP.A11y.md @@ -1,7 +1,7 @@ # twinBASIC Documentation --- The Accessibility Scan What the axe scan actually is, how its sample is chosen, and why tuning it is -dangerous. Split out of [WIP.md](WIP.md), which keeps the gate roster under [The +dangerous. [WIP.md](WIP.md) keeps the gate roster under [The gates, and where their internals are](WIP.md#the-gates-and-where-their-internals-are). `check.bat` runs [scripts/check_tree_fresh.mjs](scripts/check_tree_fresh.mjs), @@ -12,13 +12,13 @@ which refuses a tree older than the sources that produced it, then [scripts/check_a11y.mjs](scripts/check_a11y.mjs), which runs puppeteer + axe-core over thirteen sample pages against WCAG 2.0/2.1/2.2 at Level A + AA (plus the `heading-order` best-practice rule) and exits non-zero on any -violation. +violation. It requires `build.bat` to have produced an up-to-date `_site/`. What the scan *is* --- the page list, the viewports, the themes, the blocked requests and the axe run options --- lives in [scripts/lib/axe-scan.mjs](scripts/lib/axe-scan.mjs), shared with the correctness gate and the measurement rig; `check_a11y.mjs` itself is only the -reporting front end. Note that all five WCAG tags --- `wcag2a`, `wcag2aa`, +reporting front end. All five WCAG tags --- `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa`, `wcag22aa` --- must be listed: axe matches tags literally with no version rollup, so a rule tagged only `wcag21aa` does **not** match `wcag22aa`. @@ -26,40 +26,40 @@ with no version rollup, so a rule tagged only `wcag21aa` does **not** match Four details of that script matter and are easy to break: - It scans **`_site-offline/`, not `_site/`**. The online tree references its assets with root-absolute URLs (`/assets/css/…`), which resolve to nothing under `file://` — every page would load unstyled and every colour-contrast result would be a meaningless black-on-white pass. The offline tree uses relative asset paths and renders for real. -- It scans each page **in both themes and at two viewports** (`--theme`, `--viewport`). Dark mode is a separate stylesheet with its own palette, and defects such as horizontally scrolling code blocks only appear once the layout is narrow enough to overflow. The dark half is not a formality: the dark compilation re-emits every JTD base rule under `html[data-theme=dark]`, which raises its specificity from (0,0,1) to (0,1,2) -- so a root-level single-class rule in `custom/custom.scss` that overrides a bare element selector **applies in light mode and silently does not in dark**. That is exactly how the footnote-underline fix shipped half-broken, and only the dark pass caught it. Prefix such rules with `.main-content` to clear the bar. +- It scans each page **in both themes and at two viewports** (`--theme`, `--viewport`). Dark mode is a separate stylesheet with its own palette, and defects such as horizontally scrolling code blocks only appear once the layout is narrow enough to overflow. The dark half is not a formality: the dark compilation re-emits every JTD base rule under `html[data-theme=dark]`, which raises its specificity from (0,0,1) to (0,1,2) -- so a root-level single-class rule in `custom/custom.scss` that overrides a bare element selector **applies in light mode and silently does not in dark**. Prefix such rules with `.main-content` to clear the bar. - It injects a **patched** axe bundle. `SOURCE_PATCHES['plain-color-fields']` in `axe-scan.mjs` replaces `Color2`'s six WeakMap-emulated `#private` fields with plain own properties, worth **-26 %** across a realistic page set and **-30 %** on large pages. Patches need the unminified bundle, which costs ~6 ms more per page to inject. Two obligations come with it: every axe-core upgrade re-runs both `check_a11y_fingerprint.mjs --patches plain-color-fields` and `check_axe_patch_equiv.mjs` (CI and `test.bat` run the second for you), and if a result ever looks wrong, re-run with `--stock-axe` first -- that injects the unmodified bundle and says in one command whether the patch is implicated. -- It **blocks the search index** (`search-data.js` + `lunr.min.js`) via request interception — see `BLOCKED_REQUESTS`. Every page pulls in ~3.4 MB of index that never touches the DOM axe walks; loading it was 18.9 s of a 27.1 s run, and aborting it cuts the scan to ~9 s with byte-identical results (every rule id and node count, violations and incomplete alike, across every page/theme/viewport combination -- 24 of them when that was measured, 60 audits today). Do **not** extend the block list to `just-the-docs.js` — it installs the search combobox ARIA, and blocking it makes axe see *less* (colour-contrast nodes on `Select-Case` drop 54 → 2), silently masking coverage. +- It **blocks the search index** (`search-data.js` + `lunr.min.js`) via request interception — see `BLOCKED_REQUESTS`. Every page pulls in ~3.4 MB of index that never touches the DOM axe walks; loading it is 18.9 s of a 27.1 s run, and aborting it cuts the scan to ~9 s with byte-identical results (every rule id and node count, violations and incomplete alike, across every page/theme/viewport combination -- 60 audits today). Do **not** extend the block list to `just-the-docs.js` — it installs the search combobox ARIA, and blocking it makes axe see *less* (colour-contrast nodes on `Select-Case` drop 54 → 2), silently masking coverage. -**A local pass on the geometry rules used not to be authoritative, and the reason is worth keeping in mind.** `target-size` measures rendered boxes, and an inline element's measured height is its font's content area --- so it moved with whatever `system-ui` resolved to. Measured at the mobile h3 size: Segoe UI 19px, Inter / Verdana / Tahoma 17px, Arial 16px, Liberation Sans / DejaVu Sans / Roboto 15px. A heading link topped up with `padding-block: 3px` therefore cleared the 24px floor by 0.8px on Windows and missed it on CI's Linux fonts, and the local scan reported a clean pass throughout. +**A local pass on the geometry rules is authoritative only because the face is pinned.** `target-size` measures rendered boxes, and an inline element's measured height is its font's content area, so it moves with whatever `system-ui` resolves to (15--19px at the mobile h3 size; see [WIP.Typography.md](WIP.Typography.md#why-this-is-not-only-a-cosmetic-change)). [Self-hosting the text face](WIP.md#typography) pins that number at 17px on every machine, which makes a local pass match CI's. It does not licence trimming the paddings back: `font-display: swap` puts the first frames of every load on the fallback, and a reader whose font request fails stays there, so the 15--19px band is still the range a fix has to clear. Give a font-dependent measurement margin against the *smallest* of those numbers, prefer a rule that meets the floor on declared size where the layout allows one, and check the whole site for the rule rather than the sample --- one rule over every page at one viewport takes about two minutes. -[Self-hosting the text face](WIP.md#typography) pins that number at 17px on every machine, which is what closed the gap between a local pass and CI's. It does not licence trimming the paddings back: `font-display: swap` puts the first frames of every load on the fallback, and a reader whose font request fails stays there, so the 15--19px band is still the range a fix has to clear. Give a font-dependent measurement margin against the *smallest* of those numbers, prefer a rule that meets the floor on declared size where the layout allows one, and check the whole site for the rule rather than the sample --- one rule over every page at one viewport takes about two minutes. +**axe does not evaluate whether a focus ring is actually visible**, only that focusable things are reachable and labelled --- so a ring that is drawn and then clipped away passes every rule. The aux nav is where that matters. `.aux-nav` is `overflow-x: auto` (navigation.scss:182), and the overflow spec turns the other axis from `visible` to `auto` when one axis is not `visible`, so the nav is a scroll container that clips at its padding box on all four sides; its items are `height: 100%` and the first one starts at the left content edge. An outset ring on anything in there therefore loses every side that sits on the clip edge. Only a manual keyboard pass sees it. -**axe does not evaluate whether a focus ring is actually visible**, only that focusable things are reachable and labelled --- so a ring that is drawn and then clipped away passes every rule. The aux nav is where that matters. `.aux-nav` is `overflow-x: auto` (navigation.scss:182), and the overflow spec turns the other axis from `visible` to `auto` when one axis is not `visible`, so the nav is a scroll container that clips at its padding box on all four sides; its items are `height: 100%` and the first one starts at the left content edge. An outset ring on anything in there therefore loses every side that sits on the clip edge. The theme toggle shipped that way --- `.btn-reset`'s 2px ring at 2px offset survived only on the right, where the aux-nav link leaves room --- and a manual keyboard pass is what found it. The fix is an inset ring (`outline-offset: -2px` on `#theme-toggle`, in `custom/custom.scss`), which needs an id selector to out-rank the dark compilation's `html[data-theme=dark] .btn-reset:focus-visible` at (0,3,1). +The theme toggle therefore gets an inset ring (`outline-offset: -2px` on `#theme-toggle`, in `custom/custom.scss`), which needs an id selector to out-rank the dark compilation's `html[data-theme=dark] .btn-reset:focus-visible` at (0,3,1). -**Chrome's UA `outline: auto` does not rescue a link in there either**, which is easy to assume and wrong. Measured at 1280x900 in both themes, the nav's box is top 0 / bottom 59 and the "twinBASIC Home" link's is identical, so the UA ring's `+1px` offset puts its top and bottom segments outside the clip box exactly as the toggle's did; only the left and right bars survive, because the link starts 50px inboard of the nav's left edge. It takes an author ring at `outline-offset: -2px` too (`.aux-nav a.site-button:focus-visible`), with a dark-mode colour override -- the link is not a `.btn-reset`, so nothing in either compilation competes with it. +**Chrome's UA `outline: auto` does not rescue a link in there either.** At 1280x900 in both themes, the nav's box is top 0 / bottom 59 and the "twinBASIC Home" link's is identical, so the UA ring's `+1px` offset puts its top and bottom segments outside the clip box exactly as the toggle's did; only the left and right bars survive, because the link starts 50px inboard of the nav's left edge. It takes an author ring at `outline-offset: -2px` too (`.aux-nav a.site-button:focus-visible`), with a dark-mode colour override -- the link is not a `.btn-reset`, so nothing in either compilation competes with it. ## Heading permalinks -The chain icon beside every heading is **deliberately `aria-hidden="true" tabindex="-1"`**, which looks like a defect and is not. It used to carry `aria-labelledby` pointing at its own enclosing heading, so Chrome computed its accessible name as the heading text verbatim: every heading turned up a second time in a screen reader's links list, named identically, with nothing marking it as a permalink. Over 7,000 of them across the site's 869 content pages -- 867 carry at least one -- and 172 on `tB/Gloss.html` alone. axe passed `link-name` throughout, because the rule asks whether a name exists, not whether it is worth announcing. +The chain icon beside every heading is **deliberately `aria-hidden="true" tabindex="-1"`**, which looks like a defect and is not. With a name (for example `aria-labelledby` pointing at its own enclosing heading) every heading would appear a second time in a screen reader's links list, named identically, with nothing marking it as a permalink --- over 7,000 extra links across the site's 869 content pages, 172 on `tB/Gloss.html` alone. axe would still pass `link-name`, because the rule asks whether a name exists, not whether it is worth announcing. -It stays a real `` so the mouse affordances that people actually use to copy these -- right-click Copy Link Address, middle-click, the status-bar URL preview -- are untouched. The keyboard and screen-reader equivalent is `renderSectionLinks` in [builder/template.mjs](builder/template.mjs): one `