tosijs-ui 1.13.0 → 1.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +793 -0
  2. package/bin/caddy-install.ts +17 -32
  3. package/bin/deploy-preview.ts +14 -7
  4. package/bin/release-notes.ts +15 -4
  5. package/bin/resolve-site-config.ts +77 -0
  6. package/bin/tunnel.ts +15 -7
  7. package/dist/.metadata_never_index +0 -0
  8. package/dist/babylon-3d.d.ts +14 -0
  9. package/dist/babylon-3d.js +52 -0
  10. package/dist/code-editor.d.ts +1 -0
  11. package/dist/code-editor.js +50 -2
  12. package/dist/codemirror.d.ts +2 -0
  13. package/dist/codemirror.js +43 -0
  14. package/dist/data-table.d.ts +34 -1
  15. package/dist/data-table.js +257 -18
  16. package/dist/doc-browser.d.ts +15 -0
  17. package/dist/doc-browser.js +91 -12
  18. package/dist/doc-system/doc-system-styles.js +14 -6
  19. package/dist/doc-system/doc-system.js +15 -0
  20. package/dist/doc-system/routing.d.ts +16 -0
  21. package/dist/doc-system/routing.js +20 -0
  22. package/dist/doc-system/site/audit-guard.d.ts +22 -0
  23. package/dist/doc-system/site/audit-guard.js +69 -0
  24. package/dist/doc-system/site/audit-reach.d.ts +16 -0
  25. package/dist/doc-system/site/audit-reach.js +56 -0
  26. package/dist/doc-system/site/build-lock.d.ts +11 -2
  27. package/dist/doc-system/site/build-lock.js +12 -1
  28. package/dist/doc-system/site/build-stamp.d.ts +18 -0
  29. package/dist/doc-system/site/build-stamp.js +77 -0
  30. package/dist/doc-system/site/dev-auth.d.ts +9 -3
  31. package/dist/doc-system/site/dev-auth.js +54 -24
  32. package/dist/doc-system/site/dev-server.js +93 -29
  33. package/dist/doc-system/site/editable-sources.d.ts +14 -0
  34. package/dist/doc-system/site/editable-sources.js +86 -0
  35. package/dist/doc-system/site/epub.js +1 -7
  36. package/dist/doc-system/site/generate-site.d.ts +10 -0
  37. package/dist/doc-system/site/generate-site.js +12 -8
  38. package/dist/doc-system/site/host-preset.d.ts +53 -0
  39. package/dist/doc-system/site/host-preset.js +116 -0
  40. package/dist/doc-system/site/invite-page.d.ts +2 -2
  41. package/dist/doc-system/site/invite-page.js +10 -3
  42. package/dist/doc-system/site/make-llms-txt.d.ts +8 -1
  43. package/dist/doc-system/site/make-llms-txt.js +2 -2
  44. package/dist/doc-system/site/orchestrator.d.ts +12 -0
  45. package/dist/doc-system/site/orchestrator.js +231 -10
  46. package/dist/doc-system/site/preflight.d.ts +26 -2
  47. package/dist/doc-system/site/preflight.js +97 -14
  48. package/dist/doc-system/site/site-config.d.ts +29 -7
  49. package/dist/filter-builder.js +35 -1
  50. package/dist/icon-types.d.ts +16 -1
  51. package/dist/iife.js +112 -92
  52. package/dist/iife.js.map +42 -42
  53. package/dist/index-iife.js +20 -1
  54. package/dist/index.d.ts +0 -4
  55. package/dist/index.js +40 -4
  56. package/dist/live-example/code-transform.d.ts +3 -0
  57. package/dist/live-example/code-transform.js +58 -12
  58. package/dist/live-example/component.d.ts +15 -0
  59. package/dist/live-example/component.js +19 -0
  60. package/dist/live-example/execution.js +9 -2
  61. package/dist/live-example/index.d.ts +1 -1
  62. package/dist/live-example/index.js +1 -1
  63. package/dist/live-example/insert-examples.js +26 -0
  64. package/dist/mapbox.js +47 -4
  65. package/dist/menu.d.ts +35 -1
  66. package/dist/menu.js +57 -3
  67. package/dist/rich-text.d.ts +2 -1
  68. package/dist/segmented.d.ts +2 -1
  69. package/dist/segmented.js +1 -1
  70. package/dist/select.d.ts +1 -0
  71. package/dist/select.js +7 -0
  72. package/dist/sizer.d.ts +1 -1
  73. package/dist/tsconfig.tsbuildinfo +1 -1
  74. package/dist/version.d.ts +1 -1
  75. package/dist/version.js +1 -1
  76. package/llms.txt +3 -1
  77. package/package.json +9 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,798 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.14.1
