From 6fecacae7e2a2169c9e1a6602e55cdb1f99e4f6e Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Thu, 24 Sep 2026 18:59:25 +0200 Subject: [PATCH 1/8] scripts: tbrun exits 2 when the build fails instead of returning the IDE's build log as the probe's output; four gates exit 2 on a crash, not 1 --- WIP.Harness.md | 14 +++++++++++++- scripts/check_code_regions.mjs | 7 ++++++- scripts/check_dot_fit.mjs | 5 +++++ scripts/check_page_baseline.mjs | 5 +++++ scripts/check_publish_policy.mjs | 5 +++++ scripts/tbrun.mjs | 12 +++++++++++- 6 files changed, 45 insertions(+), 3 deletions(-) diff --git a/WIP.Harness.md b/WIP.Harness.md index fe5a0758..32e16adc 100644 --- a/WIP.Harness.md +++ b/WIP.Harness.md @@ -363,7 +363,7 @@ above with the one symptom that detects it removed. `tbrun` pins the path in its copy, which is why it insists on a source tree it can edit rather than a packed project it cannot. -Three smaller things it knows, each of which cost a run: +Four smaller things it knows, each of which cost a run: - **`element.click()` on `#buildIcon` does nothing.** It is a plain DIV behind the IDE's own pointer handling and needs real `Input.dispatchMouseEvent` presses at its centre. @@ -382,6 +382,18 @@ Three smaller things it knows, each of which cost a run: and the linker writes there *after* the build, so without a clear you capture your output interleaved with `[LINKER]` lines. The script warns rather than guessing which lines are yours. +- **A failed build is not output.** A build that fails after a clean compile never runs the + probe, and the IDE's own log stays in the console: `[BUILD] Starting...`, + `[TYPELIB] failed to finalize typelibrary. Disk error?`, `[LINKER] FAILED to create type + library`, `[BUILD] failed`. `tbrun` returned exactly that as the probe's output, with exit 0, + twice in round 8's fix pass --- five runs going at once on ports 9740--9744, and both passed + when repeated. It now exits 2 on a `[BUILD] failed` or `[LINKER] FAILED` line, which the + probe's own `Debug.Cls` would have erased. What made the type library fail was not isolated. + +A reader of the console that is not `tbrun` should **compare the whole console before and +after, not read on from an index**: new text can be appended to an entry that is still open. +Round 8's export probe missed the first `[EXPORT] exporting...` line of every session that +way. `tbrun` re-reads the whole backing array on every poll, which is why it never did. It settles on a quiet period rather than a sentinel, so no probe has to print a marker the script knows about. Distinct `--port` values let probes run concurrently, exactly as diff --git a/scripts/check_code_regions.mjs b/scripts/check_code_regions.mjs index ce544778..98b610ea 100644 --- a/scripts/check_code_regions.mjs +++ b/scripts/check_code_regions.mjs @@ -5,6 +5,9 @@ // node scripts/check_code_regions.mjs --verbose # per-finding detail // node scripts/check_code_regions.mjs --self-test # prove it still detects // +// Exit: 0 clean, 1 a code region changed or a probe failed, 2 the gate itself +// could not run. +// // WHY THIS EXISTS // // builder/render.mjs applies several kramdown-parity rewrites to raw markdown @@ -224,7 +227,9 @@ async function main(argv) { process.exit(failed ? 1 : 0); } +// A crash is the harness failing, not a finding: exit 2, as Extending.md's +// gate conventions require, so it cannot read as an altered code region. main(process.argv.slice(2)).catch((err) => { console.error(err); - process.exit(1); + process.exit(2); }); diff --git a/scripts/check_dot_fit.mjs b/scripts/check_dot_fit.mjs index 5e549133..c8928030 100644 --- a/scripts/check_dot_fit.mjs +++ b/scripts/check_dot_fit.mjs @@ -28,6 +28,11 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import puppeteer from "puppeteer"; +// A crash is the harness failing, not a finding: exit 2, as Extending.md's gate +// conventions require. This file runs at top level, so there is no main().catch +// to do it; the handler also catches a rejected top-level await. +process.on("uncaughtException", (err) => { console.error(err); process.exit(2); }); + const REPO = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const SRC = path.join(REPO, "docs"); diff --git a/scripts/check_page_baseline.mjs b/scripts/check_page_baseline.mjs index 9fb03049..f092b1b5 100644 --- a/scripts/check_page_baseline.mjs +++ b/scripts/check_page_baseline.mjs @@ -28,6 +28,11 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { checkPageBaseline, GUARDED_SRC } from "../builder/page-baseline.mjs"; +// A crash is the harness failing, not a finding: exit 2, as Extending.md's gate +// conventions require. This file runs at top level, so there is no main().catch +// to do it; the handler also catches a rejected top-level await. +process.on("uncaughtException", (err) => { console.error(err); process.exit(2); }); + const BASE = { src: GUARDED_SRC, pages: 908, staticFiles: 247 }; let failures = 0; diff --git a/scripts/check_publish_policy.mjs b/scripts/check_publish_policy.mjs index ad97f53c..9efac8db 100644 --- a/scripts/check_publish_policy.mjs +++ b/scripts/check_publish_policy.mjs @@ -23,6 +23,11 @@ import { SOURCE_EXTENSIONS, BUILD_EXTENSIONS, } from "../builder/publish-policy.mjs"; +// A crash is the harness failing, not a finding: exit 2, as Extending.md's gate +// conventions require. This file runs at top level, so there is no main().catch +// to do it; the handler also catches a rejected top-level await. +process.on("uncaughtException", (err) => { console.error(err); process.exit(2); }); + const SRC = process.argv.includes("--src") ? process.argv[process.argv.indexOf("--src") + 1] : "docs"; diff --git a/scripts/tbrun.mjs b/scripts/tbrun.mjs index 02ee0ab4..3a5eba06 100644 --- a/scripts/tbrun.mjs +++ b/scripts/tbrun.mjs @@ -15,7 +15,8 @@ // --show / --hide as tbbuild's // // Exit: 0 captured output, 1 the project has compile errors, 2 the harness -// failed, 3 the build produced no console output before the timeout. +// failed -- a build that fails after a clean compile included, since the probe +// never runs -- 3 the build produced no console output before the timeout. // // ---------------------------------------------------------------- why // @@ -281,6 +282,15 @@ try { const reaped = shutdown(); if (failure) die(2, `tbrun: ${failure}`); +// A build that fails after a clean compile never runs the probe, and leaves the +// IDE's own build log in the console. The probe's first statement is Debug.Cls, +// which would have erased that log, so its survival means the capture is not the +// probe's output. Returned as output, a `[TYPELIB] failed to finalize +// typelibrary` build exited 0 twice in round 8's fix pass. +if (captured.some((l) => /^\[(BUILD\]\s+failed|LINKER\]\s+FAILED)\b/i.test(l))) { + die(2, "tbrun: the build failed, so the probe never ran. The console holds the IDE's " + + `build log, not the probe's output:\n${captured.map((l) => ` ${l}`).join("\n")}`); +} if (!captured.length) { die(3, "tbrun: the build produced no console output before the timeout.\n" + (hasHook ? " The [RunAfterBuild] Sub may not have run -- check the IDE for a modal." From e9f849b6f20f6a7d3852dc0fbea5718ca8f55f4f Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Thu, 24 Sep 2026 18:59:43 +0200 Subject: [PATCH 2/8] notes: queue ten twinBASIC defects found in round 8 --- BUGS-TO-REPORT.md | 239 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) diff --git a/BUGS-TO-REPORT.md b/BUGS-TO-REPORT.md index 28cb029f..9eadba1b 100644 --- a/BUGS-TO-REPORT.md +++ b/BUGS-TO-REPORT.md @@ -666,3 +666,242 @@ variable on the right both reproduce it. the other way round: `"2" \ b` is the `Long` -2. **Found by** the result-type probe for `Reference/Operators.md`. + +--- + +## Export Project follows a directory junction in its folder and deletes what it points to + +**Build:** BETA 983 +**Severity:** data loss outside the folder the user chose. Export Project empties its folder +before writing, as the *Export Path* setting warns; it does not stop at a junction. + +1. In the export folder, make a junction to another folder that holds a file: + `mklink /J \linked `, with `\precious.txt`. +2. Run **File → Export Project** into ``, with *Export Verbose* on. +3. The Debug Console shows: + ``` + [EXPORT] DELETED: \\?\\linked\precious.txt + [EXPORT] DELETED: \\?\\linked + ``` + and `` is empty afterwards. + +**What does not reproduce it:** the command-line `export` verb, which deletes nothing. + +**Found by** the Export Project probe for round 8's UC-55, which drove the IDE's own +`exportProjectTo()` over DevTools on a scratch folder. + +--- + +## Export Project stops at a read-only file after deleting everything before it, and the IDE reports nothing + +**Build:** BETA 983 +**Severity:** a partly emptied folder, with the only record in the Debug Console. On a Git +working copy it breaks the repository, because Git makes its object files read-only. + +1. Put a read-only file in the export folder among other files. +2. Run **File → Export Project** into it. +3. The Debug Console shows: + ``` + [EXPORT] DELETE FAILED: \\?\\readonly.txt + [EXPORT] ERROR: unable to clean the output folder + [EXPORT] export failed. + ``` + The files and folders that sort before the read-only one are already deleted, nothing is + exported, and no dialog appears: the compiler's response to the IDE is code 0. + +On a `git init` working copy with a commit, it deletes `.git\config`, `HEAD`, `index`, `hooks` +and `info`, then stops at the first object file. `git status` there reports +`fatal: not a git repository`. + +**What does not reproduce it:** a folder with no read-only file, which is emptied and exported +completely --- `.git` included, with no prompt. + +**Found by** the same probe. + +--- + +## Export Path refuses `${SourcePath}` alone, but not the same folder written as a path + +**Build:** BETA 983 +**Severity:** the project file is deleted when the export folder is the folder that holds it. + +The Settings editor's check on `project.exportPath` in `ide/main.js` compares the text with +`${sourcepath}` and `${sourcepath}\`, with the message "This would DELETE the project file, as +the `Export Project` command empties the output folder before exporting". It does not resolve +the path. The compiler applies no check of its own: an export into the project's own folder +logged `[EXPORT] DELETED: \\?\\.twinproj` and completed. A **Save** afterwards +wrote the file back; closing without saving loses it. + +**Found by** the same probe. The compiler's side was measured, by calling `exportProjectTo()` +with the folder; that the editor accepts the same folder typed as a path is read from the +check's code, not tried. + +--- + +## An out-of-range index raises `&H8002000B` or `&H80004005`, not VBA's error 9 + +**Build:** BETA 983 --- the IDE and a compiled EXE alike +**Severity:** VBA code that handles `Err.Number = 9` does not recognise the error, with no +diagnostic. + +``` +Dim a(5) As Long +On Error Resume Next +a(7) = 1 +Debug.Print Err.Number, Hex$(Err.Number), Err.Description +``` + +prints `-2147352565 8002000B Invalid index.`. Every case measured, reading `Err.Number` in +the program: + +| access | twinBASIC | VBA, per VBA-Docs' *Subscript out of range (Error 9)* | +|---|---|---| +| past a fixed or dynamic array's bound, a `Variant` array's, or `Split("x y")(5)` | -2147352565 (`8002000B`) *Invalid index.* | 9 | +| an element of an array never dimensioned: `Dim u() As Integer: u(8) = 234`, VBA-Docs' own example | -2147467259 (`80004005`) *Unspecified error* | 9 | +| a `Collection` member by a missing index or key | -2147467259 *Unspecified error* | 9 for a missing member | +| `Forms(99)`, `Forms.Item(-1)` | -2147467259 *Unspecified error* | --- | + +**What does not reproduce it:** `UBound` of an erased array, `Printers(99)` and `Err.Raise 9` +all give 9, and division by zero gives 11. The IDE's run-time error panel shows the same number +`Err.Number` holds, for the array case. An erased array behaves as one never dimensioned: +`-2147467259` for an element, 9 from `LBound` and `UBound`. `Printers` raises 9 past its end but +`-2147467259` for a negative index and for an unknown name. The description of `-2147467259` +varies between runs --- *Unspecified error* in one, *Automation error* in another. + +--- + +## Reading `Forms` by index returns a broken reference, and the process then crashes + +**Build:** BETA 983 --- the IDE and a compiled EXE alike +**Severity:** crash (`0xC0000005`), from a form of access the documentation shows. + +With one form loaded (`Load Form1`): + +``` +Dim s As String +s = Forms(0).Name ' s is "", and the process later dies with 0xC0000005 +``` + +`Set f = Forms(0)` followed by `f.Name` does the same, and so does `s = Forms(n).Name` with +`n` a variable. Inside `For k = 0 To Forms.Count - 1`, `Set f = Forms(k)` corrupts the loop +variable: `k` read 0, 0, 0, then 8195702. + +**What does not reproduce it:** `n = 0: Set f = Forms(n)` outside a loop returns the form +(`f.Name` is `Form1`) and the program exits 0; `For Each f In Forms` and `Unload Forms(i)` work; +`Printers(0)` with a literal index works. + +**Found by** the fix pass for round 8's error-number findings: an EXE that logs a line before +each statement, run once per case with crash dialogs suppressed. The crash itself was +reproduced by the orchestrator; the loop-variable corruption was measured by the fix agent only. + +**Found by** the IDE debugging probe for round 8's UC-61, then a probe of its own run in the IDE +and as the built EXE, with identical results. + +--- + +## A step key pressed on the line that raised an error leaves a step pending + +**Build:** BETA 983 +**Severity:** the debugger stops where it was not asked to, and one command no longer means one +thing. + +1. Run a procedure that raises an untrapped error inside a loop, and let the error panel open. +2. Press F8 (or F10, F11, SHIFT+F8) on the failing line. The line re-runs, the error recurs, + and the mark does not move --- as expected. +3. Now choose **Ignore (Resume Next)**. It stops at the next line instead of running on. + Moving past the line with **Set Next Statement** (CTRL+F9) instead, each F5 then advances + one line. + +Seen in three runs. In the one followed to the end, it lasted until the procedure returned; in +another, a fix-then-F5 stopped once. Which of the two applies was not isolated. + +**What does not reproduce it:** choosing **Ignore** without pressing a step key first, which +runs on from the next line as the panel says. + +**Found by** the IDE debugging probe for round 8's UC-61, driving real keys over DevTools. + +--- + +## Stop at a run-time error ends only the procedure that raised it + +**Build:** BETA 983 +**Severity:** the program goes on running after the user asked it to stop. + +A `Sub Main` that calls a procedure which raises an untrapped error, and prints a line after +the call. At the error panel, choose **Stop** --- the panel's button or the toolbar's. The +failing procedure ends, and `Main`'s following `Debug.Print` still runs. Three runs, the same +each time. + +**What does not reproduce it:** **Stop** at an ordinary break (a breakpoint or a step), which +prints `aborted` and ends the whole run. + +**Found by** the same probe. + +--- + +## A `Static` declaration cannot initialise with a constructor that takes arguments + +**Build:** BETA 983 +**Severity:** a valid declaration does not compile; the workaround is a `Static` without an +initialiser and a `Set` on first use. + +``` +Private Class Dog + Private m_Name As String + Public Sub New(ByVal Name As String) + m_Name = Name + End Sub +End Class + +' in a procedure: +Static s As Dog = New Dog("Rex") +``` + +fails with TB5074, *Could not bind to parameterized constructor of class 'Dog'. No compatible +Sub New() method found*, at the `New`. + +**What does not reproduce it:** the same initialiser on `Dim` (`Dim d As Dog = New Dog("Rex")`), +and on a module-level `Private` or `Public`; a `Static` initialised with a constructor that takes +no arguments (`Static c As Collection = New Collection`); and a `Static` of a value type +(`Static n As Long = 5`). All compile and run. + +**Found by** the fix pass for round 8's UC-59, measuring the forms `New.md` documents. + +--- + +## *Import from file...* leaves the imported package unticked + +**Build:** BETA 983 +**Severity:** the package is imported but not referenced, and the documentation says it is. + +Settings → References → Available Packages → *Import from file...*, and choose a `.twinpack`. The +compiler answers the IDE's `importPackage` request with +`success: true, body: { packageSymbol: "DocProbePkg" }`, and the package appears in the list +unticked, so nothing in the project can use it until it is ticked by hand. +`packageLoadFromFile` in `ide/main.js` reads `packageSymbol` from the response itself rather +than from its `body`, which is consistent with what is seen; that part is read, not traced. + +**Found by** the package probe for round 8's UC-60, which drove the import over DevTools with +the file's path in place of the native picker. + +--- + +## Replacing an embedded package under one Apply keeps running the old copy + +**Build:** BETA 983 +**Severity:** the project builds and runs the old package after the user has replaced it. + +1. A project embeds a package built locally, `DocProbePkg` v1. +2. In Settings → References, untick it; *Import from file...* its v2; tick v2; apply once. +3. The console shows only `[COMPILER] Project settings updated` --- no restart and no save. Builds + keep running v1. Save All and then a compiler restart give v2; a restart *without* saving + brings v1 back, under a reference numbered 1.1.0.0. + +Two runs of two, on a machine with no linked copy of the package. + +**What does not reproduce it:** the same steps with a linked copy of the package present in +`%APPDATA%\twinBASIC\packages` (six runs of six), and an apply after the untick followed by +another after the import and tick (every run): each of those restarts the compiler and saves, +and v2 runs at once. + +**Found by** the same probe. From df498f79176fb1655ed08c783c65eba240fed54a Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Thu, 24 Sep 2026 19:31:39 +0200 Subject: [PATCH 3/8] docs: fix round 8's findings; Export Project empties its folder and the Git section says so, a run-time error in the IDE from panel to stepping, constructors and inheritance as measured, error numbers that are not VBA's, updating a package you built yourself, changing a typeface, CSS for both themes, and the F5, Forms, Printers and Class claims corrected --- docs/Documentation/Authoring.md | 20 ++++- docs/Documentation/Builder.md | 20 ++++- docs/Documentation/Building.md | 18 +++-- docs/Documentation/Extending.md | 23 ++++++ docs/Documentation/Tools.md | 20 +++-- docs/Features/Advanced/Classes-and-Modules.md | 21 +++-- docs/Features/Language/Inheritance.md | 56 ++++++++++++-- .../Packages/Creating a TWINPACK package.md | 5 +- docs/Features/Packages/Import-export tool.md | 17 ++++- ...mporting a package from a TWINPACK file.md | 12 +-- docs/Features/Packages/Linked Packages.md | 6 +- docs/Features/Packages/Updating a package.md | 61 ++++++++++++++- docs/Features/Packages/index.md | 2 +- docs/IDE/Debug Console.md | 12 +++ docs/IDE/Menu/Debug.md | 76 ++++++++++++++++++- docs/IDE/Menu/File.md | 38 ++++++++++ docs/IDE/Project Settings.md | 17 +++++ docs/IDE/Toolbar.md | 2 +- docs/Miscellaneous/FAQs.md | 10 +-- docs/Reference/Attributes.md | 2 +- docs/Reference/Core/Class.md | 4 +- docs/Reference/Core/New.md | 36 ++++++++- docs/Reference/Default/VB/Global/index.md | 9 ++- docs/Reference/Default/VB/Printers/index.md | 6 +- docs/Reference/Default/VBA/Collection/Item.md | 5 +- .../Reference/Default/VBA/ErrObject/Number.md | 19 ++++- .../Default/VBRUN/ErrorContext/index.md | 5 +- docs/Tutorials/Testing-with-Assert.md | 11 ++- 28 files changed, 464 insertions(+), 69 deletions(-) diff --git a/docs/Documentation/Authoring.md b/docs/Documentation/Authoring.md index c8793734..3c754e23 100644 --- a/docs/Documentation/Authoring.md +++ b/docs/Documentation/Authoring.md @@ -261,6 +261,8 @@ Do **not** jump from `#` straight to `###`. That old "house style" --- an h1 fol The repair is a re-levelling of the whole page, not a patch to one heading. The plugin raises **every** heading of level 3 or deeper until each one sits exactly one level below the heading it belongs under, closing every gap in a single pass: `#` / `###` renders as h1 / h2, and `#` / `###` / `#####` renders as h1 / h2 / h3. Only the h1 chapters are left as they are, since a page may legitimately have several. +**Only `#` and `##` headings get an entry of their own in the site search.** The search index cuts each page at its h1 and h2 headings and gives each piece one entry, titled with its heading. A `###` or deeper heading gets no entry: its text is folded into the entry of the nearest `#` or `##` above it. So a section a reader should be able to find by searching for its subject needs a `##` heading. The index is built from the rendered page, after the normalizer has run, so on an old-style page a `###` that renders as h2 does get an entry. + ### Editing a page that still uses the old style The repair is conditional, and the condition is easy to break without noticing. **The plugin runs only on a page that uses `#` and `###` and no `##` anywhere** --- a single `##` and it does not run at all. More than four hundred pages on this site are currently in that state, so on most of them, adding one `##` section disarms the normalizer for the whole page: every `###` that was already there stops being repaired and becomes a live heading-order defect, in the same edit that added a correctly-levelled section. @@ -432,6 +434,17 @@ Diagram exports carry the font with them. The Download / Copy SVG and PNG button **Do not hand-edit a diagram's `.svg`.** It is a build artifact: the `.dot` beside it is the source, and the next build overwrites your edit. Changing the face is the edit that looks most harmless and is not --- Graphviz sizes each box to the text it measured, so a diagram whose labels are painted in a font the layout never saw has text hanging outside its boxes. `check.bat` fails on that; see [Diagrams](#diagrams) below. +## Adding a CSS rule that works in both themes +{: #css-rules } + +A style rule for something new on the site goes in `docs/_sass/custom/custom.scss`. Do not put it in the vendored theme under `builder/vendor/just-the-docs/`, and do not start a stylesheet of your own, which no page would load: everything under `docs/_sass/` is compiled into `just-the-docs-combined.css`, which every page does load. `serve.bat` rebuilds it each time you save. + +**The dark theme is a second copy of the whole theme, not a set of CSS variables.** The build compiles the theme twice and emits the dark copy inside a theme selector such as `html[data-theme="dark"]`, so every theme rule is more specific in dark mode than it is in light. A rule you write with a single class can therefore beat the theme in light mode and lose to it in dark, with no error: `.reversefootnote` did exactly that. Prefix the selector with `.main-content` --- `.main-content .reversefootnote` --- and it wins in both. [The specificity trap](Builder#the-specificity-trap) covers the cases where that is not enough. + +**Check it in both themes.** Run `serve.bat`, open a page that uses the rule, and switch themes with the theme button in the page header rather than with your operating system's setting, because the button is what exercises the `[data-theme]` rules. A rule that fails only in the dark theme is usually cosmetic, and no gate reports it. + +[Project styling](Builder#project-styling) is the full account: which file under `docs/_sass/` holds what, why the theme is compiled twice, and how to verify a style change. + ## Checking that a sample compiles Nothing in the ordinary build looks inside a code fence. The link check, the accessibility @@ -968,8 +981,11 @@ does not. It lists each page it could not place, with the reason: Nav-parent orphan detected in 12 page(s): Features/Example/Child.md: no page titled "Old Title" exists -**The match is on the parent's title, not its file.** Changing a page's `title:` leaves -every page whose `parent:` names the old title without a parent, although nothing moved. +**The match is on the parent's title, not its file, and it is exact, case included:** +`parent: Strings module` does not find the page titled `Strings Module`. Setting +`nav_sort: case_insensitive` in `_config.yml` would not change that, because the build +reads it only to order the sidebar. Changing a page's `title:` leaves every page whose +`parent:` names the old title without a parent, although nothing moved. Change each of those lines to the new title, in the same commit as the rename. One search finds them, and the `grand_parent:` lines that name it as well: diff --git a/docs/Documentation/Builder.md b/docs/Documentation/Builder.md index 078b4c44..985e33f8 100644 --- a/docs/Documentation/Builder.md +++ b/docs/Documentation/Builder.md @@ -10,7 +10,7 @@ permalink: /Documentation/Development/Builder # tbdocs Builder {: .no_toc } -Detailed technical documentation for the `tbdocs` static site generator at [`builder/`](https://github.com/twinbasic/documentation/tree/main/builder). Read this when modifying the build pipeline itself; content contributors who only need to build, preview, and ship documentation should not need any of it. +Detailed technical documentation for the `tbdocs` static site generator at [`builder/`](https://github.com/twinbasic/documentation/tree/main/builder). Read this when modifying the build pipeline itself. Content contributors who only build, preview and ship documentation need none of it, with one exception: anyone adding or changing a CSS rule needs [Project styling](#project-styling), the full account of where a rule goes and of why one that works in the light theme can silently do nothing in the dark one. Module-level documentation lives next to the code: @@ -523,6 +523,22 @@ Run `serve.bat` and look at the page **in both themes**. This is not a formality Then `build.bat && check.bat`. A malformed rule surfaces as an SCSS compile failure, which warns with the source location and flips the exit code rather than aborting --- so the previous build's CSS lingers in `_site/` and the site appears to still work; read the build output, do not judge by the page. `check.bat`'s accessibility scan covers every sample page in both themes for exactly the reason above, and its `target-size` and `color-contrast` rules are where a geometry or palette change lands. If the new component introduces markup the site has not used before, also add a construct family to `scripts/pick_a11y_sample.mjs` --- see [Tools and Scripts](Tools#pick-a11y-sample) --- or no axe rule keyed on it will run anywhere. +## Changing a typeface + +This follows on from [Project styling](#project-styling). The site uses three faces --- Inter for text, Cascadia Mono for code, and Source Serif 4 for the body text of the PDF book --- and the build names them in far more places than the stylesheet. Only some of those places fail loudly when one is missed. In the order a change would go: + +1. **The font files** (all three faces). [`scripts/build_fonts.py`](Tools#build-fonts) downloads each face's pinned release, verifies its SHA-256, subsets it, and writes the `.woff2` files and their licences into `docs/assets/fonts/`. A new face is an entry in its `SOURCES` table --- the release URL, the hash and the licence --- and one entry per file in `FACES`: the file inside the archive, the pinned axes, the Unicode ranges and the output name. Keep the output `.woff2`, which is the only font format the [publish allowlist](Building#what-the-build-refuses-to-publish) accepts. +2. **The web stylesheet** (Inter and Cascadia Mono). `docs/_sass/custom/_fonts.scss` holds the `@font-face` rules, inside the `emit-font-faces` mixin, and the `$tb-body-font-family` and `$tb-mono-font-family` stacks, which `docs/assets/css/just-the-docs-combined.scss` passes into the theme. `docs/_sass/modules-dark.scss` passes them again for the dark compilation, and `docs/assets/css/just-the-docs-dark.scss` uses the mono stack once more for `pre`, `kbd` and `samp`. All of these read the two variables, so replacing a face inside an existing stack changes nothing in them. A new stack has to be passed in both compilations, or the dark theme keeps the system fonts --- the [specificity trap](#the-specificity-trap) again. +3. **The preloads** (the two roman web faces). `fontPreloads()` in `builder/template.mjs` preloads `inter-variable.woff2` and `cascadia-mono-variable.woff2` by name, from its `PRELOAD_FONTS` list. It sets `crossorigin`, which a font preload needs even from the same origin: without it the browser downloads the file twice. A preload of a file that does not exist is a broken link on every page, and the build's link check reports it. The link-check fixture under `test/fixtures/check-src/assets/fonts/` holds stub files under the same two names, so renaming either file means renaming its stub too, or [`check_links_diff.mjs`](Tools#check-links-diff) fails on every pull request. +4. **The book** (all three faces). `docs/assets/css/print.css` is the whole design of the PDF and loads nothing from the Sass build, so it has its own `@font-face` block and its own stacks. `REQUIRED_FONTS` in `builder/pdf.mjs` names the same six files and copies them into the sparse `_site-pdf/` tree. Keep the two in step: the PDF pass aborts on a listed file that does not exist, and a face `print.css` uses that was not copied fails to load, which aborts the book render. +5. **Diagram exports** (Inter and Cascadia Mono). `FONT_FILES` in `docs/assets/js/svg-inline.js` maps each family name to its weight range and file names, and the Download and Copy buttons embed those files in an exported SVG or PNG. A family it does not list is exported without its face and without a message; a listed file that no longer exists costs one console warning. +6. **The Gantt chart** (Inter). `builder/gantt.mjs` writes a `