4
+
5
+ ### The doc-test gate can tell a dropped page from a passing one
6
+
7
+ `__docTestResults` reported `{ passed, failed, pages }`, so a consumer gate could assert that
8
+ tests ran and that none failed — but not that **all** of them ran. A runner that silently lost
9
+ half the corpus satisfied every check.
10
+
11
+ That is not hypothetical. The false green fixed in 1.14.0 was a page-**selection** defect: a
12
+ substring match counted 17 pages where 16 had tests, and ran a prose page's examples in a
13
+ background iframe. Fixing the selection did not add a guard against the next one.
14
+
15
+ The resolved object now carries `pagesWithTests` and `pagesTested`, and
16
+ `tests/doc-tests.pw.ts` asserts three things where it previously asserted one: the corpus
17
+ contains test pages at all, every such page reported, and some assertions executed. A corpus
18
+ with genuinely no tests resolves with honest zeros, so "no tests exist" stays distinguishable
19
+ from "tests exist and ran".
20
+
21
+ Framed by tosijs-platform in #142, from a suite that reported **140 pass, 0 fail while twelve
22
+ cases had never executed** — skip-guarded behind emulators that were not running, asserting
23
+ `expect(true).toBe(true)`. When the emulators were finally started it found three production
24
+ bugs in ten minutes. The tests were fine; the reporting was a lie, and a comfortable one, which
25
+ is why it survived. **"We didn't look" and "we looked and it's fine" must not produce the same
26
+ output** — a reported total is that same lie one level up.
27
+
28
+ ### New page: "Adopting the doc system"
29
+
30
+ Three of the four onboarding defects this release fixes (#144, #145, #146) would have been
31
+ prevented by a page that did not exist — a narrow path to read *before* the first build, as
32
+ opposed to a configuration reference nobody reads until something is already wrong.
33
+
34
+ It is organised around the thing that makes this system hard to adopt: **its failure mode is a
35
+ site that serves perfectly and shows nothing.** Not a crash, not a 404 — pages return 200, the
36
+ markup is in the HTML, the adopter's own elements register, and the page is inert. So the page
37
+ leads with a "how to tell it actually worked" checklist (is `tosi-doc-system` defined, is the
38
+ chrome there, read one emitted `<head>`, do the examples run) and then explains the four silent
39
+ failures behind those checks.
40
+
41
+ Includes the complete `bundleEntry` file rather than naming it, since "your entry should import
42
+ what your pages need" was the sentence that cost #145's reporter the most — it reads as *your*
43
+ content, not as the doc system that renders the entire site.
44
+
45
+ ### `<tosi-table>` filters and sorts the whole table, not the first `maxVisibleRows` (#147)
46
+
47
+ The window was applied **first**, so both the filter and the sort saw only the first N rows.
48
+ Two consequences, both reported from production at 300k rows:
49
+
50
+ - **A matching row past the cap could never be found.** A newly-created company appended to a
51
+ 12,000-row list, with the cap at 10,000, did not exist as far as search was concerned.
52
+ - **Sorting did not sort the table.** You got the first N rows in *data* order, arranged
53
+ nicely — not the top N by the sort.
54
+
55
+ This was known and the answer had been a warning: the comment in the render path read *"every
56
+ count, filter and sort then ran on the truncated set, so the numbers were self-consistent and
57
+ wrong"* (#82). A warning does not help someone whose search returns nothing, and it named row
58
+ counts rather than the consequence — so "showing 10,000 of 12,000 rows" gave no clue why a
59
+ search failed.
60
+
61
+ The order is now **filter → force → sort → cluster → window**. `maxVisibleRows` is a *layout*
62
+ ceiling — the browser's maximum element height — so it belongs at the end, against what will be
63
+ drawn, rather than at the start against the data.
64
+
65
+ **Cost, measured on 300k rows**, since this moves the sort from "at most `maxVisibleRows`" to
66
+ "every match": the common case (no sort set) is **0.9ms, down from 2.9ms**; a filter narrowing
67
+ to ~1% is 2.0ms; a broad sort over all 300k is **108ms**. That last one is the price of sorting
68
+ the real table, and it is paid when you click a column header — but `pinColumns()`, a schema
69
+ change and a column resize all trigger a render without changing row order, so the pipeline is
70
+ memoized against the identities that determine it (array, filter, sort, groupId, forced ids).
71
+ A render that changes none of them reuses the result.
72
+
73
+ The truncation warning now counts against the **filtered** set — "10,000 of 300,000" is
74
+ alarming and wrong once a filter has narrowed things to 40 — and says that the rows shown are
75
+ the right ones, there are simply more than the browser can lay out.
76
+
77
+ ### `menuClass` — theme one component's menus without restyling the page (#148)
78
+
79
+ Menu metrics are all theme variables, and setting them on your component **did not work**.
80
+ The popup is built per-invocation and mounted in a body-level `<tosi-float>`, so it is not a
81
+ descendant of whatever opened it, and custom properties have nothing to inherit from. The only
82
+ selector that reached it was `:root` — which restyles every menu on the page, including the doc
83
+ system's own when your component is documented on a `tosijs-ui/site` site. The reporter had not
84
+ shipped their denser menubar for exactly that reason: a component reaching out to re-theme the
85
+ page around it is the wrong direction.
86
+
87
+ ```
88
+ popMenu({ target, menuItems, menuClass: 'my-editor-menu' })
89
+ <tosi-menu menu-class="my-editor-menu">
90
+ <tosi-select menu-class="my-listbox"> // its listbox mounts the same way
91
+ ```
92
+
93
+ The class lands beside `xin-menu tosi-menu`, takes a space-separated list, and **propagates to
94
+ submenus** — which are separate popups, so without that the theming would apply at the top
95
+ level and silently revert one level down. `menuWidth` was already a per-menu attribute, so
96
+ per-menu styling is not new here; this is the same idea for the rest of the knobs.
97
+
98
+ Chosen over the two alternatives in the report: copying the trigger's computed variables onto
99
+ the popup needs no change from adopters but is considerably more magic, and stamping a
100
+ `data-menu-owner` back-reference solves selection without solving theming.
101
+
102
+ ### Adoption blockers found by tosijs-editor onboarding (#144, #145, #146)
103
+
104
+ Three defects and a credential, all found by one project adopting `tosijs-ui/site` for the
105
+ first time. Every one of them failed silently.
106
+
107
+ **A Mapbox token in `dist/` blocked adopters' pushes (#145).** Two `<tosi-map>` doc examples
108
+ carried a literal `pk.` token, which compiled into `dist/mapbox.js`, inlined into `iife.js`,
109
+ and landed in every adopter's sourcemap. It matches Mapbox's published secret pattern, so
110
+ **GitHub push protection blocked adopters the first time they committed their built site** — a
111
+ wall with someone else's name on it.
112
+
113
+ To be clear about what this is and is not: Mapbox `pk.` tokens are **public by design** —
114
+ Mapbox's own docs tell you to put them in client-side JavaScript — so the scanner match is a
115
+ false positive and there was never a secret here. The demos still use the token; it is now
116
+ assigned programmatically and base64'd, which is **not** a security measure and would be a
117
+ poor one. It exists solely so the literal string stops matching a regex. `atob()` protects
118
+ nothing from anyone, which is precisely why it suits a value that needs no protection.
119
+
120
+ `src/no-secrets.test.ts` fails the build on any credential-shaped string in `src/`, and says
121
+ plainly what it cannot see: an encoded value, deliberately. It catches accident, not intent.
122
+
123
+ **`bundleEntry` REPLACES the default bundle rather than extending it (#145).** An entry that
124
+ imports only your own library produces a site where every page renders its prerendered markup
125
+ and nothing else — no header, no nav, no menu, no live examples — with no console error, no
126
+ build warning and no 404. `<tosi-doc-system>` sits in the HTML, inert, because nothing defined
127
+ it, while your own elements register fine and make the bundle look healthy. The jsdoc now says
128
+ this in the imperative with the two-line fix, and the build **scans the emitted bundle and
129
+ warns** when `tosi-doc-system` is absent (and `tosi-example`, when the corpus has executable
130
+ fences). Grepping is sound here for once: `customElements.define` needs the literal tag, so
131
+ minification cannot erase it.
132
+
133
+ **`baseUrl` meant two different things (#144).** `generate-site` treated it as the origin and
134
+ added `basePath` on top; `make-llms-txt` treated it as origin-plus-path and ignored `basePath`
135
+ entirely. On a GitHub project page no configuration satisfied both — matching the canonical
136
+ URLs doubled the prefix in `llms.txt`, matching `llms.txt` dropped it from every canonical and
137
+ sitemap entry. Underneath were **two copies of `withBase`** (`generate-site`, and `epub`, whose
138
+ comment read "mirrors generate-site's withBase") plus this third consumer with none: one rule,
139
+ three implementations, one of them empty. `withBase` now lives once in `routing.ts`, `llms.txt`
140
+ applies it, `baseUrl` is documented as origin-only, and the build warns on the combination that
141
+ is always wrong.
142
+
143
+ **Which fence languages execute was undocumented (#146).** Six run — `js`, `ts`, `tjs`,
144
+ `html`, `css`, `test` — and the only place the distinction appeared named a subset, so the
145
+ mental model everyone formed was "js/ts/tjs/test run, everything else is display". A ` ```css `
146
+ fence showing how to style the component in your app is injected as a page-wide `<style>`; a
147
+ lone ` ```html ` fence showing the markup something compiles to renders as a broken-looking
148
+ example. Neither fails the build. Now documented as a table with display-only alternatives
149
+ (`xml` for markup), along with the grouping rule — adjacent fences form one example, prose
150
+ between them starts a new one — which is why moving a `test` block under its own heading
151
+ silently detaches it from the demo it was written against.
152
+
153
+ ## 1.14.0
154
+
155
+ ### Quote style no longer decides whether a live example runs (#141)
156
+
157
+ The live-example import rewriter accepted `'single-quoted'` specifiers and rejected
158
+ `"double-quoted"` ones — the same import, the same `{ named }` form, differing only in quotes.
159
+
160
+ On its own that is a papercut. What made it serious is **Prettier**: it formats fenced code
161
+ inside markdown and normalises quotes to double, so on any project that formats its `.md`,
162
+ every live example was silently converted into a non-running one. The only symptom was a
163
+ build-time warning that reads as advisory.
164
+
165
+ Reported from tosijs-3d-ensemble, where the README's headline example — the one showing what a
166
+ game imports — had never run for the life of the repo. Because it never ran, nothing noticed it
167
+ also named a function the package has never exported. Two defects in the most-read code block
168
+ there, both invisible for the same reason. Found by the doc-test corpus, not by reading.
169
+
170
+ All three context forms (`{ named }`, `* as ns`, default) now accept either quote style, as
171
+ does the `.elements` accessor.
172
+
173
+ **And the error message named the wrong thing.** It listed the supported forms and the context
174
+ packages — so a failing `import { x } from "pkg"`, a listed package in a listed form, was
175
+ answered with a sentence that ruled out its own cause. The reporter spent ~40 minutes rewriting
176
+ a multiline import that was never the problem. The message now separates the two real causes: a
177
+ specifier the context does not carry, versus a clause shape the rewriter cannot handle.
178
+
179
+ ### `<tosi-code>`'s own diff overlay is readable by default (#143)
180
+
181
+ `<tosi-diff>` resolves its surface as `--tosi-diff-bg || --background`. Inside `<tosi-code>`,
182
+ `--background` is still the page's white while `--text-color` is deliberately the light code
183
+ colour — so the diff `showDiff()` mounts rendered near-white text on white, and its unchanged
184
+ lines were invisible. The one place the component mounts a diff itself was the one place the
185
+ default did not work.
186
+
187
+ `--background` is now redefined for that subtree to follow `--code-bg`, so the nested diff
188
+ inherits the editor's palette. Deliberately **not** by setting `--tosi-diff-*` here: an inner
189
+ definition beats an inherited one, so that would have overridden the very custom properties
190
+ adopters are already using as an escape hatch — a silent break of the thing the issue was
191
+ about. A consumer's `--tosi-diff-bg` is still checked first and still wins.
192
+
193
+ ### Live `ts` examples load a pinned TypeScript compiler
194
+
195
+ tjs-lang's `fromTS` lazy-loads the TypeScript compiler from `DEFAULT_TYPESCRIPT_URL` —
196
+ `https://esm.sh/typescript@5`, an **unpinned major range**. It was the one hop in the entire
197
+ live-example chain that was neither same-origin nor pinned: everything else routes through
198
+ `__TJS_LOCAL_BASE` or a `TJS_VERSION`-pinned CDN. Because it is fetched at runtime it appears
199
+ in no lockfile, so `bun audit` cannot see it and "which compiler ran in a reader's browser" had
200
+ no answer.
201
+
202
+ Now pinned, via the `typescriptUrl` option tjs-lang already exposes, to the **exact version
203
+ this repo type-checks with** — so a doc example is lowered by the same compiler that validates
204
+ the source it was extracted from. A drift test asserts the pin against the **installed**
205
+ compiler rather than the declared range, because `typescript` is a caret dependency here and a
206
+ range check would call `^5.9.3` satisfied while the pin said something else.
207
+
208
+ esm.sh stays as the host — tjs-lang measured it as the only CDN that reliably serves
209
+ `typescript` as ESM. The fix is the pin, not the host; a same-origin copy is the upstream ask.
210
+
211
+ Nobody was exposed: this repo's corpus contains no executable ` ```ts ` fences.
212
+
213
+ ### `MenuAction.action` is optional, so a drop-only menu item is expressible
214
+
215
+ `acceptsDrop` + `dropAction` with no `action` — a target you can drag onto but not click — is
216
+ a shape the runtime supports and `popDropMenu`'s docs describe, but the type required `action`.
217
+ When the new typecheck lane compiled the tests for the first time, four literals were silenced
218
+ with `as unknown as MenuItem` rather than the type being corrected.
219
+
220
+ The casts are gone. Two tests now pin what the widened type admits: a drop-only item appears
221
+ **disabled** in a click menu and is removed only under `hideDisabled` — the documented
222
+ `hideDisabled` contract. (The first draft of that test asserted it was filtered out entirely,
223
+ and failed; the finding implied a behaviour the code does not have.)
224
+
225
+ ### `buildSite` docs: `dist/` is reset only when the build owns it (#130)
226
+
227
+ Three shipped surfaces still promised an unconditional reset — the config table and the
228
+ gotchas list in `doc-site-system.md`, and the `prebuild` JSDoc, which reaches
229
+ `dist/doc-system/site/site-config.d.ts` and is what an adopter reads on editor hover.
230
+
231
+ `dist/` is cleared only when `emitLibrary` or `libraryTsconfig` means this build produces it.
232
+ A `libraryBuild` function owns the directory and may deliberately emit a subset, so it is never
233
+ cleared — **if you supply `libraryBuild`, cleaning `dist/` is yours.** The output dir is still
234
+ always reset, which is the part `prebuild` authors actually need to know.
235
+
236
+ ### Security: the source editor can no longer write files the build executes (#128)
237
+
238
+ `editableSources` writes were contained only to the project root — which includes
239
+ `.git/hooks/*`, `bunfig.toml` (preload), `package.json` scripts and `bin/`, every one of which
240
+ runs on the developer's next ordinary command. With CSRF closed (#90/#121) there was no known
241
+ path to an unauthorised write; this removes the *consequence* of a future one.
242
+
243
+ `editableSourcePaths` / `mayEditSource` build an **allow-list derived from the doc corpus**,
244
+ not a deny-list of dangerous names: the endpoint exists to edit the source of a page you are
245
+ looking at, so the writable set is exactly what the extractor scraped. A deny-list needs
246
+ extending every time someone invents a new way to make a file execute and is wrong by default;
247
+ this is right by default. It **fails closed** — an unreadable corpus permits nothing.
248
+
249
+ **The first version of this was not enough, and the pre-release review caught it.** A doc
250
+ source can itself be an executed script: `bin/make-icon-data.js` carries a `/*#` block, so it
251
+ was a doc source, so it was writable — and `bin/dev.ts` spawns it on *every* build. The
252
+ original tests passed because the synthetic corpus omitted `bin/`, and the live check used
253
+ `.git/hooks/pre-commit`, which the corpus never contained. Both proved something true and
254
+ irrelevant. There is now an `isExecutedByBuild` deny layer (all of `bin/`, the spawned `-cli`
255
+ scripts, `generate-css`), and the tests run against the **shipped** corpus with an assertion
256
+ that the dangerous entry is really in it — otherwise the refusal test proves nothing.
257
+
258
+ The **read** half is gated by the same predicate. It was left open initially on the grounds
259
+ that the reported issue was a drive-by write; the review was right that the argument for
260
+ symmetry was stronger, since "view source" only ever reads a doc's own source.
261
+
262
+ ### The dependency audit knows where a package sits (#56)
263
+
264
+ `runtimeReachable` / `classifyReach` walk the manifest graph and label an advisory's package
265
+ **runtime** or **build-only**. Reported from an app monorepo running `buildSite`: 18
266
+ high/critical advisories of which the runtime-reachable subset was a small fraction, so
267
+ blocking on all of them would have bricked local dev over risk they do not carry.
268
+
269
+ Conservative by construction — root `dependencies`, `optionalDependencies` **and
270
+ `peerDependencies`** are runtime seeds (a peer ends up in the consumer's app), and an
271
+ unresolvable package counts as reachable. A finding silently downgraded is the one outcome
272
+ worth engineering against.
273
+
274
+ `audit.blockOn: 'severity' | 'runtime'` defaults to `'severity'` — today's behaviour. Set
275
+ `'runtime'` and a build-only finding is reported but does not block. If the manifest walk fails
276
+ the filter is disabled entirely, because "we could not tell" must not read as "not reachable".
277
+
278
+ A build that passes **only because of an active gate** now says so:
279
+
280
+ ```
281
+ 🟡 Build: PASSING ON A WAIVER — 1 finding(s) at or above high are suppressed
282
+ by an active gate. This is not a clean audit.
283
+ ```
284
+
285
+ Only when the waiver is load-bearing: a gate suppressing something below the threshold would
286
+ have passed anyway, and flagging that would train people to ignore the marker.
287
+
288
+ ### The doc-test runner stopped executing pages that only *mention* tests
289
+
290
+ `docsWithTests` matched ` ```test ` as a **substring anywhere in the document**, so a page
291
+ merely writing about the test tier qualified — and was rendered in a background iframe with its
292
+ examples executed. A failure in an example on a page with *no tests* therefore surfaced through
293
+ the doc-test tier, and only sometimes, depending on how quickly the TypeScript compiler loaded.
294
+
295
+ That produced a **false green**: a standalone doc-tests run reported "62 passed" against a build
296
+ the full suite failed on. Page selection now requires a fence at the start of a line, the same
297
+ rule the doc extractor applies to `/*#`.
298
+
299
+ The example it was hiding was ours: a ` ```ts ` fence in `doc-site-system.md` — added in the
300
+ #93 write-up — was **executable**, so the doc-test lane ran `Bun.spawn` and `process.on` in a
301
+ browser and threw `ReferenceError: process is not defined`. It is now ` ```typescript `, which
302
+ is display-only. One character, and it had been red on `main` from the moment it was pushed.
303
+
304
+ Related and still open (#138): an example that *compiles and then throws* is caught by nothing
305
+ on a page without a ` ```test ` block. The build-time check catches what will not compile, not
306
+ what compiles and fails.
307
+
308
+ ### An existing `firebase.json` is checked against `outputDir` (#134)
309
+
310
+ `host: 'firebase'` scaffolds a config for a fresh project and keeps its hands off an existing
311
+ one — but checked nothing, so `buildSite` could write `docs/` while `firebase deploy` published
312
+ `.demo/`. Both commands succeed and the site serves whatever was there last. It now warns,
313
+ handling multi-site arrays, framework-aware hosting with no `public`, and `./docs` vs `docs`
314
+ spelling, and warns only when *every* declared target disagrees.
315
+
316
+ ### tjs-lang 0.13.4 → 0.13.11, and the two pins can no longer drift (#135)
317
+
318
+ We were pinned to a version npm has **deprecated**, seven releases behind. The deprecation's
319
+ threat model does not reach live-example — it is about a server transpiling submitted source —
320
+ but sitting on a deprecated version with seven releases of fixes is its own problem.
321
+
322
+ `TJS_VERSION` (what the *published site* fetches from a CDN) and the exact devDependency (what
323
+ tests and local builds use) live in different files, so a test now asserts they agree. Drift
324
+ there is invisible exactly where it matters: every lane stays green while the deployed site
325
+ transpiles with a different version.
326
+
327
+ ### `<tosi-example>` warns when executable fences collide (#139)
328
+
329
+ `js`/`tjs`/`ts` all write the same single-valued slot, so a second executable fence silently
330
+ overwrote the first — the earlier block vanished from the page, the example ran as the later
331
+ dialect, and nothing said so. Behaviour is unchanged (last one still wins); it now names the
332
+ file and which dialect survived. This is the seam a dialect selector needs, which is why it is
333
+ worth fixing before that feature rather than during it.
334
+
335
+ ### `getSelectText` returned the empty string on every call
336
+
337
+ `<tosi-filter>` read `select.options[select.selectedIndex]`, and `selectedIndex` has never
338
+ existed on `TosiSelect` — it selects by value. So every filter chip read ` "needle"` with two
339
+ blanks where the field and condition belong, which looks like a styling bug. Found by adopting
340
+ tosijs 1.10.0, whose removal of `Component`'s `[key: string]: any` index signature made the
341
+ compiler able to see it for the first time.
342
+
343
+ ### Markdown is no longer formatted, and `bun format` is green again
344
+
345
+ `*.md` is in `.prettierignore` by request from tosijs. `proseWrap` was already `preserve`, so
346
+ prettier's only remaining markdown behaviours were escaping literal characters
347
+ (`a * literal` → `a \* literal`), padding table cells, and rewriting `*` bullets — the first of
348
+ which edits a document's *content*, and markdown is the product here.
349
+
350
+ Separately, `bun format` had been exiting 1 on seven ESLint errors: CI gates `format-check`,
351
+ which runs prettier only, so the ESLint half of the documented pre-commit command was red
352
+ without failing anything. Among the fixes: two dead locals in the CSRF same-origin check
353
+ (checked rather than assumed — the logic was sound, only the initialisers were dead); a dead
354
+ `explanation` in `dev-server.ts` that was built and never used, left over from before
355
+ `invite-page.ts` took ownership of that copy in #114; and an
356
+ `AsyncFunction` construction failure that now rethrows with `{ cause }` so the engine's own
357
+ error — the one naming *which* parameter it choked on — is not discarded.
358
+
359
+ ### `icons.*` was typed `SVGElement`, and composites are spans
360
+
361
+ **This is why the release is a minor.** `SVGIconMap` declared every icon as
362
+ `ElementCreator<SVGElement>`. A **composite** icon — any name containing `$`, the stacking
363
+ syntax `icon-composition.md` documents and the demos use throughout — returns an
364
+ `HTMLSpanElement`.
365
+
366
+ Measured rather than reasoned about:
367
+
368
+ ```
369
+ icons['tosiHat$tosi']() → SPAN, instanceof SVGElement === false
370
+ icons.lock() → svg, instanceof SVGElement === true
371
+ ```
372
+
373
+ `resolveIcon` has always returned the honest `Element`; only the cast on the exported proxy
374
+ disagreed. It is now `ElementCreator<IconElement>`, and **`IconElement` is exported from
375
+ `icon-types.ts` for consumers holding an icon** — defined as `SVGElement | HTMLSpanElement`.
376
+ Both members carry `.style` and `.dataset`,
377
+ which is what callers actually reach for — widening all the way to `Element` would break
378
+ working code to no purpose.
379
+
380
+ **Why a minor rather than a patch.** Nothing about the runtime changed; a composite has always
381
+ been a span. But code that annotated an icon as `SVGElement` compiled before and does not now,
382
+ and "a patch never breaks you" is the contract. This breaks you out of a false belief, which is
383
+ still breaking.
384
+
385
+ **The lie was load-bearing.** Correcting it surfaced **14 sites in this repo** — `data-table`,
386
+ `notifications`, `rich-text`, `segmented` — that stored or passed an icon as `SVGElement`. Any
387
+ consumer doing the same has the same latent bug, which is the argument for fixing the type
388
+ rather than the tests. If your build newly complains, that is the bug being shown to you: the
389
+ fix is `IconElement`, or `as SVGElement` at a site you know is not a composite.
390
+
391
+ ### The test suite is type-checked now (#73)
392
+
393
+ `tsconfig.json` excludes `*.test.ts` — right for emit, and it meant nothing ever compiled them.
394
+ **48 errors had accumulated unseen** (the issue counted 40 when it was filed; it grows).
395
+ `tsconfig.tests.json` plus a CI step brings it to zero and holds it there.
396
+
397
+ The icons defect above is what that gate found on its first run. Two test files carried
398
+ duplicate imports — `sessionCookie` and `nodePath` — which is invalid ESM that the runtime
399
+ tolerated and nothing checked. The other 46 were genuine test-side noise, fixed honestly
400
+ rather than with blanket `any`.
401
+
402
+ A type-level test asserts nothing if nothing compiles it — which was already the argument for
403
+ `typecheck-guards`, applied to everything else.
404
+
405
+ ### The test lane itself, honestly
406
+
407
+ Three things the pre-release review found in the tests rather than in the code, recorded
408
+ because a suite that is quietly wrong is worse than one that is loudly red.
409
+
410
+ **Six tests were deleted and the suite stayed green.** Adding the #139 cases to
411
+ `insert-examples.test.ts`, `cat >` was used instead of `cat >>`, wiping two `describe` blocks
412
+ that covered still-shipping behaviour — grouping across a baked `<script>`, and fenced
413
+ execution modes. Nothing complained, because fewer tests all passed. That is the exact shape of
414
+ a coverage regression no gate catches: green is not a measurement of how much you asked. The
415
+ blocks are restored, and the #139 cases rewritten against the same stub creator the originals
416
+ used.
417
+
418
+ **`ENOENT reading "https://esm.sh/typescript@5"` during `bun test`** came from the same
419
+ rewrite: the real `liveExample` transpiled a `ts` fence, and the rejection leaked across test
420
+ files, printing under an unrelated file's header. Zero occurrences now — and worth noting that
421
+ the message named a URL while claiming `ENOENT`, which is why it read as noise for as long as
422
+ it did.
423
+
424
+ **`caddy-install.test.ts` was flaky about one run in ten.** Each of its tests cold-starts a
425
+ real `bun` to exercise the generated remote script, so its cost is interpreter startup — ~1.2s
426
+ each, with the file swinging 3.5s to 10.1s under load. Under bun's 5s default that put single
427
+ tests over the line whenever the machine was busy. They now carry a 20s budget that describes
428
+ the work; a test exceeding *that* is a real defect rather than machine load.
429
+
430
+ ### Every bin answers `--help`, and refuses what it does not understand (#85)
431
+
432
+ `caddy-install`, `deploy-preview`, `tunnel` and `release-notes` each carried their own
433
+ `flag()`/`has()` pair. One shared `parseArgv` replaces all four.
434
+
435
+ **Refusing unknown flags is the load-bearing part.** `tosijs-caddy-install --status` — a flag it
436
+ never had — used to read as "no flags at all", and the bin went on to ssh into the configured
437
+ host. The same shape was still live in `tosijs-deploy`. A tool that contacts a machine must not
438
+ read an instruction it does not understand as consent to proceed.
439
+
440
+ `--help` comes with it, and is parsed **before** the site config is read, so it works from any
441
+ directory. That matters because no lane runs a shipped bin's argument handling —
442
+ `smoke-consumer.ts` checks shebangs and deliberately does not execute the bins that reach the
443
+ network — so `tosijs-release-notes --help` shipped unrecognised and died on a raw `ShellError`
444
+ outside a git repo.
445
+
446
+ Also: `deploy:index` now asks for `PREVIEW_HOST` like the bins, instead of the `PREVIEW_SSH`
447
+ they moved off.
448
+
449
+ ### Magic-link codes are eight letters, no digits (#132)
450
+
451
+ ```
452
+ 🔗 code: JCYGCCVX (case-insensitive)
453
+ ```
454
+
455
+ The code is typed by hand into a floating keyboard on a headset. Crockford base32 was already
456
+ chosen for typo-resistance and that was right as far as it went — but it contained **digits**,
457
+ and on a headset the letter↔number switch is a trip to a different keyboard page. A reported XR
458
+ session minted `A70MDCD` and `X9Q3Z53`: three digits each in seven characters, so three round
459
+ trips for a code being gaze-and-pinched one glyph at a time.
460
+
461
+ **The extra character is free; the mode switch is not.** Eight letters of a 22-letter alphabet
462
+ is **35.7 bits against the old seven-of-32's 35.0** — slightly _stronger_, while never leaving
463
+ the alphabetic keyboard.
464
+
465
+ `I`, `L`, `O` and `U` stay excluded. `I`/`L` are indistinguishable across case, which matters
466
+ _more_ now that there are no digits, because the code is case-insensitive so the reader cannot
467
+ use case to tell them apart. Redemption also maps back the digits someone might type for a
468
+ letter they misread (`5`→`S`, `2`→`Z`, `8`→`B`, `6`→`G`) — pure upside, since a digit can never
469
+ be part of a real code — and ignores hyphens and spaces.
470
+
471
+ Rate limiting is unchanged and still does the heavy lifting: redemption is serialized at ~10
472
+ attempts/second, so 22⁸ takes ~174 years to exhaust and a five-minute window is about 1 in 18
473
+ million.
474
+
475
+ `dev-auth.ts` itself went on arguing from **32⁷** — ~111 years, 1 in 11 million — for the whole
476
+ of the change that made the token 22⁸. The conclusion never stopped holding, which is precisely
477
+ why nothing surfaced it: a safety argument that survives its own premises being replaced is not
478
+ being read. The figures are corrected in place, with the superseded ones kept beside them, since
479
+ this is the one file where the numbers *are* the argument rather than a description of it.
480
+
481
+ The invite page's code box (shipped for #75) covers most of the rest of the ask: bookmark the
482
+ host once, then each session is eight letters into a text field. The `dev.tosijs.net/<code>`
483
+ short URL needs host-side infrastructure and is tracked in `TODO.md`.
484
+
485
+ ### BREAKING (narrow): the root barrel no longer re-exports the doc system (#133)
486
+
487
+ `import { … } from 'tosijs-ui'` no longer gives you `code-editor`, `doc-browser`,
488
+ `doc-system/doc-system` or `live-example`. **Import them by subpath instead** — they were
489
+ already reachable that way, and nothing else changes:
490
+
491
+ ```typescript
492
+ import { tosiCode } from 'tosijs-ui/code-editor'
493
+ import { createDocBrowser } from 'tosijs-ui/doc-browser'
494
+ ```
495
+
496
+ **Why.** `code-editor` pulls CodeMirror; `live-example` and `doc-system/doc-system` each pull
497
+ `tjs-lang` independently. This package has no `sideEffects` field — correctly, since
498
+ `elementCreator()` registers elements at import time and a blanket `sideEffects: false` shakes
499
+ a bare `import 'tosijs-ui'` down to zero registrations — so a bundler must treat every
500
+ `export *` target as side-effectful and cannot drop it. All four were therefore eager in any
501
+ app that imported _anything_ from the barrel.
502
+
503
+ Measured by snowfox-app moving a 15MB React bundle to the bun bundler, and reproduced here:
504
+
505
+ | | size |
506
+ | ----------------------- | ------------------------------------ |
507
+ | barrel as shipped | 1.68 MB |
508
+ | barrel without the four | 0.38 MB |
509
+ | real app bundle | 15.21 → 13.86 MB (**1.35 MB, 8.9%**) |
510
+
511
+ The doc cluster was **77% of the barrel**. For contrast the 3D, map and lottie components —
512
+ the ones you would expect to be the heavy tail — cost about 10kB, because they already
513
+ lazy-load properly.
514
+
515
+ The doc site and CDN `<script>` users are unaffected: `index-iife.ts` imports all four
516
+ explicitly, so the weight lands on the bundle built _for_ the doc site rather than on an app
517
+ that imports a button.
518
+
519
+ ### New: `tosijs-ui/codemirror` — extend `<tosi-code>.editor` without a silent no-op (#131)
520
+
521
+ `<tosi-code>` exposes the raw `EditorView` and the 1.7 notes invite you to configure CodeMirror
522
+ through it. But CodeMirror 6 keys facets, `StateField`s and gutters by **object identity**, so
523
+ an extension built from a _second copy_ of `@codemirror/state` or `@codemirror/view` is not
524
+ mismatched — it is **silently ignored**. No error, no warning; the gutter never renders.
525
+
526
+ Reported with a real repro: a consumer's `@codemirror/view` resolved to `6.43.6` while ours
527
+ hoisted to a nested `6.43.10`, so their `gutter()` and `GutterMarker` came from a different
528
+ instance than the view. `@codemirror/state` happened to dedupe, which made it worse — enough
529
+ worked that the failure looked like a bug in their gutter code.
530
+
531
+ ```typescript
532
+ import { gutter, GutterMarker, StateField, StateEffect } from 'tosijs-ui/codemirror'
533
+ ```
534
+
535
+ Only `@codemirror/state` and `@codemirror/view` are re-exported — the two needed to extend a
536
+ view, exactly as the report identified.
537
+
538
+ **Why not peer dependencies**, which the issue proposed and is the obvious fix: a required peer
539
+ would make every consumer install two CodeMirror packages including the overwhelming majority
540
+ who never render an editor (`elementCreator()` registers eagerly, so importing tosijs-ui is not
541
+ evidence of using it), and an _optional_ peer is worse than either — the component silently
542
+ does nothing until you discover you needed 12 packages. Re-exporting removes the failure by
543
+ construction rather than policing it: there is no version to match and no `overrides` to write,
544
+ because there is only ever one copy.
545
+
546
+ ### `buildSite` no longer wipes `dist/` — it is your package output, not a site artifact (#130)
547
+
548
+ The prebuild ran `rm -rf dist/` on **every** `buildSite`, including from `devServer` and
549
+ including for projects that configure no library build at all. For the repos this API exists to
550
+ serve, `dist/` is the **published package output** — an input to `npm publish` — so the wipe
551
+ destroyed build products the current run does not rebuild.
552
+
553
+ Reported from tosijs with the full chain: two bundles (`module.debug.js`, `module.safe.js`,
554
+ exposed as the `./debug` and `./safe` subpath exports) are produced only under `--build`,
555
+ because they need a 53-file transpile too slow for the dev loop. `bun start` — or Playwright's
556
+ `webServer`, which runs it — wiped `dist/` and rebuilt five of seven. A publish from that tree
557
+ ships two subpaths that throw `ERR_MODULE_NOT_FOUND`, and it reached a commit once already.
558
+ Nothing local could catch it: size and smoke gates iterate the bundles the current run _built_,
559
+ so a bundle that was **deleted rather than skipped** is invisible to all of them.
560
+
561
+ The rule is now "clean only what you wholly generate":
562
+
563
+ | configuration | `dist/` cleaned? |
564
+ | -------------------------------------------------------------- | ---------------- |
565
+ | `emitLibrary` / `libraryTsconfig` (tsc emits the complete set) | yes |
566
+ | `libraryBuild` — a consumer function that may emit a subset | **no** |
567
+ | none configured — nothing here owns it | **no** |
568
+
569
+ The doc `outputDir` wipe is unchanged and stays: that directory is wholly generated, and the
570
+ build announces it. `dist/` is different.
571
+
572
+ Incidental: `emitLibrary` runs `tsc --incremental`, whose `.tsbuildinfo` lives in `dist/`.
573
+ Wiping it every run meant incremental compilation had never once done anything.
574
+
575
+ ### `bun start` serves in ~3.6s instead of ~23s, and prints 13 lines instead of 37
576
+
577
+ **20.1 of those 23 seconds were the dependency audit**, running synchronously before the port
578
+ was bound — where it hit its own 20s timeout, gave up, and **failed open**. So the stall
579
+ bought nothing on that run: it was the cost of a check that did not happen. Measured against
580
+ the live registry, `bun audit` takes **79.5s**.
581
+
582
+ The code justified blocking on the grounds that "the audit is sub-second… a gate you wait for
583
+ cannot be raced". The premise was false, so the conclusion was buying a twenty-second stall on
584
+ every start. It now runs after the server is up and reports when it lands, prefixed loudly
585
+ enough that a late advisory cannot be mistaken for part of the startup banner — non-blocking
586
+ must not mean easy to miss. **Release builds still block**: `buildSite` audits synchronously,
587
+ and `--test` gates there too, which is where waiting is the right trade.
588
+
589
+ **Build output is 13 lines, down from 37.** Eighteen of them were `bun build` listing every
590
+ chunk and every sourcemap — the same size information the build already summarises, only
591
+ longer. Captured now, and replayed **in full** on failure; verified by deliberately breaking
592
+ the bundle entry, which still prints the errors, exits 1, and restores the last good build.
593
+
594
+ Spam is not free: it trains people — and agents — to stop reading build output, which is
595
+ exactly what lets a real failure through.
596
+
597
+ The ePub was the assumed culprit and is innocent: 0.34s of the 23. Measuring first is the only
598
+ reason the actual cause was found.
599
+
600
+ ### `docs.json` is cache-busted, so a stale corpus stops surviving a deploy
601
+
602
+ Every generated URL went through the asset stamp — the stylesheet, the hydration bundle, the
603
+ IIFE — **except the one that matters most**. `docs.json` is the entire corpus: nav, routes and
604
+ every page's content, and it was emitted bare.
605
+
606
+ A static host sends no `Cache-Control` for it, so browsers apply **heuristic** caching — free
607
+ to invent a freshness lifetime when you decline to state one. The site then renders a previous
608
+ deploy's corpus against the current bundle, and nothing in the console says so. It presents as
609
+ pages that will not update, and the fix is a hard reload nobody thinks to try. It cost a
610
+ maintainer a long debugging session before it was traced to a Chrome cache entry.
611
+
612
+ The corpus gets **its own stamp**, hashed from `docs.json`'s own bytes rather than the project
613
+ version. The version is right for the bundles — it moves once per release, which is when they
614
+ change. The corpus does not work that way: it changes whenever anyone edits a doc, many times
615
+ within one version, and a preview host redeployed mid-version is the normal way this project
616
+ reads its own site. A version-keyed corpus URL would have been stale for exactly that workflow.
617
+
618
+ The dev server was already correct here — it sends `no-store, must-revalidate` on everything,
619
+ verified. This was only ever the built static site.
620
+
621
+ ### `<tosi-table>` stops silently capping at 10,000 rows (#82)
622
+
623
+ `maxVisibleRows` defaulted to a flat `10000` and `slice`d everything past it **without a
624
+ word**. A 25,000-row table showed 10,000, and every count, filter and sort then ran on the
625
+ truncated set — self-consistent, and disagreeing with the data. The property was documented
626
+ nowhere, so the only way to find it was to lose rows and go looking.
627
+
628
+ The number was wrong because it was sized as a _rendering-cost_ limit. It is a **layout**
629
+ limit. A virtual `listBinding` renders only the visible window, so the UI cost is O(1) in row
630
+ count — 300,000 rows scroll like 300 — and the array size is irrelevant. What binds is the
631
+ spacer element's height against the maximum height a browser will lay out.
632
+
633
+ So the cap is now derived at runtime: `maxElementHeight / rowHeight`, which at the default
634
+ 30px rows is on the order of half a million to a million rows. **Probed, not hardcoded**,
635
+ because the ceiling is engine- _and version_-specific — two Chromium measurements in this
636
+ project sit a factor of two apart (Chrome 151 clamps at 16777214px, the Playwright Chromium
637
+ at 33554428px). Any constant would be wrong for somebody, in the direction that loses rows.
638
+
639
+ And when a cap does bite, it now **says so in the console** with both numbers and how to
640
+ raise it. `rowHeight: 0` stays conservative on purpose: with no fixed row height there is no
641
+ virtualisation, every row is a real DOM node, and the cost genuinely is O(n).
642
+
643
+ Set `table.maxVisibleRows = n` to override, exactly as before.
644
+
645
+ ### A large `rowHeight: 0` table now complains, loudly (#84)
646
+
647
+ `rowHeight: 0` turns virtualisation off — every row becomes a real DOM node, and sorting,
648
+ filtering and group toggles walk all of them on every render. It is documented for "smaller
649
+ tables, or tables with variable row-heights", and that is the only thing it is for.
650
+
651
+ Past **1,000 rows** the table now warns **on every render** until a `rowHeight` is set. That
652
+ is deliberate, and deliberately unlike every other notice in this codebase, which fire once:
653
+ those describe states a developer may not be able to change, whereas this is a one-line fix
654
+ that persists until made, and a single notice scrolls out of the console and is gone.
655
+
656
+ The originally suggested fix was to binary-search the scroll-anchor walk. That was declined:
657
+ it optimises a configuration nobody should be in, and makes a misconfiguration marginally
658
+ less bad instead of surfacing it. A table big enough for the cost to matter is big enough to
659
+ virtualise, and virtual tables are O(1) in row count.
660
+
661
+ ### `<tosi-3d>` is deprecated — use `tosijs-3d`
662
+
663
+ Nothing breaks: it still works, still ships, and stays until a future major. But new work
664
+ should use **[`tosijs-3d`](https://www.npmjs.com/package/tosijs-3d)**.
665
+
666
+ It is a much better library, and maintaining both is duplicated effort spent against the
667
+ weaker one. `<tosi-3d>` is a thin Babylon wrapper that grew inside a general-purpose UI
668
+ library; `tosijs-3d` is a project actually about 3D and XR, with the declarative component
669
+ set and XR support that implies. Every hour spent here is an hour not spent there, and the
670
+ result would still be the lesser of the two.
671
+
672
+ `tosijs-3d` grew out of this component, so migration is mostly a rename.
673
+
674
+ The deprecation appears in three places, deliberately: the doc page (what people read), the
675
+ `@deprecated` tags on `B3d`/`b3d` (what editors surface), and a console notice printed **once
676
+ per page on first use** — not at import, since `elementCreator()` registers eagerly and a bare
677
+ `import 'tosijs-ui'` is not evidence anyone is using this; and not per instance, because a page
678
+ with several scenes would spam its own console, which is how a deprecation notice teaches
679
+ people to filter warnings rather than act on them.
680
+
681
+ ### The machine-health guard now reports 13GB of orphans instead of skipping them (#93)
682
+
683
+ `isDevProcess` decided what was even a _candidate_ for the runaway check, and excluded
684
+ `build` — because we spawn the bundler ourselves and a build peaking mid-run must not look
685
+ like a runaway to its own parent. But the exemption did not say "our bundler". It said
686
+ **every `bun build` on the machine, forever, regardless of owner or age**, so an orphan whose
687
+ parent died a week ago was exactly as exempt as one that started 200ms ago.
688
+
689
+ Reported with measurements: **69 orphaned `bun build --watch` processes holding 13.09GB**, ~55
690
+ of them spawned inside a single 61-minute window, on a 128GB box running these very dev servers
691
+ and sitting at 116MB free. The guard looked, and skipped every one — while resting on the
692
+ premise that "nothing noticed the runaways because nothing ever looked".
693
+
694
+ `--watch` is the distinguishing signal, and it was right there in the argv: a watcher is a
695
+ server whoever started it. The mid-rebuild false positive the exemption existed for is already
696
+ handled, and handled _precisely_, by parentage — the assessor filters `process.pid` plus
697
+ `descendantsOf(process.pid)` before assessing anything. That is the exact test where a
698
+ subcommand name was a blunt proxy for it, so this costs no false positives. `bun run` with
699
+ `--watch` is caught too; the reported machine had four such servers up to 18 days old.
700
+
701
+ **Visible is not the same as caught**, and the first pass at this shipped the difference. Every
702
+ trigger in the assessor asks "is _one_ process too big?", and 194MB is nowhere near any of them —
703
+ so dropping the exemption walked the guard past all 69 orphans and returned `ok` for 13GB, while
704
+ the note above claimed they had been dealt with. The assessor now carries a rule that never
705
+ consults RSS: **a watcher whose parent is gone (`ppid` 1) is waste at any size**, reported with
706
+ the `kill` command. One 12MB orphan warns. It warns rather than refuses, because a build that
707
+ will not start because of someone's stray process is a guard people switch off.
708
+
709
+ "A bunch of similar names" was the other candidate signal and is deliberately _not_ used: three
710
+ identical `bun --watch bin/dev.ts` lines are usually three sibling projects each running their
711
+ own server. Identical argv is not duplicate work — the distinguishing fact is the cwd, which
712
+ `ps` does not report.
713
+
714
+ **And the other half, in `doc-site-system.md`:** "shell out so the OS reclaims the memory on
715
+ exit" is only true if the child _exits_. A `dev.ts` that spawns `bun build --watch`, itself run
716
+ under `bun --watch dev.ts`, leaks one bundler per keystroke — the previous child is never
717
+ signalled. The doc now carries the reaping idiom (kill the previous child; reap on
718
+ `SIGINT`/`SIGTERM`/`beforeExit`) beside the advice that produced the leak. This project's own
719
+ builds are one-shot and were never affected.
720
+
721
+ ### `layout: "full-screen"` finally gets its scroll container (#119)
722
+
723
+ `.doc-content`'s `overflow` is an **inline** style set by the doc-browser, so the full-screen
724
+ layout's stylesheet `overflow: auto` was competing with an inline style and always lost. The
725
+ `height: 100%` landed and the scroller did not — a full-screen page taller than its box was
726
+ clipped and unscrollable. It sat one line below a `padding` that correctly went through a
727
+ variable, with a comment explaining exactly why it had to.
728
+
729
+ Both now go through `--doc-content-overflow` (default `hidden`), on the inline style **and**
730
+ the pre-hydration rule — they have to match, or an override would take effect only after
731
+ hydration and the page would visibly change shape.
732
+
733
+ The second consequence is the one worth knowing about: `overflow: hidden` on an ancestor
734
+ kills `position: sticky` in its subtree, so a page wanting a sticky viewport-sized window
735
+ inside the article previously needed `.doc-content { overflow: visible !important }`. An
736
+ `!important` in a consumer's stylesheet is this component failing to offer a seam.
737
+
738
+ ### A page with tests says so, even when tests are off (#113)
739
+
740
+ Tests default to on for localhost and off everywhere else, and when they are off the widget is
741
+ hidden — so **a page with failing tests looked identical to a page with no tests**. There was
742
+ an override, `localStorage['tosijs-ui-tests-enabled']`, but you had to know the key, which
743
+ means you had to already suspect there was something to see.
744
+
745
+ That bit hardest down the path we recommend: `tosijs-tunnel` necessarily serves from a
746
+ non-localhost hostname, so the sanctioned way to view a dev site remotely is exactly where test
747
+ state went invisible. A maintainer read a clean tunnelled page as "the failure must be
748
+ localhost-specific" and lost the thread.
749
+
750
+ The app menu now offers a **Tests** toggle — but only on a page that has tests, where it reads
751
+ `Tests (N not run)` while they are off. `pageTestCount()` is exported from live-example for
752
+ this: how many examples carry tests, regardless of whether tests are enabled — the number that
753
+ lets a UI say a runner exists here without turning it on. A published page with no tests offers nothing, so the
754
+ default stays as quiet as it should be. The default itself was never the problem; the silence
755
+ was.
756
+
757
+ ### `/version.json` stops restamping a site that did not change (#122)
758
+
759
+ `docs/` is committed here and in the sibling projects, and the stamp named
760
+ `git rev-parse HEAD` — so **every commit's `version.json` named its own parent, forever**, and
761
+ no build→commit cycle converged:
762
+
763
+ ```
764
+ build at A → version.json says A
765
+ commit → B contains a version.json saying A
766
+ rebuild → version.json says B → dirty
767
+ commit → C contains a version.json saying B → …
768
+ ```
769
+
770
+ `build-stamp.ts` already argued against a wall-clock stamp for exactly this reason and missed
771
+ that the argument applies one level up: `HEAD` is not a clock, but in a repo that commits its
772
+ own output it moves on the same cadence. Small, and it cost something real — a false positive
773
+ on `git status` at the moment you are trying to confirm a release tree is clean, so it gets
774
+ checked, re-checked, and eventually ignored.
775
+
776
+ The fix is the reporter's: if nothing else in the output changed, leave the stamp alone. A new
777
+ `contentHash` field records what the rest of the output hashed to, so the next build can tell
778
+ "changed" from "unchanged" without keeping a copy of the old tree — though as it happens the
779
+ previous build is still on disk (the build moves it aside as `<outputDir>.last-good` rather
780
+ than deleting it, and drops it only on success), so the comparison costs no extra bookkeeping.
781
+
782
+ Verified end-to-end, which is the only way to check a convergence claim: build → commit →
783
+ rebuild now leaves a **clean tree**.
784
+
785
+ The stamp is also more honest for it. It now names the last build that actually changed the
786
+ site, rather than the last one that happened to run. **Deleting `version.json` still forces a
787
+ fresh stamp** — the escape hatch for re-identifying an unchanged build, at no extra flag.
788
+
789
+ ### `describeHolder(null)` says nothing is building (#123)
790
+
791
+ `describeHolder(currentHolder(root))` is the natural spelling, and `currentHolder` returns
792
+ `null` whenever nothing holds the lock — the ordinary, healthy case. It rendered the alarm
793
+ template with the fields unfilled (`pid undefined … building in undefined`), reporting a scary
794
+ problem that did not exist, in a reader whose whole purpose is answering "who holds this?".
795
+
3
796
  ## 1.13.0
4
797
 
5
798
  ### tosijs `^1.9.1` — and a console with nothing in it