# Changelog

## 1.16.28

### Tunnel

- **`tosijs-tunnel` prints a single-use link that keeps you signed in.** Once the route is
  registered, it prints one link per global key, such as
  `https://ui.edit.dev.tosijs.net/?t=KFMRPWQT`. The link carries a one-use code, never the key.
  Opening it starts a session in that workspace and gives the browser the box's key as a
  cookie across the preview domain for 30 days. From then on that browser opens every
  workspace holding the key, and the box's root list, without another link, across dev-server
  restarts. The edit link expires after the usual link window (5 minutes,
  `tunnel.linkTtlMinutes`); the view link is read-only and lasts a day (`--share-ttl`).
  `--link --global` and `--link --global --share` print fresh ones. Plain `--link` and
  `--link --share` are unchanged.
- **It only offers a link that means what it says.** It reads the box's `PREVIEW_DOMAIN` and a
  sha256 of each key over ssh (the keys stay on the box). It prints no link for a key this
  workspace refuses or the box does not share, and says why instead.
- **The box's root page lists every workspace** and says what your key opens in each: `edit`,
  `view`, or nothing. A workspace that is offline, or whose dev server is on a tosijs-ui before
  1.16.26, shows as not answering. In 1.16.26 those rows were hidden, so a workspace on an
  older tosijs-ui vanished from the page meant for picking one. Workspaces are now listed above
  the deployed builds.
- **`?t=<key>` on any preview host takes `&to=<label>`** and sends you on to
  `<label>.<preview domain>`, and the edit key's cookie lasts 30 days there too, as the view
  key's always has.

### Security

- **The box's access log recorded every link credential** (affected: 1.9.0 to 1.16.27). Caddy
  logs the request URI, so each `?t=<key>` visit wrote that key, and each link redemption its
  code, to `/var/log/caddy/preview.log`. The template now redacts `t` and `token` there.
  **Re-run `tosijs-caddy-install`, then scrub the old logs** (`/var/log/caddy/preview*.log*`) **or
  rotate your keys.** Caddy already kept cookies out of the log.
- **The `to` redirect stays under the preview domain.** `to` must be DNS labels (lowercase
  letters, digits, `-`, separated by `.`); anything else, including a path, a second `to` or
  encoded line breaks, falls back to `/`. Checked against Caddy 2.6.2 and 2.11.7.

## 1.16.27

### Deploy

- **`tosijs-caddy-install --go` installs a Caddyfile Caddy can read** (affected: 1.10.0 to
  1.16.26). It installed the file readable by root only, so a Caddy running as its own user
  (Debian's package does) refused the reload. Caddy kept serving the old config, the new file
  stayed on disk, and the bin said "Nothing was installed", so the next restart would have
  taken down every site on the box. The file is now mode `640` with the group systemd runs
  Caddy as, or `600` when Caddy runs as root; it holds both keys, so never world-readable.
  **If you ran `--go` with an affected version, re-run it.**
- **The installer starts a stopped Caddy** instead of failing to reload it, and if Caddy
  refuses the new config it puts the previous one back and exits non-zero.
- **The installer refuses a `PREVIEW_TOKEN`** containing anything but letters, digits, `-` and
  `_`. It goes into the gate's cookie regex, where a `.` loosened the match.
- **The preview gate matches a key cookie exactly** (affected: 1.9.0 to 1.16.26; the edit
  cookie since 1.16.26). It matched by prefix, so `tosi_edit=<key>x` passed. That still
  required the whole key, but the dev servers compare exactly and the gate now agrees with
  them. Re-run the installer to pick it up.

## 1.16.26

### Tunnel

- **Global keys: one link opens every workspace, and survives restarts.** The preview host can
  hold two machine-wide keys: `PREVIEW_EDIT_TOKEN` (new, optional) opens every workspace for
  editing, and `PREVIEW_TOKEN` (the static previews' invite secret) opens them read-only.
  Put them in `/etc/caddy/preview.env` on the box (re-run `tosijs-caddy-install`) and in
  `~/local-secrets/tosijs-preview.env` on each machine running a dev server. Visit the box's
  root once with `?t=<key>`: Caddy sets a cookie across the preview domain (24 hours for the
  edit key), and the root page lists every workspace that is up and whether you can edit or
  only view it. Opening one starts a session there on the first page load, and after a
  dev-server restart a reload is all it takes. Keys must be at least 24 letters, digits, `-` or
  `_`, because the box compares them with no rate limit; the installer refuses a short edit
  key, and a dev server ignores one and says so. A workspace can refuse them with
  `tunnel.globalKeys: false`, or with `'view'` let either key browse and neither edit; any other
  value refuses both and says so. The eight-letter `--link` codes are unchanged.
- **The view key is your preview invite.** Everyone you have given a static-preview link can,
  by default, also read every workspace that accepts the view key while it is up, even with
  `requireToken`. Set `tunnel.globalKeys: false` on a project that should stay yours, or rotate
  the invite secret.
- **Rotating a key** means editing both files, re-running the installer and restarting the dev
  servers: a session a key already opened lasts up to 24 hours and is not re-checked against
  the key until its server restarts.
- **`tosijs-tunnel` refreshes the box's root page** when it registers a workspace, so a newly
  tunnelled project appears in the list straight away. The box's root page escapes project
  names, which it inserted into the page as they were.
- The edit key opens a `globalKeys: 'view'` workspace read-only, as the view key does.

### Security

- **Source and data writes over the tunnel must come from the workspace's own origin**
  (affected: 1.9.0 to 1.16.25). They relied on the session cookie's `SameSite=Lax`, but every
  host under the preview domain is the same *site*, so a page on another project's preview (its
  live examples included) could POST to your workspace with your session attached. The haltija
  bridge's component and socket over the tunnel check the same (affected: 1.12.2 to 1.16.25).
- **A read-only session can no longer attach the haltija agent bridge** (affected: 1.15.0 to
  1.16.25, with `haltijaDev: 'tunnel'`). A share link's session passed the bridge's gate, which
  asked only whether the session was valid.
- **A redirect after redeeming a link stays on the workspace.** A link at a path starting `//`
  redirected to another host.

## 1.16.25

### Dev server

- **`editableData`: folders an editor on the dev page may save to** (#3365). A level or
  scene editor running on a dev-server page can list, read and save the project's data files
  through `/__docstore/data`, behind the same gate as source: this machine, an edit session
  over the tunnel, or the LAN under `trustLan`. Share links stay read-only.
  `editableData: ['static/levels']`, or `{ dirs, extensions, commit }`. Only data formats may
  be allowed (`.json` by default; also `.jsonc` `.json5` `.ndjson` `.txt` `.csv` `.tsv`
  `.yaml` `.yml` `.toml`), so nothing saved there runs or renders. Dot-files and symlinks are
  never written, nor are common configuration names (`package.json`, `tsconfig.json`,
  `firebase.json`, `*.config.*`), though the folders you name are the real boundary. A `.json`
  save must parse, and a save replaces the file whole. A folder outside the project, the
  project itself, one in or around `.git` or `node_modules`, or one overlapping the build
  output stops the server at launch. `tosijs-ui/site` exports the `EditableDataOptions` type
  and the `DATA_EXTENSIONS` list.
- **`commit: true` makes every save a version.** Each save is its own commit
  (`data: save <file>`, naming who saved it), and other staged work is left alone. The
  repository's hooks run, and each git step gets 20 seconds. Nothing is committed while the
  repository is mid-rebase, mid-merge, cherry-picking, reverting, bisecting or on a detached
  HEAD. `?history=<file>` lists a file's latest 100 versions and `?file=<file>&at=<commit>` reads
  one back. A save that wrote the file but did not commit answers with `commitError` instead of
  reporting a clean save. Each committed save waits for git, so it suits an explicit Save, not
  autosave.
- **`tosijs-ui/doc-system/data-client`** wraps the endpoint for an editor: `listData`,
  `readData` (text, or `{ json: true }`, or `{ at }`), `saveData` (a string as is, anything
  else as formatted JSON) and `dataHistory`. A refusal rejects with a `DataStoreError` carrying
  the status, and so does a page answering for the endpoint (a static host's catch-all), so
  `listData()` tells an editor whether to offer saving.
- **`trustLan` can name your network**, so a committed setting trusts nobody on anyone else's:
  `trustLan: '192.168.1.0/24'` or a list, IPv4 or IPv6. Only private addresses inside it are
  trusted. **`TOSIJS_TRUST_LAN`** sets it for one run and wins over the config either way: `1`,
  a network, or `0` to turn a committed setting off. A network that cannot be parsed stops the
  server at launch, and the launch warning names who is trusted and where the setting came from.
  A refused device outside the named networks is told so in the log, and the messages readers
  see when the LAN is refused now suggest these forms rather than `trustLan: true`.
- **The SPA fallback shows the page the URL names.** Was: a path with no file of its own got
  the root page, which then asked for its assets relative to that path, got 404s and never
  hydrated; with `home` set, a reader saw the home doc. Now the dev server serves the root page
  with its relative URLs made root-relative (in-page `#` links still stay on the page), and
  hydration adopts a pre-rendered article only when it is the doc being shown. Generated pages
  stamp the article with `data-doc` for that. The asset fix is the dev server's; a static host's
  own catch-all rewrite is unchanged.

## 1.16.24

`dist/iife.js` grows by about 0.2 kB gzip.

- **Composition rules chain.** A rule may apply to another rule, so `un:check:square` slashes
  the checked square, and `unCheckLock` draws instead of the placeholder. `iconSvg` reports a
  chained rule as needing the renderer, as it does any rule. Chains stop at four rules deep,
  which keeps a hostile icon name cheap to resolve, and a custom rule whose prefix can match
  nothing no longer recurses. `defineIcons` may now warn about a new name that reads as a
  chain (`unCheckFoo`); it is a warning, and your definition still wins.
- **The icon composition demo shows the bracket form.** It printed `icons.check:square()`,
  which is not JavaScript; a name with a `:` now shows as `icons['check:square']()`.
- **Prettier never formats SVG files.** `*.svg` is in `.prettierignore` and a `requirePragma`
  override, and `tosijs-format` excludes it on the command line as it does markdown. Prettier
  has no SVG parser of its own, but an editor association or a plugin such as
  `prettier-plugin-xml` would reflow a drawing tool's export. Adopters using `tosijs-format`
  get the command-line exclusion. To cover editors too, add `*.svg` to `.prettierignore` and
  `{ "files": ["*.svg"], "options": { "requirePragma": true } }` to your Prettier `overrides`.

### Dev-server access

- **A live example's "Save to source" is offered wherever the dev server admits the page**:
  a LAN device under `trustLan`, or a tunnel visitor with an edit session, not only
  `localhost`. The dev server marks the pages it serves with `editableSources` on, and an
  example on such a page asks with a `HEAD` request when it connects, once per source file; it
  believes only an answer carrying the dev server's own header. A deployed static site is never
  asked.
- **Every source write admitted by `trustLan` is logged** with the device's address, the file
  (quoted, so a crafted name cannot forge a log line) and whether it was saved.
- **The dev server suggests `trustLan` only to a refused device on a private network**, the
  one case it would admit; anyone else is pointed at an edit link.

## 1.16.23

- **`rule:icon` applies an icon composition rule explicitly.** A `:` marks the boundary
  between a rule's prefix and the icon it applies to. `checkSquare` is a registered icon, so
  it draws that icon; `check:square` is the `check` rule over `square`. The explicit form
  always applies the rule, needs no capital after the prefix (`check:Square` is the same), and
  takes suffixes and stacking as usual (`spin90:loader40s`, `un:lock$shield`). A `:` is not
  valid in an identifier, so write `icons['check:square']()` or
  `<tosi-icon icon="check:square">`, and it works in the `caption:icon` option strings of
  `<tosi-select>` and `<tosi-segmented>` (`done=Done:check:square`), whose caption now ends at
  the first colon. Names without a `:` resolve exactly as before. `defineIcons` warns when
  it defines a `rule:icon` name, since the definition then wins over the rule.
- **The kitchen-sink style testbed is no longer bound into the ePub or print.** It stays on
  the site; it is a component showcase, not reading. Docs-only: no API or rendering moved.

## 1.16.22

Three site options asked for by adopters, and a review pass over the documentation.

- **`home`: choose the page served at `/`.** `defineSiteConfig({ home: 'host' })` (a slug or
  filename) makes that page the site root, for a site that is an app. README then has its own
  page at `/readme/`, and the nav, canonical links, sitemap, `llms.txt` and books follow. A
  doc can claim the root itself with `{"home": true}` (or `home: true` in YAML frontmatter).
  The build fails when `home` names no page or a hidden one, or when two docs claim the root.
  **README's URL and canonical link change** when you turn it on; Doc-Site System says so. An
  ePub takes its slugs from the whole site, so README keeps `/readme/` in a book even when the
  home doc is not in that book. closes #225
- **`{"header": false}`: a page without the site header.** It removes the header bar on that
  page, before hydration and after, and gives its space to the page. With
  `layout: "full-screen"` the page has no site chrome at all, for an app page with its own
  toolbar or an installed PWA. The page links back to the docs its own way. closes #226
- **YAML frontmatter reads `header: false` and `home: true`**, as the JSON metadata block does.
- **Doc-Site System documents `header: false` and `home`**, including that home moves
  README's URL.
- **The build warns (fails under `strict`) on a link written as a bare page name.**
  `[valueRenderer](value-renderer)` resolves under the page it is on and reaches nothing;
  three shipped that way here.
- **`naturalCompare`, `naturalSorter` and `isBlank` are exported from the package root.**
  The naturalCompare page imported `naturalCompare` from `'tosijs-ui'`, got `undefined`, and
  showed the lexical sort it warns against.
- **The documentation site is regrouped.** A new Documentation System section holds every
  page about the doc system (adopting, One Source, Doc-Site System, doc-system, doc-browser,
  docs, highlight-block, highlight-styles, the layout demos, kitchen sink). Icons moves under
  Components, with Icon Composition, iconSvg and make-icon-data beneath it; naturalCompare
  joins Helper Libraries; Appendices keeps migrating and release-notes.
- **Documentation fixes.**
  - The doc-browser and docs pages showed a CLI that does not exist, imports from the wrong
    module and wrong option names. Both are rewritten against the real API.
  - Three links (data-table → value-renderer, pocket-bar → icon-composition and popFloat)
    reached nothing.
  - Migrating: the ACE-era `theme`/`options` props warn and do nothing, rather than being
    gone; the 1.11.0 edit-link note points to the 1.14.0 change; new notices for 1.16.17
    (tjs-lang 0.14), 1.16.19 (`iconSvg` throws) and 1.16.21 (tosijs `^1.10.9`).
  - Adopting and Doc-Site System: install `chokidar` with the build peers; display-only
    fences use `:static`; a detached test block shows its results.
  - Icons: the class added is `tosi-icon` (no `icon-chevron-down`); how icons are coloured
    replaces the old icomoon paragraph.
  - Live examples: `ide` mode is described as what it is today (it runs like `iframe`).
  - Smaller: size-break's container-query intro, the iife size (~484KB gzip), code-editor's
    mode list (`ajs`), a stray CSS line in editable-rect's example, the menu demo's link, and
    typos across six pages.
- `dist/iife.js` grows by about 0.2KB gzip.

### Dev-server access: `trustLan` (opt-in)

Who may read and write source through the dev server changed, but only for a project that
sets the new option; with it unset the rules are exactly 1.16.21's.

- **`trustLan`: LAN devices may read and edit source.** With `editableSources` on, a direct
  peer at a private address (RFC 1918, IPv6 unique-local or link-local) may read and save
  source as one at the dev machine can, so a phone or headset on the LAN uses "Edit page
  source" without the tunnel (a live example's "Save to source" is still offered only on
  `localhost`). closes #227
- **What is never trusted:** tunnel traffic (it still needs a session), a request with
  forwarding headers, and a write from any origin but the dev server's own. Agent driving,
  invite links, builds and test reports stay this machine only; agent driving no longer
  inherits the write rule's options, so LAN trust cannot widen it.
- **Anyone on the network can then edit your working tree**, which includes code that runs on
  this machine: use it on a network you control. The dev server prints a warning at every
  launch while it is on, and says so when it is set without `editableSources` (it then does
  nothing).
- The edit and save alerts tell a LAN reader that invite links cannot help them, instead of
  sending them round that loop.


## 1.16.21

- **Expanding or collapsing a table row's detail no longer corrects `scrollTop` itself.**
  tosijs 1.10.9's variable-height list keeps the row in place across the height change, so
  `<tosi-table>` stopped measuring the row and nudging the scroll position up to three
  times. closes board #3326
- **tosijs peer floor raised to `^1.10.9`**, the release that anchors the list. On an older
  tosijs the toggled row now jumps when its detail opens or closes; upgrade tosijs alongside
  (`dist/iife.js` already bundles 1.10.9).
- **The icons page points to [lucide](https://lucide.dev)** in its introduction and under
  Icon Sources: a fork and expansion of Feather, and the place to start for more icons in
  the same style.


## 1.16.20

- **A registered icon with style suffixes wins over a composition rule.** `checkCircle90r`
  drew the `check` rule over a rotated `circle`; it is now `checkCircle` turned 90°, in the
  renderer and in `iconSvg`. A rule still applies where the whole name is not a registered
  icon with suffixes (`checkLock90r`). closes board #3295
- **`defineIcons` warns when a new name it defines is also what a composition rule would
  draw** ("icon checkFoo is also a composition rule's name; your definition wins"). Only a
  name that reads as the rule's warns (`checkFoo`, not `checkbox` or `unlink`), and
  overriding a name that already exists, a built-in such as `unlock` included, never does.
- **A redirect to a missing icon names what was asked for.** `defineIcons({ myChevron:
  'chevronDwon' })` warned "icon chevronDwon does not exist", naming only the target; it
  now adds "(requested as myChevron)", through stacks too. closes board #3294
- **Six live examples on the doc site showed nothing; a Playwright spec now fails on any
  that does.**
  - The table's Column Width section rendered an empty `<tosi-table>` (an `html` fence
    meant as syntax). It is now a live before/after with a test.
  - The router page's examples shared the page's one router, so the last to run blanked
    the others. Basic Usage holds it on load and has links; Named Views routes when you
    press its button, and a test checks the first example shows its route.
  - Code meant to be read no longer runs as an empty example: the token-palette `css` on
    highlight-block and highlight-styles, liveTheme's programmatic-control snippet, and the
    migrating guide's setSchemaValidator lines are `:static`.

## 1.16.19

- **`iconSvg()` resolves the built-in icons that rotate or flip another.** 27 built-in
  names are redirects with a rotation or flip (`chevronDown` is `chevronRight90r`; also
  `chevronLeft`/`Up`, `arrowDown`/`Left`/`Up`, `cornerDownLeft`, `rotateCcw`, `thumbsDown`
  and others), and `iconSvg()` returned `undefined` for every one of them, silently. They
  now return markup with the transform applied about the centre of the viewBox. A rotation
  or flip written inline (`iconSvg('chevronRight90r')`) and other inline names returned
  undefined silently too: a rotation or flip now resolves, and anything else throws. A Playwright
  test draws each one both ways, through `iconSvg` and through `icons.<name>()`, and
  finds no pixel that differs by more than antialiasing, in all three browsers.
- **`iconSvg()` throws for a name it cannot turn into one SVG.** A stack
  (`cloudSync: 'spin120Loader40s_30x$cloud'`, or `tosi$hat` written inline), a rule
  (`unLock`, `spin90Loader`), a style suffix other than rotation or flip (`check50o`), a
  redirect to a name that is not an icon, or a redirect cycle all returned `undefined`,
  the same answer as a name that does not exist, so a build script rendered nothing and
  said nothing. Each now throws, saying which it is and, where the icon renderer would draw
  it, that `icons.<name>()` does so where there is a DOM. **Code that relied on `undefined`
  for those names now gets an exception;** a name nothing would draw still returns
  `undefined`, as documented. This is the follow-up to #224, for the DOM-free path.
- `iconSvg()` reads suffixes with the same grammar as the icon renderer, now shared
  between them: `edit2_90r` is `edit2` turned 90°, not -90°.

## 1.16.18

- **A `defineIcons` name for a stacked composition draws the stack.** `defineIcons({
  cloudSync: 'spin120Loader40s_30x$cloud' })` made `icons.cloudSync()` draw the placeholder
  square and log "icon cloudSync does not exist", although the same string used directly as
  an icon name worked. The icons page documents exactly this pattern for building an app's
  icon vocabulary, so every such name was silently a square, in every version since
  composites shipped. A redirect's target now goes through the whole icon pipeline, stacking
  included. closes #224, reported by tosijs-virta.
- **A named composite answers to its name.** Its `data-icon` is the name you defined
  (`cloudSync`), not the composition it stands for, and suffixes and rules apply to it as
  to any icon (`cloudSync50o`, `spin90CloudSync`).
- **A redirect cycle says so.** `a: 'b'` with `b: 'a'` now logs "icon a redirects in a
  cycle" and draws the placeholder, instead of claiming the icon does not exist; a
  composite that names itself (`a: 'a$check'`) draws the placeholder in place of the
  cycling layer. A long redirect chain that ends in a drawing is no longer cut off at ten
  hops.
- The named-composition example on the icons page is now a live example with a test, so
  the documented pattern is checked on every build.

## 1.16.17

- **The pinned tjs-lang is 0.14.0-rc.5; it was 0.13.13.** That is the devDependency this
  package builds and tests with, and `TJS_VERSION`, which sites without their own copy load
  from a CDN. What it turns on for `tjs` and `ts` examples on those sites, all of it
  feature-detected in 1.16.16: examples written as modules run (`export` is removed), a
  console line shows its own value and offers the JavaScript it became, and a built-in
  `tjs` example that declares a function gains a **Docs** tab beside its code, generated
  from its signatures.
- **What can break, and how to stay where you were.** tjs-lang 0.14 refuses some code that
  0.13 accepted, with no compatibility switch: `||` in a type (a union is spelled `|`),
  `unsafe var` and `unsafe eval`, and a `wasm` block that cannot compile, among others. Its
  [changelog](https://github.com/tonioloewald/tjs-lang/blob/main/CHANGELOG.md) lists them
  under "what breaks". A `tjs` example using one of those stops compiling on a site that
  loads tjs-lang from the CDN. To keep the old transpiler, install `tjs-lang@0.13.13` in
  your project: the build copies the installed tjs-lang next to your pages and the page
  prefers that copy to the CDN. A site that already installs its own tjs-lang is
  unaffected.
- The transpiler bundle a page with `tjs` or `ts` examples downloads is larger: 169 kB to
  195 kB gzip. `dist/iife.js` is unchanged, and pages without such examples load neither.
- The peer range is unchanged (`^0.13.1 || ^0.14.0-rc.5`).

## 1.16.16

For tjs-lang's book (GitHub #216 item 3 and #210 item 5), plus one change to `llms.txt`.
Both tjs-lang items are feature-detected, so nothing changes until your tjs-lang has them.

- **A console line shows its own value, and the JavaScript it became.** A dialect's
  transform may now return `prelude` and `body` beside `code`: its runtime setup, and what
  the source itself became. When it does (tjs-lang 0.14.0-rc.5 and later), a line typed at
  an example's console is evaluated for its own value, so a declaration shows `undefined`
  where it used to show the setup's last assignment, and a line that needs both the setup
  and `await` shows what it resolved to. The input also gains a small **JS** button that
  shows the JavaScript for that line. A transform that returns only `code` behaves as
  before, with no button.
- **`tjs` and `ts` examples may be written as modules.** `export` is removed from `tjs` and
  `ts` examples before they run, by tjs-lang's own parser (`stripExports`, from
  0.14.0-rc.4), and the build's example check does the same. An example runs as a function
  body, where `export` is a syntax error, so with an older tjs-lang it fails as it always
  has. `js` examples are unchanged.
- **The tjs-lang peer range admits the 0.14 release candidates** (`^0.13.1 || ^0.14.0-rc.5`).
  It read `^0.14.0`, which excludes every prerelease, so `npm install` refused the one
  tjs-lang that has these features. Run here with 0.14.0-rc.5 installed: the browser lane
  on Chromium, and the new tests of both features on all three browsers.
- **`registerDialect` documents what a transform should return.** `testRunner` has been
  honoured since 1.16.8 but was not in the type or the docs: a dialect that returns only
  `code` has its inline tests run from their raw text, with JavaScript's semantics. The
  `transform` type now names `testRunner`, `prelude` and `body`.
- **An example re-run while it was still loading could leave its page never finishing its
  tests.** A run replaced by a newer one (tests turned on, or an edit, while the first run
  was still awaiting a fetch) went on to run its test block against a preview no longer in
  the page. A test that waits for something to render then waited for ever and the example
  stayed marked as running, so the page was reported as not finished after 45 seconds. A
  replaced run now stops before its tests, and one replaced while its tests are running
  no longer writes its results over the newer run's. Seen on the data-table page off
  localhost.
- **`llms.txt` no longer carries a paragraph about the dev server.** A site with
  `haltijaDev` set used to tell readers of its `llms.txt` to run `bun start` and drive the
  page with the `hj` CLI. That file is for whoever consumes the library, and those readers
  have no repo to run. If you relied on it to brief an agent working in your project, put
  the same note in your `CLAUDE.md` or `AGENTS.md`.

The tjs-lang this package pins for sites that load it from a CDN is still 0.13.13, which has
neither feature. A site that serves its own tjs-lang (the installed peer, copied
same-origin by the build) gets both as soon as that is 0.14.0-rc.5.

`LlmsTxtMeta.haltijaDev` is still accepted and now ignored (deprecated). `dist/iife.js`
grows by about 0.4 kB gzip.

## 1.16.15

For projects that build their own docs bundle, from tosijs's upgrade to 1.16.13 (board
#3248). Two failures that left a green build over a broken site now say so.

- **`restampSite(config)`, from `tosijs-ui/site`.** If your site has no `bundleEntry` and you
  overwrite `/iife.js` after `buildSite`, call it afterwards. It checks that the file you
  wrote registers `<tosi-doc-system>`, and `<tosi-example>` when the corpus has live
  examples, and then re-stamps the script and stylesheet `?v=` in every page from the files
  actually on disk (the stamp `buildSite` wrote described the bundle it replaced, so a
  returning visitor could run a cached library against new pages). A bundle whose entry
  imports only the root `tosijs-ui` registers neither element since 1.14.0, and until now
  nothing noticed: pages served, no header, no nav, no examples, no doc tests. A warning by
  default. Under `strict: true` it throws before writing anything. It also throws when
  there is nothing to re-stamp, on a `bundleEntry` site, and when it cannot read the corpus.
- **The build reports two copies of CodeMirror.** After an upgrade a lockfile can keep an
  older hoisted `@codemirror/state` while the newer one nests under
  `node_modules/tosijs-ui/`; every `<tosi-code>` is then blank and nothing fails.
  `buildSite` now asks the module resolver whether the packages the editor is built from
  agree, and names both copies and the command that fixes it
  (`bun update @codemirror/state @codemirror/view`). A warning by default. **Under `strict: true` it fails
  `buildSite` on a site with a `bundleEntry`, and fails `restampSite`**, which is new in a
  patch: those are the two places a bundle is built from the install, and such a build was
  already producing blank editors. Otherwise `buildSite` says it once and carries on. At runtime `<tosi-code>` logs the same explanation beside CodeMirror's own
  "Unrecognized extension value" error. Cost: `iife.js` +176 bytes gzip.
- **Fixed: `llms.txt` told agents to try `/carousel/index.md` on every site.** That is a
  tosijs-ui page. The example is now a page of the site being built.
- **The "relative imports without a file extension" warning says what it read.** It is
  about the `.js` files tsc emitted, at the moment it emitted them. A project that replaces
  those with bundles afterwards could not find the files it named; the warning now says so
  and points at `"emitDeclarationOnly": true`.

## 1.16.14

More reach for curated schema forms (#221): into arrays and unions, grouped into sections,
and usable as a review screen. All of it is off until you ask for it.

- **A curation can reach inside arrays and unions.** Give an array's, a union's or an
  object's entry its own `fields`, with paths relative to the property: for an array they
  apply to every item (`thresholds: { fields: { value: 'Threshold' } }`), for a union to the
  branch the value currently matches. Items and branches keep what is not shown, an error in
  an item property that is not shown is listed by its real path, and a union's
  `picker: false` hides the variant picker. One `fields` covers every branch of a union: a
  path the active branch lacks is left out, a path no branch has says so.
- **Sections.** An entry `{ section: 'Advanced', fields: {...}, collapsed?: true }` groups
  fields in a titled panel. `collapsed: true` also works on an object or array entry. A
  section or collapsed entry is marked `-invalid`, with a red title, while a field inside it
  is in error. **TypeScript:** `FieldCuration`'s values now include `CuratedSection`, so code
  that walks a curation and reads `entry.caption` needs the new guard: `isSection` is exported.
- **Proposals, for review screens.** `form.proposals = { path: { value, reason?, source? } }`
  shows each proposed value under its field with Accept and Reject. `value` stays the
  current value until one is accepted; accepting fires `proposalaccept` then `change`,
  rejecting fires `proposalreject`. `acceptProposal(path)` / `rejectProposal(path)` do it
  from code, and `proposalView` renders a proposal your own way. A proposal can be for a
  whole array, object or union, and an object item of an array can be proposed whole
  (`list.0`). One for an array item follows its item when items move. `proposalView` is
  given `ProposalContext.readOnly`, and its `accept` / `reject` do nothing while the form is
  read-only. Works with or without a curation. New localized strings: `Proposed`, `Accept`, `Reject`,
  `Proposed changes to fields not shown here`.
- For TypeScript: `isSection` is exported; `ProposalContext.readOnly` tells a custom
  `proposalView` that the form is read-only.
- **Fixed: an array item with several fields laid them out in two columns**, so the second
  field sat in the column meant for the move and remove buttons and the buttons wrapped
  underneath. Fields now stack and the buttons sit beside the first. The form now places an
  item's children itself (`grid-column`), so a stylesheet of your own that arranged
  `.schema-item` children may need a look.
- **Fixed: setting `value` with a different number of array items left the old rows.** A
  longer array showed only the rows it had before, and a shorter one kept controls for items
  that no longer existed. Arrays are now re-filled when their length changes.
- **Fixed: a schema `title` that is not a string** was handed to the label, or to a union's
  picker option, as element properties. Only a string is a title now, for properties and for
  `anyOf` / `oneOf` branches; anything else falls back to the key or the branch's value.
- Schema `title`s were already shown as written, never humanised or localized; the docs now
  say so and a test holds it.

## 1.16.13

Documentation only; no library code changed.

- **Better examples for the two 1.16.12 features**, in the package's doc comments and
  `llms.txt` as well as on the site. The table's row detail example now opens each invoice to
  a nested `<tosi-table>` of its line items with a totals line. The curated form example
  starts from a valid value and has **View only** and **Every field** toggles; the
  hidden-error case is a separate, smaller example.

## 1.16.12

Two additions for task-focused screens: a table row can open to show a detail element, and a
schema form can show a hand-picked set of fields. Both are off until you ask for them.

- **`<tosi-table>` rows can expand to a detail element** (#220). Set
  `rowDetail = (row) => element` and each row can open to show that element underneath it,
  across the full width of the table. A column with `expander: true` renders the toggle;
  `toggleRowDetail(row, open?)` does it from code, and `rowexpand` / `rowcollapse` report it.
  The detail stays in view while the columns scroll sideways, is built once per expansion and
  kept while the row is scrolled out of view, and the toggled row keeps its place on screen.
  Expansion follows the row through sorting and filtering; with `rowId` it also survives a
  reload of the data, and `expandedRowIds` can be read and set. `rowHasDetail` hides the
  toggle on rows with nothing to show. Pinned rows are not expandable. A table placed in a
  detail keeps its own selection, editing and column resizing.
- **The scrollbar of a table with `rowDetail` is approximate.** Rows are no longer all one
  height, so the table places them by interpolation (tosijs's variable-height list). A table
  that does not set `rowDetail` is unchanged.
- **`<tosi-schema-form>` takes a `fields` map for curated forms** (#221). It maps paths into
  the schema to `{ caption, help, readOnly, editor }`, in display order, and the form shows
  only those. Controls, choices, `required` and constraints still come from the schema.
  `editor` is any function returning an element with a `value` and a `change` event, such as
  an element creator from this library, or a field plugin object. The editor's `value` must be
  of the schema's type, so a native `<input>` suits string properties only.
- **A curated form still owns the whole value.** Properties that are not shown keep their
  values, the whole value is validated, and an error in a property that is not shown is
  listed above the fields. A path the schema does not describe renders as a placeholder that
  says so.
- **Fixed: an error on a plugin-rendered field was never shown** under that field. The form
  now shows it there, a missing required value included, as it does for built-in controls.
- **Fixed: `readOnly` on a schema form did not reach a plugin control that is a custom
  element, or a `<textarea>`.** Both are now locked with the rest of the form, through
  `disabled` or, for a component that has only that, `readOnly`. Of these, the form
  releases only what it locked: one a plugin disabled itself stays disabled.

`dist/iife.js` grows by about 2.5 kB gzip.

## 1.16.11

In-page links work, teaching examples show their test result, and "Download ePub" stops
offering a book that was never built. `dist/iife.js` grows by 1.2 kB gzip.

- **Headings have ids.** Every heading rendered from markdown now carries an `id`, so
  `[text](#that-heading)` and `/page/#that-heading` go somewhere. Before this they were
  links that did nothing, with no error anywhere. The id is the GitHub slug of the heading
  text, with `-1`, `-2` for a repeat on the same page. Arriving at a heading scrolls it to
  the top of the page.
- **The build warns about an in-page link that matches nothing** on its page, naming the
  page and the anchor. Under `strict: true` it fails the build.
- **An inset's `#anchor` is matched as the heading's id**, the one the page carries:
  `install.md#whats-new` now matches "What's new", and `#usage-1` names the second "Usage".
  The 1.16.9 spelling still matches (`#what-s-new`).
- **Inline views show the test result** (#219). An example opened with `{"view": "code"}` or
  `{"view": "console"}` has one line under its preview: the count when its tests pass, the
  first failure in red when they do not. It covers the `test` block and a `tjs` example's
  inline tests, and clicking it opens the tab with the detail.
- **An example in an inline view always runs its tests**, whatever the page's tests switch says (it is off
  by default away from localhost). A `tjs` example with inline tests therefore transpiles in
  the page when it is in an inline view on a deployed site, where it used to run from the
  copy the build transpiled. Examples without a `view` option are unchanged.
- **Inline views hide empty tabs.** `html`, `css` and `DOM tests` appear only when the
  example has that block. The full-screen code view still shows them all.
- **A long section title keeps its arrow.** In the sidebar, a section title that wraps no
  longer leaves the disclosure triangle alone on a line above it.
- **"Download ePub" follows the build** (#218). The settings menu links to the ePub at the
  URL the build wrote, shows a submenu for a site with several volumes, and has no entry on
  a site that builds none. It used to appear on every site and 404 on those. A hand-written
  `<tosi-doc-system config>` without the new `ebooks` key keeps the old derived link.

## 1.16.10

Print now opens with the book cover.

- **Print as PDF has a cover.** The first page is the default volume's cover: your
  `epub.cover` image, or the one generated from the title and `coverIcon`. The title page and
  contents follow it. Print checks the image loads before using it, so a site without `epub`,
  a build that wrote no cover, or a corpus with no default volume prints as it did before.
  `epub: { printCover: false }` leaves the cover out of Print and keeps it in the ePub.
- **The cover is a file you can link.** Each ePub build writes its cover beside the book
  (`my-project-cover.png`; an explicit cover keeps its own type), and `/epub-volumes.json`
  gains `coverFilename` and `coverUrl` for every volume whose cover the build wrote. A volume
  without a cover has neither field.
- A WebP or SVG `epub.cover` is now declared with its own media type in the ePub; it was
  labelled `image/png`.
- `attachCovers`, `printCoverManifestUrl` and `VOLUME_MANIFEST` are exported from
  `tosijs-ui/site` for a build that does not go through `buildSite`.
- A configured `epub.cover` that does not exist now prints a warning; the build still
  generates a cover in its place, as before.

## 1.16.9

Two sets of asks from tjs-lang for *The TJS Programming Language*, a book that teaches
programming in the page. Single-sourcing for books (#217): one corpus, several outputs,
nothing kept in sync by hand. And live examples that open with their code showing and a
console that speaks the example's language (#216). Additive, with one behaviour change to
know about: in a `tjs` or `ts` example, or one in a registered dialect, a line typed at the
console is now evaluated in that language and not as JavaScript. `dist/iife.js` grows by
0.7 kB gzip.

### The book opens on the project, not on "Adopting the doc system"

Both pages are pinned to the top, and the adopting page had an `order` where the README had
none, so it sorted first: the ePub's opening chapter, and the first entry in the site nav, was
a page about adopting the doc system. The README now leads both.

### One source, several outputs (#217)

- **Per-volume placement.** A page can sit in a different place in each book:
  `{"placement": {"language": {"parent": "for-programmers", "order": 20}}}` in its metadata.
  Each entry may set `parent`, `order`, `pin` and `title`, and naming a volume also binds the
  page into it. The site nav is unaffected.
- **Text insets.** `<!--{ "inset": "install.md#with-bun" }-->` includes another document, or
  one heading's section of it, in the page. A fragment (a markdown file whose name starts
  with `_`, in a directory the docs are extracted from) can be inset without being a page, and is the only kind of
  file an inset reads. Resolved at extraction, so the site, the ePubs, `llms.txt` and the markdown copies
  all get the assembled text, and an inset passage's live examples and tests run where it is
  inset.
- **Conditional text.** `<!--{ "only": "book" | "site" }--> … <!--{ "end": "only" }-->`
  conditional text, for wording that belongs to one output, such as "next chapter" against
  "next page". Books read `bookText`, everything else reads `text`; both are in the published
  `docs.json`, so this chooses wording and does not hide anything.
- **Each is guarded.** An inset that resolves nowhere, an include cycle, a missing heading, a
  file that is not a fragment, an unbalanced `only`, a directive inside a list, quote or
  paragraph, and a placement under a parent that is not in the volume all fail the build.
- **Print is not a volume.** The in-app Print output takes the book wording but stays the
  whole site in site order; `placement` arranges ePub volumes.
- New from `tosijs-ui/site`, for a consumer that builds its corpus some other way:
  `assembleCorpus`, `resolveInsets`, `splitConditions`, `SingleSourceError`, `placeInBook`,
  `bookTextOf`, and the `BookPlacement` type.
- Saving an edited live example back to source is refused on a page whose source uses an
  inset or conditional text, where the example's position on the page no longer identifies a
  block in that file. No existing page is affected: it applies only to a page that uses one
  of the new directives.

### Examples that open with their code showing, and a console in their language (#216)

- **A fence picks the view an example opens in.** `{"view": "code"}` in the source block's
  fence options opens the editor beside the preview, in the page and not full-screen;
  `{"view": "console"}` puts the console where the preview would be, beside the code, for
  examples whose point is a value. The editor is built as the example nears the viewport. In
  a narrow column the panes stack. `example.showInline('code' | 'console')` does the same
  from code.
- **The console evaluates in the example's dialect.** A line goes through the same transform
  as the example's code, with the same fence options, so `x == '5'` in a `tjs` example is
  TJS's `==`. A line the dialect rejects shows the dialect's error and is not retried as
  JavaScript. Plain `js` examples and `run` dialects are unchanged. A registered dialect's
  `transform` now also receives console lines, marked with `options.repl === true`.
- **Known limit of the above with tjs.** tjs puts setup statements in front of the line it
  translates, so an expression shows its value, but a line that is only a declaration shows a
  leftover value where a browser console shows `undefined`, and a line needing both that
  setup and `await` shows `undefined`. Fixing it needs the setup to be separable, which is
  asked of tjs-lang.
- The generated JavaScript tjs-lang asked to see already exists as the read-only **JS** tab of
  a `tjs` or `ts` example, and the inline views make it reachable in the page. Showing the
  JavaScript for a single console line is not in this release: a transform's output for one
  line is mostly its runtime prelude, and there is no general way to separate the two.

## 1.16.8

A patch for honest results. The doc-test lane can no longer pass over pages that never
reported, three more of tjs-lang's findings are fixed, and an example that only logs now
shows what it logged. Two things to know on upgrade: `ts` examples lose tjs's runtime
parameter guards (below), and one thing may newly go red: a doc-test
run that was silently losing pages will now fail and name them. `dist/iife.js` grows by
1.0 kB gzip (484.0 → 485.0 kB).

### Three more from tjs-lang's sweep of its own site (#210)

- **Inline tjs tests ran with plain-JavaScript semantics**, so passing tests were shown
  failing: `"hello world".capitalize is not a function` for a local `extend`, `Expected false
  but got true` for a boxed boolean. This release prepares the fix and tjs-lang delivers it:
  an example's inline tests now use the runner `tjs()` itself returns, and from the tjs-lang
  release after 0.14.0-rc.2 that runner is built from the rewritten test bodies. On 0.13.13,
  which this release still pins, nothing changes.
- **The `ts` dialect overrode converted TypeScript's semantics.** It passed `dialect: 'tjs'`
  to `tjs()`, which outranks the annotation `fromTS` writes, so a TS example's
  `new Calculator(…)` was refused as native TJS. It now passes no dialect. **This changes
  what every `ts` example does at run time:** converted TypeScript now has JavaScript's
  semantics, so the runtime parameter guards are gone too. `add('a', 2)` with
  `(a: number, b: number)` returned a type error before and returns `'a2'` now, as it would
  in TypeScript. `tjs` examples are unaffected.
- **An example whose output is only `console.log` was an empty box.** When a run finishes with
  nothing rendered into its preview, the lines it logged (and its inline-test summary, when
  one was run) are shown where the preview would be. It steps aside when the example renders
  anything, never writes into `preview`, and follows the existing console switches. Inline
  examples only: an `:iframe` example's output stays in its Console tab.

Not in this release: examples written as modules (`export const x = …`) still fail. Stripping
`export` safely needs a JavaScript parser, which tosijs-ui does not ship; tjs-lang already
bundles one, so the ask is with them to export their stripper.

### The doc-test lane can no longer pass over pages that reported nothing (#2874)

The background test runner counted a page as tested when it finished or timed out, whether or
not any results arrived, and the lane passed on "0 failed". A run once passed with results for
7 of 19 pages.

- A page with test blocks that reports no tests, or does not finish within 45 seconds, is now
  recorded as a failed test naming the page and the reason. A page that timed out after some
  of its examples had reported was still recorded as a pass in the first cut of this fix; a
  timeout now fails the page whatever had reported.
- The parent waited 30 seconds per page, the same as the frame's own stall timeout, so a
  stall report could arrive just after the parent moved on. The page budget is now 45s.
- Which pages have tests is decided by parsing the markdown the way the page is rendered. A
  ` ```test ` line shown inside a longer or `:static` block no longer makes a page count as
  one with tests.
- `bun run test-browser` now exits non-zero unless every page with tests has recorded results,
  and prints how many pages its count covers.
- The page a run starts on is tested in place, by its own code path. That path replaced the
  page's results each time one example finished, so the last example was the whole page and
  a failing earlier one could vanish; and it marked the page tested on a timer. It now adds
  its examples up and waits for all of them, by the same rule the iframes use. A deployed
  (non-localhost) page with tests enabled follows the same rule.
- The runner accepted `tosi-test-results` messages from any window on the page; it now
  requires its own test frame as the source.
- `testReportVerdict` is exported from `tosijs-ui/site` for adopters' own gates. A gate that
  checks only `failed === 0` should also require results for every page with tests.

## 1.16.7

A patch for readers that are not browsers, and for builds that were too polite. Every doc page
now also ships as markdown, for LLM fetchers that fail on ordinary HTML. Two misconfigurations
tjs-lang found the hard way now say so, and `strict: true` turns the build's warnings into
failures. Nothing to change on upgrade: `markdownPages` is on by default and adds files only;
`strict` is off by default. `dist/iife.js` grows by 0.3 kB gzip (483.7 → 484.0 kB).

### Every doc page also ships as markdown

The site build now writes each page's markdown source beside its HTML, at
`/{slug}/index.md`, links it from the page with
`<link rel="alternate" type="text/markdown" href="index.md">`, and says so in `llms.txt`.
It is for readers that want the text and not the page: an LLM's URL fetcher, a script, `curl`.
Prompted by one such fetcher reporting "no `<body>`" for a page whose whole article was in the
static HTML.

- On by default; `markdownPages: false` in the site config ships HTML only. It adds one file
  per page: on this repo's site, 70 files and 0.5 MB, about 3% of the output.
- The file is the page's source as written, so HTML comments and metadata blocks in a doc
  are served as text. If a page's source holds something its rendered page does not show,
  mark it `noindex` or turn this off.
- A `noindex` page gets neither the file nor the link.
- The build now writes `.nojekyll` whenever markdown pages are on, not only under
  `host: 'github-pages'`. Without it, GitHub Pages' Jekyll would render `index.md` over the
  real page. The file is empty and harmless on other hosts.

### Misconfigurations the build and the page used to accept in silence (#210)

Two reported by tjs-lang, whose site ran every example against the wrong tjs-lang for weeks
without being told.

- **The same-origin tjs-lang copy was skipped without a word.** When `tjs-lang/browser`
  cannot be resolved, nothing is written to `/tjs/`, and `tjs`/`ts` examples load tjs-lang
  from a CDN at the version tosijs-ui pins. The build now warns when the corpus has live
  `tjs` or `ts` examples and that happens. A corpus with none stays quiet.
- **A custom `bundleEntry` silently dropped what examples import.** It replaces the bundle
  that set the `xinjs`/`xinjsui` globals, so `import { elements } from 'tosijs'` in an example
  failed with "Cannot destructure property 'elements' of 'tosijs'". Examples now get `tosijs`
  from the doc system's own copy. `tosijs-ui` still has to be supplied by the entry (importing
  it here would put every component in every doc site's bundle), and an example that imports
  it without one now fails with a message naming the module and the two lines that fix it,
  whether it is met in the example or its tests. The cost of the
  `tosijs` fallback is 0.3 kB gzip on this site's own bundle.

### `strict: true` makes the build fail on what it otherwise warns about

A new site-config option, default `false`. With it, `buildSite` throws
`SiteMisconfiguredError` when `bundleEntry` does not register `<tosi-doc-system>`, when the
corpus has executable fences and `bundleEntry` does not register `<tosi-example>`, or when
the tjs-lang copy above is skipped on a corpus that needs it. Warning stays the default
because each is a legitimate thing to do on purpose; this is for a project that never does.
Asked for by tosijs-product, whose inert-bundle blocker reached a release candidate under a
green build. `SiteMisconfiguredError` is exported from `tosijs-ui/site`, so a caller can tell a
refused misconfiguration from a crash.

Whether a corpus "has executable fences" is now decided by the same rule the page uses, under
the site's `liveExamples` policy. The build's warning used a narrower pattern of its own: it
counted a bare `js` fence on an opt-in site, and missed `js:iframe`, `html`, `css`, `~~~`
fences and registered dialects. `llms.txt`'s note about live examples follows the same rule.

### Found by the pre-tag review, fixed before release

None of these ever shipped; they are listed so the notes account for every fix in the release.

- `liveFenceLanguages` found no fences in a CRLF corpus and disagreed with the renderer on
  blockquoted, indented and commented-out fences, because it scanned lines. It now reads
  marked's tokens through `collectCodeTokens`, shared with check-examples.
- The missing-module error covered the example only; tests and inline tjs tests still got the
  destructuring error. They now go through `rewriteContextImports`, and the check matches a
  real import statement, not a commented-out one.
- The Console REPL keeps plain `rewriteImports`: the checking variant threw outside the `try`
  that prints REPL errors, so a line importing an unsupplied module printed nothing.
- A failed copy of the tjs-lang bundles shared a `catch` with resolution and would have been
  reported as "could not be resolved"; only resolution is caught now.

### `<meta charset>` is back in the first 1024 bytes of every page

Two explanatory HTML comments at the top of each generated page's `<head>` had pushed the
charset declaration to about byte 1550; the HTML spec wants it within the first 1024. The
comments now live in the generator's source, and charset and viewport lead the head. Every
page is about 1.4 kB smaller.

## 1.16.6

A one-fix patch: a link to the page you are already on no longer adds a history entry that Back
has to step through (#174). No API change.

### Clicking a link to the page you are on no longer costs an extra Back (#174)

A doc link pointing at the document already showing pushed a duplicate history entry, so the
next Back press left the page where it was. It now replaces the entry, as a native same-URL
navigation does. "The same page" means the same document: `/x`, `/x/` and `/x/index.html`, or
`?x` with extra query parameters, all count. A URL with a hash still pushes, so Back from
`/x/` returns to `/x/#example`. The `router`'s `navigate()` had the same defect and gets the
same rule: navigating to the current path (with no hash) replaces.

The double push #174 measured on every link (ensemble on 1.14.1) does not reproduce on 1.16.4
with the same direct import: one click, one entry, and Back and Forward both work, in Chromium
and WebKit. The documented consumer entry point is `import 'tosijs-ui/doc-browser'`, which
registers `<tosi-doc-system>` since 1.14.2. The deep `tosijs-ui/doc-system/doc-system.js`
import loads the same module.

## 1.16.5

A patch, mostly found by using the doc site on an iPhone: menus that stay on screen as Safari's
toolbars come and go, a code panel that fits the visible screen, and a REPL that completes as
you type (touchably, case-insensitively, with `$` and `$$`). Also quiet under tosijs 1.10.7's
long-list advice, and `pinnedTopRows` now works from the element creator. `dist/iife.js` grows
by 3.6 kB gzip (478.7 → 482.2 kB), including the move to tosijs 1.10.7, which it bundles.

**One read-side change to check for:** `<tosi-table>`'s `pinnedTopRows` and `pinnedBottomRows`
now read as `null` rather than `undefined` when unset (typed `any[] | null`). That is what makes
them work when passed to the element creator. Code that tested `table.pinnedTopRows ===
undefined` should test `== null` instead; truthiness checks are unaffected.

### `$` and `$$` in the REPL

As in a browser console: `$('.thing')` returns the first match and `$$('.thing')` an array of all
of them, within the example's own preview. An example's own `$`, or a page's jQuery, takes
precedence.

### The Console tab's REPL completes as you type

Typing a name, or a dot after a value, opens a list of suggestions above the prompt: the
value's properties (prototype chain included) or the names in scope (the example's own,
`preview`, `console`, the page's modules, globals). Tap one, or use ↓/↑ and Enter; Tab takes the
highlighted or first suggestion, and Escape closes the list. Rows are touch-sized, so it works
on a phone, where there is no Tab key and no browser console at all. Matching ignores case, and
the field turns off iOS's auto-capitalisation and autocorrect; the example's own names rank
above globals.

### Quiet under tosijs 1.10.7's long-list advice; `pinnedTopRows` works from the creator

tosijs 1.10.7 warns once about any list binding of more than 10 items that has neither
`virtual` nor a `nonVirtualReason`. tosijs-ui builds some lists its users can't reach, so it now
gives the reason itself:

- `<tosi-table>`'s pinned rows, which are always on screen;
- its body under `rowHeight: 0`, which is the author choosing to render every row (the table's
  own warning still fires past 1,000 rows);
- the doc browser's flat nav in `routing: 'query'`.

While testing this, `tosiTable({ pinnedTopRows: [...] })` (and `pinnedBottomRows`) turned out to
be silently ignored when passed to the element creator: the getter returned `undefined` when
unset, so tosijs set an attribute instead. It now returns `null` when unset, and the creator
works (see the note at the top of these notes). The dev dependency moves to tosijs 1.10.7; the peer range is unchanged (`^1.10.3`).

### Menus stay inside the screen on iPhone as Safari's toolbars come and go

A menu's room was measured once, when it opened. Mobile Safari then shows and hides its toolbars
(and the keyboard comes and goes) as you touch the page, which changes the visible area without
a window resize, so a menu measured a moment earlier ran under the toolbar. Open floats (menus,
tooltips, hint lists) are now re-fitted to their anchor whenever the visible viewport changes.
Draggable floats are left where they are. Reported by the maintainer on an iPhone (the other
half of #2460).

### The Console tab's REPL is reachable on iPhone

A full-screen example (the code panel, or maximized) was sized `100vh`, which on iPhone Safari is
the height without the browser toolbar, so the bottom of the panel sat under it, and that is
where the Console tab's REPL input lives. It is now sized to the visible viewport (`100dvh`).

## 1.16.4

A patch: the example console becomes a tab with a REPL, test-only examples render on deployed
sites, and the doc nav search gets a clear button in every browser. The console's move from a
panel under the preview to a tab changes how live examples look; it ships as a patch because
live examples are a developer-facing tool. It does remove the panel's styling hooks: the
`[part="console"]` element, the `-has-console` class, and `console` from the `ExampleParts`
type. Nothing in tosijs-ui used them, and a site that styled the 1.16.2 panel should restyle
`.example-console` in the tab instead. `dist/iife.js` grows by 1.7 kB gzip (477.0 → 478.7 kB).

### The example console is a tab, and a REPL

What an example logs now appears in a **Console** tab beside its code, instead of a panel under
its preview, so the preview is never squeezed. The tab is also a REPL: type an expression and
press Enter to evaluate it inside the example's own scope (its top-level variables and
functions, `preview`, its `console`, the page's modules), as in the browser's console: input
gives its value, `await` works, Shift+Enter adds a line, and ↑/↓ recall what you've entered.
The REPL costs nothing until it's used: the first input re-runs the example once to give it
the example's scope. The error that stopped an
example appears there too. `exampleConsole: false` and `{"console": false}` still turn it off.

### A test-only example shows its results on deployed sites too

A ` ```test ` fence with nothing beside it shows its results as its body, but it ran its tests
only when the page's test toggle was on. That toggle is off by default anywhere but localhost,
so on every deployed site the example was an empty box. It now always runs its tests, because
they are its content; ordinary examples still follow the toggle.

### The doc navigation search has a clear button in every browser

The doc system's nav search relied on `type="search"` for its clear (✕) button, which browsers
draw inconsistently: Chromium only while the field is focused, Safari while it has text, and
Firefox never. It now has its own ✕, shown whenever there's text, with the native one hidden so
there's never two. Escape clears it as well.

## 1.16.3

A patch: iPhone menus that stay reachable, a Docs tab for live examples, and a way to declare
your dialects once in the site config. Nothing changes for existing code, with one behaviour
fix to know about: menus and other popped floats no longer close when an unrelated scroller
on the page scrolls, only when a scroll moves the element they came from. `dist/iife.js` grows
by 2.1 kB gzip (474.9 → 477.0 kB). 1.4 kB of that is the Docs tab loading its markdown renderer
on first use: that keeps `marked` out of `tosijs-ui/live-example` for ESM apps, but the iife
already contains the renderer, so there it only adds lazy-loading wrappers.

### A Docs tab for live examples (#184)

A dialect can document its source: `registerDialect(name, { …, docs(source, options) })` returns
markdown, shown in a **Docs** tab beside the code. tjs-lang asked for it: in TJS the signature
*is* the documentation. It's called only when someone opens the code panel, and again as they
edit, so readers pay nothing. It works for `run` dialects too.

The built-in `tjs` fills the tab from tjs-lang's own generator (`generateDocsMarkdown`) as soon
as tjs-lang's browser bundle exports it; it doesn't yet, as of 0.14.0-rc.1. Until then, a site
with the full tjs-lang can supply `docs` through a `tjs` override.

### Declare your dialects once, in the site config (#2463)

`dialects: ['ajs']` in a `tosijs-ui/site` config, plus any built-in you replace
(`dialects: ['ajs', 'tjs']`), tells the build what your bundle registers with `registerDialect`:

- The build-time highlighter leaves those fences alone, even when the name is also a Prism
  grammar. Before, this needed a separate `registerLiveLanguage` call.
- A replaced built-in isn't checked or pre-transpiled with the installed tjs-lang. That check
  used to fail correct examples written for the site's own build.
- On the page, a declared dialect that nothing registered is reported in the console, by name.

### Menus on iPhone stay reachable (#2460)

A long menu popped low on an iPhone ran below the visible screen, and scrolling the page to
reach its last items dismissed it.

- **Floats are sized to what's visible.** `positionFloat` now measures against
  `window.visualViewport` instead of `100vh`/`100vw`. On Mobile Safari `100vh` is the large
  viewport (as if the URL bar were hidden). A long menu now fits and scrolls inside itself.
  Desktop browsers, where the two agree, see no change.
- **Scrolling past a menu's end doesn't scroll the page** (`overscroll-behavior: contain`).
- **A float closes only for a scroll that moves its anchor:** the page, or a scroller that
  contains the element it was popped from. Before, any scroll anywhere closed a menu or
  tooltip, such as a sidebar or a table re-rendering. `<tosi-float>` gained an `anchor`
  property (`positionFloat`, and so `popFloat`, sets it); a submenu closes with its parent, and `<tosi-search-field>` now uses this rule rather than a
  copy of its own.

## 1.16.2

A patch: live examples show what they log, a spinner while a `run` dialect works, and three
carousel fixes. The one visible change to existing pages is the first: an example that logs
now shows its output under the preview. `dist/iife.js` grows by 1.8 kB gzip (472.8 → 474.6 kB).

### A spinner while a `run` dialect is working (#184)

A `run` that waits on the network or an LLM used to leave an empty box that looked broken.
Now a spinner shows over the example while the run is pending, after a quarter-second so a
fast run never flashes it, and a re-run replaces it. Under `prefers-reduced-motion` it is a
still ring. Asked for by tjs-lang, whose weather and GitHub examples take 5 to 10 seconds.

### `<tosi-carousel>`: tappable dots, `auto` that waits, resize-safe (#204)

Found by tosijs-virta using a carousel as a full-bleed hero on a phone.

- **The dots are tappable.** Each still draws an 8px dot, but answers taps across its whole
  slot and 44px tall (`--carousel-dot-hit-size`), so a tap that just misses a dot no longer
  opens the slide underneath.
- **`auto` holds still while someone is looking:** under a mouse hover, while keyboard focus is
  inside it, and always under `prefers-reduced-motion: reduce` (which also makes paging jump
  instead of animating). A touch is never treated as a hover.
- **A resize re-seats the current slide** instead of leaving it partway into the next.

### Live examples show what they log

What an example logs (`console.log`, `info`, `warn`, `error`, `debug`, `dir`, `table`) now
appears in a console under its preview, as well as in devtools. tjs-lang asked for it on #184:
a `tjs` example that logs its results showed a reader an empty box.

- Each example gets its own `console`, passed in the way `preview` is. The global console is
  never patched, so examples never see each other's output.
- The panel appears only when an example logs something. **Existing examples that log now show
  it**; examples that don't look exactly as before.
- A re-run starts a clean console, and a late log from the previous run (a timer it left
  behind) is not shown. The panel keeps 500 lines and counts the rest.
- **To turn it off:** `exampleConsole: false` in a `tosijs-ui/site` config (site-wide, no
  code needed), the fence option `{"console": false}` for one example, or
  `setExampleConsole(false)` from `tosijs-ui/live-example` in your own bundle.
- An example that declares its own top-level `console` (`const console = …`) keeps it and
  isn't captured, rather than failing to run.
- Values print readably: strings as written, data as JSON (Maps and Sets included), errors as
  `Name: message`, also for errors and elements from an `:iframe` example.
- A `run` dialect receives the same console as `context.console`, so a VM can forward its
  output to the panel.

## 1.16.1

A patch: everything here is additive. Two features, a search field that suggests refinements
and pluggable live-example dialects, and nothing that changes how existing code behaves.
`dist/iife.js` grows by 2.9 kB gzip (469.9 → 472.8 kB), which is both features going through
the batteries-included bundle; the ESM path pays only for what it imports.

### `<tosi-search-field>`: a search box that suggests refinements (snowfox, virta #2459)

A new component in the spirit of Finder's search field. As you type, it lists **hints** from
rules you supply: type `doc` and it can offer *name contains "doc"* or *Word documents*.
Picking one turns it into a **tag** in the query and consumes the text, and you can keep
typing. `value` is `{ tags, text }`.

- A tag is `{ caption, background?, color?, …your data }`, with chips coloured like
  `<tosi-tag-list>`'s.
- It fires `change` when the tags change, and `action` on Enter when no hint is highlighted.
- It is a real combobox: focus stays in the field, ↓/↑ move through the hints, Escape closes
  them, and Backspace in an empty field removes the last tag. The hints close only for a
  scroll that moves the field itself, so a table re-filtering on every keystroke (which
  scrolls it) doesn't close them the instant they open, and scrolling past the end of the list
  doesn't scroll the page.
- A clear (✕) button, shown when there's anything to clear, removes the tags and the text
  (also `clear()`).
- **As a filter:** give each tag a `test(item)` and the field a `textTest(item, text)`, and
  `field.filter` is an array filter to hand straight to `<tosi-table>.filter`. The docs filter
  the 3,655-row emoji table this way. `hints` and `textTest` can be passed to the element
  creator like any other property; `textTest` defaults to `null`, because tosijs sets a creator
  prop as a property only when its default is not `undefined` (otherwise it was stored as an
  attribute, a stringified function). `filter` returns the same function until the query
  changes, so a table's filter memo holds.
- Importing it on its own (`tosijs-ui/search-field`) costs 25.5 kB gzip: `<tosi-tag>` moved
  into its own module (still exported from `tag-list`), so it no longer pulls in the menu
  system.

### Pluggable dialects for live examples (#184)

Live examples can now be written in languages tosijs-ui doesn't know about. `registerDialect`
from `tosijs-ui/live-example` adds one, and a fence in that language becomes a live example
like `js`, `tjs` and `ts`: it groups with `html`/`css`/`test` blocks, honours `:static` and the
site's example policy, and saves back to source. `js`, `tjs` and `ts` are now built-in entries
of the same registry.

A dialect either **transforms** its source to JavaScript that the example runs as usual, or
**runs** it itself — given the example's preview, its options, the page's modules, an
`AbortSignal` for when the example re-runs, and `report()` for showing a result. Registering
over a built-in (`registerDialect('tjs', { transform })`) swaps in your own tjs-lang build.
This is what tjs-lang asked for to move AJS into doc pages; see the live-example page, "Adding
a dialect".

If your dialect's name is also a syntax-highlighting grammar, call `registerLiveLanguage` from
`tosijs-ui/site` in your site config too: the build-time highlighter never sees page
registrations, and would otherwise tokenize the source the example reads.

A replaced built-in is what runs in the browser, in dev and on the deployed site alike: the
build pre-transpiles `tjs` examples with the installed tjs-lang (so readers don't load the
transpiler), and the page skips that pre-transpiled code once the site registers its own `tjs`.
The build-time example check still transpiles `tjs` fences with the installed tjs-lang, so an
override that accepts newer syntax can fail that check; `checkExamples: false` is the escape
until the site config can say `tjs` is overridden.

Fence languages are case-sensitive, as they were in 1.16.0: ```` ```JS ```` is not a live
example. One visible difference: 1.16.0 also left such a fence unhighlighted, and it is now
highlighted as ordinary static code, on the page and in the ePub.

**Fence options.** Any fence can carry a JSON object after its language,
`` ```tjs {"runTests": "report"} ``, passed to its dialect as `options`. Malformed options are
reported in the console with the example they belong to.

Two more copies of "which languages run" were found while doing this, a hard-coded selector in
`insertExamples` and a private fence parser in the example checker, which would have read
`{"debug":true}` as the mode `true`. Both now use the shared rule.

## 1.16.0

### `<tosi-md>` sanitizes by default (#179)

**Breaking, as announced by 1.15.3's console warning.** A `<tosi-md>` with no `sanitize`
attribute now strips executable content, where it used to render raw HTML from the markdown.
Markdown from anyone other than you (issue bodies, comments, API data) could otherwise run
script in your page: tosijs-virta's review read a login token out of `localStorage` that way.

If your markdown deliberately embeds HTML that sanitizing removes (`script`, `style`, `iframe`,
`form`, event-handler attributes, `javascript:` or `data:` links, and a few more), set
`sanitize="off"` on that element, and only for markdown you control. Ordinary markdown, links
and inputs render exactly as before; custom elements do not (see the next paragraph). Only the exact value `off`
opts out; anything else, a typo included, sanitizes. The once-per-page warning is gone.

**Custom elements are unwrapped when sanitizing** (their sanitized content is kept, the element
is not): a component is code, and some render raw HTML or run code of their own, so untrusted
markdown must not be able to instantiate them. The pre-release review showed a nested
`<tosi-md sanitize="off">` escaping the sanitizer. Allow trusted display components by name
with the new `allowed-elements` attribute (space- or comma-separated), e.g.
`<tosi-md allowed-elements="tosi-icon tosi-tag">`, or the `allowedElements` property; changing it
re-renders. `tosi-md`, `tosi-example` and `tosi-doc-system` are never allowed, because they render
raw HTML or run code.

### ESM doc sites no longer load their entry bundle twice (#191)

A doc site built with `bundleEntry` loaded its entry as `hydrate.js?v=<hash>`, while its
code-split chunks import the entry back as `../hydrate.js`. Those are different URLs, so the
browser created **two module instances of the entry** on every page: everything in it ran twice,
and a module that patches a prototype in a shared chunk threw on the second pass (Babylon's
`Cannot redefine property: onBeforeViewRenderObservable`, which also broke its shaders). Reported
with a clean before/after measurement by tosijs-3d-ensemble.

The entry is now built as **`hydrate-<hash>.js`** and loaded with no query string, so the page
and every chunk name one URL, and a rebuild still busts caches. **Rebuild your site to pick it
up.** Anything that refers to `hydrate.js` by name must follow the rename: CSP or SRI entries,
`<link rel="modulepreload">`, host cache rules, service workers, smoke checks.

### Prettier never touches markdown, and `tosijs-format` (#187)

Markdown is authored prose, and in this stack the product: all Prettier did to it was escape
literal characters, pad tables and rewrite bullets and emphasis (`*n*` → `_n_`). tosijs-ui has
ignored it since 1.14.0, but the rule never reached the other repos. **The fix belongs in your
Prettier config**, where every entry point reads it (editors' format-on-save, lint-staged, a bare
`prettier --write`):

```json
"overrides": [
  { "files": ["*.md", "*.markdown", "*.mdx"], "options": { "requirePragma": true } }
]
```

**New `tosijs-format` bin**, a convenience on top: `eslint --fix` (when the project has an
`eslint.config.*`; its `ignores` decide the scope) then `prettier --write .`, excluding markdown on
the command line too. `tosijs-format --check` checks instead and exits 1 on anything unformatted or
any lint error, for CI. It uses your project's own Prettier and ESLint.

tosijs-ui's own CI now lints through it, which found 32 unused `Component` / `WebComponent`
imports left by this release's tosijs 1.10 migration (fixed before release; the migration had
passed every lane because nothing ran ESLint).

### `tosijs-release-notes --check` reads only the section being released

It matched annotations against the whole CHANGELOG, so wording from OLDER releases could count a
new bullet as written up: this release's #191 fix had no entry and the gate still said "all
accounted for". It now reads only the section for the version being released and fails when that
section is missing. **Heading formats it recognises:** `## 1.16.0`, `## v1.16.0`, and Keep a
Changelog's `## [1.16.0]` / `## [v1.16.0]`, each with any suffix (a date, "(unreleased)"). The
section lookup is exported as `changelogSection`. If your gate goes red after upgrading, check
that the section heading names the exact version. When `package.json` still names the version that
is already tagged, it now says so ("bump the version before running --check") instead of
reporting every new bullet as unwritten.

### tosijs 1.10: components are typed from their attributes

**Breaking, type-level only: the tosijs peer floor is now `^1.10.3`.** 1.10.0 removed
`Component`'s `[key: string]: any` index signature (tosijs#36), and 1.10.1 fixed the declaration
emit (TS2742, tosijs#38) that had blocked this adoption since 2026-09-04. Every component declares its
attributes with tosijs's `withAttributes()`, so a component instance is typed from its attribute
values: `tosiRating().max` is a `number`, a misspelt property is a type error, and so is
assigning the wrong type. On tosijs 1.9 TypeScript did not know `max` existed at all, because
`Component` carried an any-index signature that tosijs 1.10 removed (tosijs#36). Nothing changes
at runtime. Element-creator *arguments* are not typed by this. See
[Migrating](/migrating/) for your own components.

### The CDN bundle updates tosijs-schema and grows 2.8kB

`dist/iife.js` (the single-`<script>` bundle) inlines tosijs-schema as the `<tosi-schema-form>`
validator; it moves from 1.8.0 to 1.10.2. It also carries tosijs 1.10 and the CodeMirror updates:
**+2.8kB gzip (+0.6%, 466,942 → 469,753 bytes)**. The ESM build is unaffected (tosijs-schema is an
optional peer there). `NOTICE` now attributes tosijs-schema and tosijs-kilpi, both bundled.

## 1.15.5

### `<tosi-select>` options can have keyboard shortcuts (#188)

`SelectOption` accepts `shortcut` (menu syntax, e.g. `'ctrl-1'`). It is shown in the popup, and
picks the option from the keyboard even while the popup is closed. A pick by shortcut sets
`value` and dispatches `change` exactly as a click does, so there is one code path. A disabled
select ignores its shortcuts.

### Removed `<tosi-menu>`s no longer fire their shortcuts

`<tosi-menu>` added its document keydown listener with `capture: true` and removed it without,
which in a real browser removes nothing. Every menu ever taken off the page kept its listener,
and its keyboard shortcuts kept working. Only a real browser shows this (happy-dom removes the
listener anyway), so it is covered by a Playwright test in all three engines.

### `tosijs-ui/test-setup`: the happy-dom preload, published (#170)

```toml
[test]
preload = ["tosijs-ui/test-setup"]
```

gives `bun test` a DOM that runs tosijs and tosijs-ui components. It needs `happy-dom` (an
optional peer). Use it instead of copying a `test-setup.ts` from another repo; the copies had
drifted three ways. It is also tosijs-ui's own preload, so every unit test here exercises it.

## 1.15.4

### `libraryBundle`: a library build Node can actually load (#169)

`emitLibrary` and `libraryTsconfig` run a bare `tsc`, which copies each import specifier through
unchanged. Written in the usual bundler style (`export * from './model'`), that produces a
`dist/` **Node refuses** (`ERR_MODULE_NOT_FOUND`), though Bun and bundlers resolve it, so it
tends to ship unnoticed. Three projects had each written their own guard for it.

The new `libraryBundle` option is the paved road:

```ts
libraryBundle: { entries: ['src/index.ts', 'src/core.ts'] }
```

`bun build` bundles the entries with code-splitting (every declared dependency and peer stays an
import), `tsc` emits declarations only, and the build **fails** if `dist/` still has a relative
import without a file extension. Options: `externals` (default: all declared dependencies and
peers), `tsconfig` for the declaration pass, `target` (default `browser`). It needs `typescript`
installed, as any TypeScript library has.

`emitLibrary` and `libraryTsconfig` keep working, and now **warn** when their output has that
problem.

### Tag colours on chips and in the pick menu (#173)

A Tag in `<tosi-tag-list>`'s `availableTags` can carry `background` and `color`. Chips now use
them (they were ignored), and the pick menu shows each coloured tag's caption as a lozenge in
those colours, with the checkmark beside it. Without a `color`, the text is black or white,
whichever contrasts more.

### Tag list fixes (#189)

- A Tag object without a `caption` (the obvious way to colour chips: `{ value, color, background }`)
  showed as a **blank row** in the pick menu; on a phone, a tall empty column. It now shows its
  value.
- A selected tag that was also in `availableTags` as an object appeared in the menu **twice**:
  string tags were compared against Tag objects with `includes`. It now compares by value.
- `<tosi-tag-list>`'s `value` is documented as `string | string[]`, but an array threw
  `split is not a function` on render.

### `emitLibrary` no longer ships `tsconfig.tsbuildinfo`

Libraries built with `emitLibrary` shipped TypeScript's incremental-build cache in `dist/`: no
use to consumers, and different on every rebuild. It is gone; rebuild and republish to drop it
from your package.

## 1.15.3

### `<tosi-md sanitize="on">`, and a heads-up: it becomes the default in 1.16 (#179)

`<tosi-md>` assigns rendered markdown to the page, and markdown can carry raw HTML. For text you
did not write (issue bodies, comments, anything from a user or an API), that is a **stored
XSS**: tosijs-virta's pre-release review demonstrated reading a token out of `localStorage`
through an issue body with `<img onerror>`.

`sanitize="on"` strips executable content (scripts, event-handler attributes, unsafe URL
schemes) with [kilpi](https://www.npmjs.com/package/tosijs-kilpi), and drops any link that is
not safe to navigate to. It **removes some elements together with everything inside them**:
`script`, `style`, `iframe`, `object`, `embed`, `form`, `link`, `meta`, `base`, `noscript`,
`template` and SVG animation elements. So markdown that embeds a `<form>` needs `sanitize="off"`.
Markdown, ordinary links (relative, `#anchor`, `mailto:`), inputs and custom elements survive. A
bare `<tosi-md sanitize>` also means on.

**If you leave `sanitize` unset, nothing changes in this release** except one console warning
per page. **In 1.16 the default becomes `on`.** Set `sanitize="on"` or `sanitize="off"` now and
the upgrade will not affect you; `off` keeps raw HTML and is silent.

`tosijs-kilpi` is a new runtime dependency: +865 bytes gzip on `dist/iife.js`.

### Escape closes a `<tosi-dialog>` properly — `confirm`/`alert`/`prompt` no longer hang (#183)

Escape let the browser close the native `<dialog>` directly, bypassing `close()`. So
`await TosiDialog.confirm(...)` (and `alert`, `prompt`) **never settled**, the `showModal()`
promise never resolved, and a `removeOnClose` dialog stayed in the DOM, one leaked element per
Escape. Escape now closes with `'cancel'` through the same path as the Cancel button. So does
the browser closing the element on its own (a `<form method="dialog">`, or Chrome refusing a
repeated Escape), so nothing waits forever.

**New: `dialogWillClose` can refuse a close.** Return `false`, or a Promise of `false`, and the
dialog stays open, e.g. while an async save is in flight. `close()` now returns whether it
closed. Reported by snowfox-app, which carried a workaround at nine sites.

The default `dialogWillClose` no longer `console.log`s on every close, and the docs now say that
the built-in **OK** button sits beside footer buttons rather than being replaced by them.

### Icon types: `IconElement` is importable, and `icons.x()` needs no `!` (#176, #181)

- `IconElement`, `IconName` and `SVGIconMap` are exported from `tosijs-ui/icons`:
  `import { icons, type IconElement } from 'tosijs-ui/icons'`. 1.15 widened the icon return
  type from `SVGElement` to `SVGElement | HTMLSpanElement` (a composite icon is a span), and
  there was no importable name for the new union.
- Under `noUncheckedIndexedAccess`, every `icons.user()` read as possibly undefined, forcing
  `icons.user!()` everywhere. Built-in icon names are now explicit keys of the type, so the `!`
  can go, and a misspelled `IconName` is a type error. Composites and `defineIcons()` names
  still go through the index signature.
- `tosijs-make-icons` now generates `satisfies IconData` rather than `as IconData`, which keeps
  icon names in the type system. **It needs TypeScript 4.9 or later** in the project that runs
  it.

### `tagList.popSelectMenu` can be replaced after the element is on the page (#172)

The documented "replace this method" hook only worked if you replaced it before the element
connected, which `tosiTagList({...})` never allows: the button captured the original function.
It now looks the method up on every click.

### Doc-site sourcemaps no longer expose the build machine's paths

A doc site built with `bundleEntry` published sourcemaps whose sources climbed to the
filesystem root and into the builder's home directory (`../../../../../../Users/<name>/…`),
because the hydration bundle was built in the OS temp directory. The deployed site revealed
the build machine's directory layout, and two machines could not produce the same site. The
bundle is now built under your project's `node_modules/.cache`, and its maps are rewritten to
point at the sources from where they are served, so devtools find them and every machine
builds identical output. Rebuild your site to replace the old maps.

### `/version.json`: what `commit` means (#180)

Documentation only. Since #122, a content-identical rebuild keeps the previous stamp, so `commit`
is the last commit whose build *changed the site*, and can be older than `HEAD`. To check that
committed output matches `HEAD`, rebuild and confirm the tree is clean. `doc-site-system.md` now
says so and shows `contentHash`.

## 1.15.2

### The `tjs-lang` peer admits 0.14.x (#182)

**Upgrade before, or together with, tjs-lang 0.14.0** — if you install with npm and have
`tjs-lang` installed.

The optional peer was `^0.13.1`, and a caret on a `0.x` version admits patches only, so it
could not reach 0.14.0. An optional peer is only optional when it is *absent*: when it is
present and out of range, npm 7+ fails the install with `ERESOLVE`. It is now
`^0.13.1 || ^0.14.0`. bun resolves either way, which is why nothing here surfaced it; tjs-lang's
own pre-release review found it (the same defect as #98, one minor later).

Verified against `tjs-lang@0.14.0-rc.0` before widening: typecheck, build, unit, Playwright on
all three engines (the whole doc-test tier included) and the haltija lane all pass against it.
The one break 0.14.0 names, runtime types becoming callable, touches nothing here: no tosijs-ui
code handles a tjs runtime type.

The live-example CDN pin and the dev dependency stay on 0.13.13 until 0.14.0 is final, so a
published doc site keeps fetching the version it was built and tested against.

### The installed `bin/docs.ts` shim resolves its import

The back-compat shim for `extractDocs` importers re-exported from `../src/…`, and `src/` is not
in the published package, so since the doc-site system moved into `tosijs-ui/site` (June) it
threw `Cannot find module` for every installed consumer. It now re-exports from `dist/`, so
running it with `bun` or importing it by file path works. The bare specifier
`'tosijs-ui/bin/docs'` still does not resolve (the `exports` map sends it to `dist/`), and never
has since that map was added. **Use `tosijs-ui/site`** — it was never affected.

Found by a release check that had been silently skipping; it runs now.

### `tosijs-tunnel`, `tosijs-deploy`, `tosijs-caddy-install`, `tosijs-release-notes`: import spelling only

These bins now import `./resolve-site-config.ts` with its extension instead of relying on Bun's
extension probing, so each relative specifier names a file that is in the package. **No
behaviour moved:** the same module is loaded, and nothing in argument handling, ports, hosts,
secrets or what they write was touched. The diff is the import line in each file. Found by the
same check.

## 1.15.1

### Doc-test failures reported the wrong line — the same wrong line, every time (#142)

Reported by an adopter converting a WebGL project to test fences: two failures in two
*different* blocks both named `(line 114)`. Reproduced in Chromium — blocks failing at lines 2
and 10 both reported **131**.

Nothing was being recognised as the user's frame, so the first bundle frame won and every
failure named one constant wrong line. The cause is narrow and unlucky: the only mechanism was
a list of bundle filenames to skip, it matched a bare `/iife.js`, and a doc site serves
`/iife.js?v=<hash>` — the cache-busting stamp — which the `$` anchor rejects. It also never
listed `hydrate.js`, which is the bundle an *adopter's* site actually loads. So on a real doc
site nothing was skipped at all.

The user's frame is now identified **positively**, by the `sourceURL` the code was tagged with,
which is immune to query strings, to bundle names we did not anticipate, and to whatever an
adopter calls theirs. The exclusion list survives as a fallback for untagged stacks, now
matching on the path with any query or fragment stripped. When a tag is expected and no tagged
frame exists — WebKit routinely produces none — it reports **nothing** rather than guessing.

The reporter's framing was right and is worth keeping: *a wrong line is worse than none,
because it sends you confidently to the wrong place.*

### `toBeCloseTo`, `toBeGreaterThanOrEqual`, `toBeLessThanOrEqual` in doc tests (#142)

Also from that report. Everything renderer-shaped is floats — intensities, densities, bounding
boxes — and without `toBeCloseTo` they were writing `Math.round(x * 1e6) / 1e6` at every
assertion. Jest's semantics exactly, so the habit transfers: `digits` is decimal places and the
tolerance is half a unit in the last one. All three compose with `.not`.

## 1.15.0

### `save-to-source` wrote edits into the wrong fence, silently

Found by this release's own pre-release review, and it is caused by a feature this release
introduces — so it could not have shipped ahead of `:static`, and `:static` must not ship
ahead of it.

`example-policy.ts` was added this cycle as **the one rule** for "does this fence become a
live example", and `insert-examples` was converted to it. Its structural twin was not:
`save-to-source` kept a flat six-language set *and* its own fence-info regex that captured the
language as `[\w-]*` — which stops at the colon, so `js:static` read as plain `js` and the
mode was invisible there while being load-bearing everywhere else.

Example ordinals are a **shared coordinate system** between those two modules. They agree only
if both ask the same question, and after `:static` they did not: every ordinal following a
display-only fence pointed one group too far, so editing an example saved over a *different*
block. Silently — the "couldn't locate this example" guard only fires on an out-of-range
ordinal, and here a group existed at that index, just the wrong one. Under `liveExamples:
'opt-in'`, which is the prose and book setting, every ordinal on the page shifts.

Fixed at the class rather than the instance: `parseFenceInfo` now joins `isLiveFence` in
`example-policy.ts`, `render.ts` uses it instead of its own inline copy, and `save-to-source`
groups with `isLiveFence` and takes the policy from the caller. There is one parser and one
predicate; a third divergence has nowhere to live.

Mutation-verified — restoring either half of the old behaviour turns the regression tests red.

### `<tosi-table>` was silently capped at 10,000 rows in Firefox

Found by the release gate, and pre-existing — it fails identically at v1.14.3, verified in a
worktree rather than assumed.

`probeMaxElementHeight` asks for a 1e9-pixel element and measures what the engine gives back,
on the stated reasoning that *"every engine tried clamps it"*. Firefox does not: above its
ceiling it returns **0**, which is indistinguishable here from "no layout to probe". So
`derivedMaxVisibleRows` fell back to the flat 10,000 that #82 exists to replace — on every
Firefox page, silently, while the feature reported working everywhere else. Measured:

```
1e9 → 0      1e8 → 0      1e7 → 10000000      1e6 → 1000000
```

The probe now halves until the element lays out, which converges on the real ceiling within a
factor of two on any engine, clamping or not, in at most a dozen forced layouts — once per
page and cached. Result:

| engine | before | after |
| --- | ---: | ---: |
| Firefox | 10,000 rows | **520,833** |
| Chromium | 1,118,480 | 1,118,480 |
| WebKit | 1,118,480 | 1,118,480 |

The test had reimplemented the probe inline — the same single ask, carrying the same wrong
assumption — so it was testing a copy rather than the component. It now calls the shipped
function, which is why it caught this at all once the real one was fixed.

**Why it hid for so long:** CI runs Chromium only, and the fallback is graceful. Nothing was
broken, just quietly 52× smaller.

### Guards that could not fail, and a scope that was never stated

- **`theme.codeBg` was unguarded.** The WCAG contrast test hardcoded `#fdfdfd`, so it could
  not see the public knob at all — with `codeBg: '#1e1e1e'` the worst light-mode token measures
  **1.71:1** against AA's 4.5, all eleven light rules fail and all eleven dark ones too, and
  the test stayed green throughout. It now reads the background out of the spec, so it asserts
  against what ships. The limit is also stated rather than implied: the palette is eleven fixed,
  contrast-checked literals and **does not track `codeBg`** — override it and you own the
  contrast (retheme the tokens via the `--token-*` variables). There is now a test asserting
  that limit, so a change in scope has to be a deliberate edit rather than a silent one.
- **The `checkExamples` process boundary had no test.** The example policy crosses it through
  `TOSI_LIVE_EXAMPLES`, and every build we run sends `'auto'` — so `'opt-in'` had never crossed
  a process boundary anywhere, which is precisely where the earlier M2 defect lived. Now covered
  by an integration test that spawns the CLI: the env key, the clean-JSON stdout contract, and
  the default. Mutation-verified against a child that ignores the env value.
- **The Print path changed twice in one cycle with no test at any tier.** Extracted as
  `printWhenReady` and covered: an already-`complete` window prints without waiting for a `load`
  that will never fire again, a loading one waits, a failing highlight still prints (plain code
  beats a dialog that never opens), and highlighting happens *before* print rather than racing
  it.

### The cache-busting stamp no longer skips an asset in silence

`?v=` is computed by hashing the assets it guards, and the loop `continue`d past a missing
input without a word. That is how `doc-system.css` came to be **named in the inputs and never
hashed** for the life of the feature: the stylesheet was generated ~80 lines later in a
~1270-line function. Measured at the time — the `?v=` on every page equalled
sha256(hydrate.js + iife.js) exactly. A `theme`-only change, which reaches the CSS generator as
argv and never enters a bundle, therefore deployed new CSS under an **unchanged URL** and
returning visitors kept the old stylesheet.

The previous fix was to move one statement earlier, with nothing asserting the ordering — so
the same reorder could undo it and every lane would stay green. `computeAssetStamp` is now its
own module: a named-but-missing input is reported with the file and the consequence, and the
properties are unit-tested (changing any input moves the stamp; an unchanged rebuild does not;
the fallback is used only when nothing exists). Verified end to end — the `?v=` in the built
site equals sha256 of all three assets.

### One `language-*` pattern, not five

The class pattern was written `[A-Za-z0-9_+#-]+` four times in `highlight.ts` and `[\w-]+` once
in `epub.ts`, so a `c++` or `c#` fence was a language to one pass over a document and plain `c`
to another. Hoisted into `langOfClass`, with `+` and `#` in the character class deliberately —
they are real language names. Mutation-verified against the narrower spelling.

### Corrections to the `registerGrammar` surface, before anyone builds on it

tjs-lang is writing a TJS Prism grammar against this API (#155), so the companion pieces
mattered more than their severity suggested.

- **`grammarFor` contradicted `highlight()` on registered languages.** It consulted the alias
  table only, so after `registerGrammar('tjs', …)` — the one thing a language author does — it
  still answered `javascript` while `highlight()` correctly used the supplied grammar. It is
  now registration-aware, and documented as returning a grammar *name*.
- **A false guarantee about print.** The docs said a registered grammar reaches "the
  pre-rendered page, the ePub **and print**". The first two are true; print is not. The
  registry is module state in the *build* process and only `__TOSI_EXAMPLE_POLICY` is stamped
  into the page — so a hard-loaded page is correct (the tokens are already in the HTML) while
  client-side navigation and the in-app Print path re-highlight with an empty registry and fall
  back to `javascript`. Now stated plainly, with the workaround (an ESM consumer can register
  in their `bundleEntry` too) and the remaining gap (a CDN `<script>` consumer cannot).
- **Two exported types named `ExamplePolicy`** with different value sets — `'auto' | 'opt-in'`
  and `'auto' | 'opt-in' | 'none'` — both reachable through the `./*` wildcard, so which one
  you got depended on the import path. One type now, with `SiteExamplePolicy` expressing the
  site-valid subset as `Exclude<ExamplePolicy, 'none'>` rather than as a second declaration
  that can drift.

### The doc-system registration guard reported green on an inert bundle (#159)

`bundleRegistrations` tested `bundleSource.includes('tosi-doc-system')` and its own
documentation called that *"the one case where grepping a bundle is sound"*. It is not. The tag
being present is **necessary and not sufficient**, and our own documented API supplies the
counter-example: the context-map loop `document.querySelectorAll('tosi-doc-system')` puts the
string into a bundle that registers nothing. The reporter's build reported healthy with exactly
one `customElements.define` in it, none of it the doc system — and the caller then fell through
to warn about a *different* component, pointing them away from the defect.

The obvious repair is wrong in the other direction, and is worth recording because it looks
right: matching `define(` beside the quoted tag **false-negatives on our own bundle**.
`elementCreator()` registers with a variable, so the minified output reads
`customElements.define(i,this,r)` while the literal sits in a `closest()` call and some CSS
selectors, nowhere near a `define`.

It now looks for evidence of a registration by either route — `preferredTagName="<tag>"` (the
tosijs path, verified to survive minification in both `dist/iife.js` and a built `hydrate.js`)
or `define("<tag>"` (a direct call). Checked in both directions against real bundles and
against the #159 reproduction. The false soundness claim is gone from the shipped `.d.ts`; what
replaces it says plainly that this is a heuristic which now fails closed on the reported shape,
rather than claiming to be sound.

### tosijs-ui no longer steals a `Prism` the page already owns

Prism is a free-global library — its grammar files register against `window.Prism` at load
time — so load order decides who owns it and neither side is told. We were importing our copy
over the top of a host's. Reproduced rather than reasoned about, with a host Prism carrying one
custom language and one plugin:

```
ensureGrammar(rust)           = true
globalThis.Prism is host?     = false
host custom lang reachable?   = false
host plugins reachable?       = false
host object itself intact?    = true     ← their captured reference points at an orphan
we leaked manual onto global? = true
```

Nothing errored. Their languages and plugins were simply unreachable, and anything they had
captured addressed an orphan. The `manual` leak is the worse half: Prism reads that flag at
load, so a host Prism core loading *after* us computed `manual === true` and **silently never
ran `highlightAll()`** — us breaking their page, not merely losing a global.

Now: we **adopt** a host instance when one exists, never write `manual` onto an instance we did
not create, and restore the prior global if our own import fails. Every line of that
reproduction flips. Mutation-verified — deleting the adopt branch or leaking `manual` each
turns the tests red.

This is the #131 hazard class (`@codemirror/state` identity) in a worse container. That one got
a real remedy — `tosijs-ui/codemirror`, a re-export so only one copy exists — and this one still
has no equivalent: `registerGrammar` supplies grammars, not instance access. The harm is fixed;
the asymmetry is recorded in `UPSTREAM.md` and `TODO.md`.

The adopter reference states that a page which brought no Prism of its own ends up with ours,
and that loading your own afterwards wins and stops our highlighting — that is Prism's model,
not something we can fix from here. Load yours first, or use `registerGrammar` to add a
language to ours instead of bringing a second copy.

### Published-surface corrections

- **`bundleEntry` lost its documentation in the emitted `.d.ts`.** `liveExamples` was added
  between that field's JSDoc and the field itself, and TypeScript attaches a doc comment to
  whatever declaration follows it — so `bundleEntry?: string;` shipped with no comment at all,
  taking the #145 warning with it (the one explaining why omitting the doc-system import gives
  you a site that serves 200s and shows nothing). Reordered, and guarded by a test that reads
  the **emitted** `.d.ts` rather than the source, because the source looked fine throughout.
- **`<tosi-table>`'s selection docs described pre-1.15 behaviour**, calling `selectRow` /
  `selectRows` internals "documented because they may as well be". They are now a supported
  surface that enforces cardinality and fires `selectionChanged` — and the published page said
  neither. It now also carries the feedback-loop guard `<tosi-crud>` needed, since any consumer
  that both drives and observes selection will hit the same thing.
- **The README understated the iife by 21%** — "~385KB gzip" against a measured 468KB — and
  named CodeMirror as the only contributor. Prism and its 27 grammars (+31.8KB) are the second,
  with the same cause and the same remedy.

### `<tosi-highlight>` renders in colour anywhere, not only inside a doc site

The component shipped without the palette it needs. This package contains **no CSS files at
all** — every style is a `StyleSheet()` call — and the `.token.*` rules lived inside the
doc-system stylesheet. So a `<tosi-highlight>` on an ordinary page produced perfectly correct
`<span class="token …">` markup and rendered it in flat black: 17 token spans, every one
computing to `rgb(0,0,0)`. That is the single thing the component exists to do.

The doc block compounded it by telling you to "import the doc-system CSS" — naming an import
that did not exist.

The palette now lives in `doc-system/highlight-styles.ts` as an exported `highlightStyleSpec`,
the doc-system spreads that one copy rather than holding its own, and the element **injects it
on first use** — so the standalone case needs no import and no configuration. Every colour is
still a `varDefault`, so `:root { --token-keyword: rebeccapurple }` retunes one token type
without touching the rest.

> Writing the test for this found a second defect before it shipped: `StyleSheet()` does not
> dedupe by id on its own, and `render()` runs many times per element, so the first version
> injected a `<style>` per render — nine in a single test file. It now carries the same
> module-level guard as `ensureTooltipStyles`.

### Every "Run this example live" link in an `opt-in` book was dead

The ePub generator carried a **third** verbatim copy of the six-language set and
re-implemented example grouping without consulting `isLiveFence` or `config.liveExamples`.

The consequence landed hardest on exactly the people who build ePubs. Under
`liveExamples: 'opt-in'` — the setting the config docs recommend for book and prose sites —
the page produces no live examples at all, while the book went on injecting "▶ Run this example
live ↗" links to every one of them. Every link in the shipped book pointed at an anchor that
does not exist. With a single `:static` fence the ordinals also slid, so `#example-3` addressed
a block the page never anchored.

A book is the medium where a broken link cannot be corrected after publication, which is what
moved this from a note to a fix.

The ePub now uses the same predicate and the site's own policy, and reads the `:mode` off the
`<pre>` exactly where `insertExamples` reads it. Its language-class pattern also matched
`[\w-]+` while the highlighter matched `[A-Za-z0-9_+#-]+`, so a `c++` or `c#` fence was read
differently by the two; both now use the wider one.

The existing test asserted only that the link class was present. It now builds a chapter with a
`:static` fence ahead of a live one and asserts there is exactly **one** link and no
`#example-2`. Mutation-verified.

### A gitignored file shipped to npm, and no repo-reading check could have seen it

1.14.2's tarball contained `dist/.metadata_never_index` — a zero-byte macOS Spotlight marker
that is gitignored and untracked. Harmless in itself. The mechanism is not.

`files` includes `/dist` wholesale, and **when `files` is present npm consults neither
`.gitignore` nor `.npmignore`** for the paths it selects. So anything sitting in `dist/` ships,
and being gitignored makes it *less* visible rather than more: every guard that reads the repo
is blind to it by construction. A stray debug dump or a scratch `.env` would ship exactly as
readily.

Verified the hard way — an `.npmignore` naming the file was added first and changed nothing,
so there is no declarative fix and the config line was removed again. `bin/smoke-consumer.ts`
now asserts on the **packed listing**, the only place the truth exists, and fails on
`.DS_Store`, Spotlight markers, `.env*` and debug logs.

Found by diffing the published tarball against the tag — the post-publish check that reads the
artifact instead of the repo. All 871 built files were byte-identical; this was the only delta.

Also removed: **`bin/dist/`**, 1.6MB of tracked build output last rebuilt in August, not in
`files`, and referenced by nothing. `reviews-1.11-pass2.md` flagged it — and the identical
Spotlight marker inside it — two releases ago and left the box unticked. Every clone was paying
for it, and anyone grepping for `iife.js` found a stale copy.

### tjs-lang 0.13.13 (#135) — **already shipped in 1.14.3**

Carried here so a 1.13.x upgrader gets it too. Full notes under 1.14.3 below: on tjs-lang
<=0.13.11 a `test '…' { … }` inside a quoted string executed at transpile time, in the host
page, outside the iframe sandbox. If you are on 1.14.3 you already have this.

### Share a link with a PERSON: `tosijs-tunnel --link --share`

The magic link was tuned for **device** handoff — you read a seven-character code off one
screen and type it into the headset in your hands, seconds later, and you want to edit. Five
minutes is right for that, and the short window is exactly what pays for a token short enough
to type.

**Human** handoff is a different shape and had nothing. You text a URL to a colleague, she
opens it after her next meeting, and she gets a dead link — which reads as *your feature is
broken*, not *your link expired*. The alternative, in one real case, was building a Tauri app
and shipping her an installer so she could look at something.

```bash
tosijs-tunnel --link --share                 # read-only, 24 hours
tosijs-tunnel --link --share --share-ttl=120 # …or two
```

A share link is **read-only** — it browses and cannot save source — and that capability is
what makes the long life safe. It is deliberately **one flag rather than `--ttl` plus
`--read-only`**: two orthogonal knobs would let you construct a week-long *write-capable*
token sitting in a chat log, and nothing would stop you. For the same reason, raising
`linkTtlMinutes` is not the answer; that produces a long link that can still write.

The session it mints is narrowed to match and expires after **7 days** instead of the usual 30
— it belongs to someone else, so its horizon should be the demo rather than your dev server's
uptime. `--share-ttl` is capped there, because past it the link would outlive the credential it
can hand over. Share links also ignore `linkPolicy: 'single-use'`: a texted link gets opened
when the recipient gets to it, sometimes twice, sometimes after being forwarded — and spending
it on first redemption is the friction that led an adopter to replace the mechanism with a
permanent token of their own.

**New in the security surface, since this is a dev-tooling path people audit.** Read-only
sessions are the mechanism: `AuthState` gains a `readOnly` set marking a token, `redeemLink`
carries the capability from link to session, and `sessionMayWrite` / `cookieMayWrite` answer
it. `mayWriteSource` gains an optional `sessionMayWrite` — **optional, defaulting to
permissive**, so every existing three-field caller behaves exactly as before; a narrowing that
defaulted the other way would have silently disabled editing for everyone. Also exported:
`resolveShareTtlMs`, `READONLY_SESSION_TTL_MS`, `SHARE_LINK_TTL_MS`. The loopback-listener rule
that decides what counts as remote is untouched.

Configurable as `tunnel.shareTtlMinutes` (default 1440). Nothing about the existing edit link
changes: still 5 minutes, still write-capable, and `mayWriteSource` treats an omitted
capability as permissive so no existing caller behaves differently.

It still fronts the running dev server, so it needs your machine on. For a demo that has to
survive your laptop sleeping, use the static preview.

### `import 'tosijs-ui/doc-browser'` defines `<tosi-doc-system>` (#158, #159) — **already shipped in 1.14.2**

Carried here so a 1.13.x upgrader gets it too. Full notes under 1.14.2 below. If you are on
1.14.2 or later you already have this.

### Client-side navigation lost syntax highlighting on an `opt-in` site

The doc-browser called `highlightBlocks` without a policy, so the new `isLiveFence` filter
defaulted to `'auto'` and skipped all six executable languages — leaving them for a live
example that an `opt-in` site never creates. Measured on client-side navigation: **531
highlighted tokens down to 23.**

The symptom lands on exactly the audience `liveExamples: 'opt-in'` exists for — prose and book
sites, where nearly every fence is illustration. It now passes `examplePolicy()`, the same
global `insertExamples` reads, so the two cannot disagree.

### Print waited on a fixed timer instead of `load`

`autoPrint: true` used to inject a `load` wait; switching it off so the page could be
highlighted first dropped the wait with it, and printing raced the render. Restored.

### `<tosi-schema-form>`: non-conforming data is documented, not silently absorbed (#162)

The form is an editor, not a gate — a value that violates the schema is rendered, validated on
load and marked, never coerced, stripped or refused. That is deliberate, and it is now written
down, along with why there is no built-in save gate: with no validator registered `validate()`
returns `true`, so a gate here would open silently for every consumer who never installed one,
which reads as enforcement while enforcing nothing.

Three known limitations are documented with it, because the value is preserved but **cannot be
seen**: a value the typed control cannot hold renders empty (`age: "abc"` in a `number` input),
a union value matching no branch renders no fields at all, and an error whose path has no
rendered field is invisible while `validate()` returns `false`. Tracked as #162; the fix is a
panel listing what could not be displayed.

### `bun format` was red on two lint errors no gate ever ran

CI runs `format-check`, which is **prettier only** — so the ESLint half of `bun format`, the
command the docs tell you to run before committing, had gone red without failing anything.
Two errors in `audit-guard.ts` (a dead initializer and a `require()`), both fixed. Worth
knowing the shape: a documented command and the gate that checks it were not the same command.

The annotation gate had the mirror-image problem, and it is worth recording for anyone using
`tosijs-release-notes`: the commit that wrote up the last batch left `release-check` missing
three more, because a write-up is itself an annotation. Two were real user-visible fixes,
written up above. The third was internal and should have carried `[note]` — which is exactly
what CLAUDE.md means by "the last commit before `git tag` must carry only `[note]` bullets, or
the loop does not terminate."

### ePub NCX `playOrder` was duplicated and non-monotonic

`playOrder` is the NCX's **linear reading position**, and the spec requires it unique and
increasing — readers use it for "next chapter" and progress. It was read inside a template
literal *after* the recursive child call had already advanced the shared counter, so every
parent inherited its deepest descendant's number.

Our own ePub shipped **4 duplicate values across 72 navPoints**, with a sequence that went
backwards. A four-level section collapsed all four onto one value.

`id` was always correct, because it captured `++counter.n` into a const. `playOrder` now does
the same, which is the whole fix.

Found by checking whether the nav system supports a parent with a parent — it does, at every
level (model, static HTML, hydrated nav, EPUB3 `nav.xhtml`, NCX, and the CSS indentation),
and refreshing a deeply nested page discloses every ancestor correctly. A flat corpus cannot
reproduce this defect, which is why it shipped.

### `<tosi-table>`'s selection API agrees with clicking, and notifies (#157)

`selectRow`/`selectRows` stamped the selection key with no reference to the table's mode,
while the click path enforces it three separate ways. So `selectRows([a, b, c], true)` on a
single-select table left **three rows selected** — a state no amount of clicking can produce —
and neither method fired `selectionChanged`, so a consumer keeping its own UI in step silently
missed every programmatic selection. Including "restore selection after a data refresh", which
is what these methods are documented for.

Both now enforce cardinality and notify exactly once. Selecting in single-select mode
**replaces**, exactly as a plain click does, and warns if handed more than one row rather than
silently dropping the rest.

**Narrower than the issue proposed, and deliberately.** `multiple` is a data-model invariant
and is enforced; `select` is not, because it governs what the *user* may do rather than what
the *program* may do. Gating on it broke legitimate headless use — including this component's
own pinned-row example, which selects a totals row on a table that never enables clicking.
Cardinality is the invariant the report is really about, and `multiple` covers it alone.

Deselection is never gated: it can only reduce a selection, and refusing it would strand rows
selected before the mode changed with no way to clear them.

The click path keeps notifying once per interaction by composing a private non-notifying
primitive — a plain click deselects everything and then selects one, so a notifying public API
called from inside would fire several times per click.

**`<tosi-crud>` needed a guard for the new notification**, and the shape is worth knowing if
you drive a table programmatically: it both drives and observes the selection, so its own
`deSelect()`/`selectRow()` sync now called back into its handler — the deselect half reporting
an empty selection, which cleared the current record mid-edit. A component that writes and
watches the same state has to be able to tell its own writes from the user's.

### `registerGrammar` — a language can supply its own Prism definition (#155)

Our alias table maps fence names onto Prism's built-in grammars, which is the wrong seam for a
language that ships its own. tjs-lang is generating a TJS definition from the same source that
already emits their TextMate grammars; hardcoding it here would put their grammar behind our
release cadence.

```js
import { registerGrammar } from 'tosijs-ui/site'
import { tjsGrammar } from 'tjs-lang/prism'
registerGrammar('tjs', tjsGrammar)
```

Registered grammars win over the alias table, install under the **fence** name (so the class
stays `language-tjs` for a theme to target), and are used by the **build-time** pass — so they
reach the pre-rendered page, the ePub and print, not just a hydrated browser tab.

That last part is the whole point, and it is their argument rather than mine: a wrong token
colour is cosmetic on the web and permanent in a printed book. TJS's colon examples
(`greet(name: 'Alice')`) are *values*; rendered with TypeScript's type colour, the page
visually asserts the exact misreading the document exists to correct.

### `popstate` no longer re-renders on a hash-only change (#152)

Setting `location.hash` is a fragment navigation, and per spec it fires `popstate` before
`hashchange` — so any component keeping state in the hash re-mounted the entire article on
every write, destroying and re-creating every live example on the page. An editor lost focus
and its buffer mid-keystroke. The handler now compares the resolved filename and only scrolls
when the document is unchanged.

### No rule redefines a global palette token (#150)

`button, select, .clickable` and `pre, code` each set
`--text-color: var(--brand-color)` — redefining the token the whole palette derives from
(`--tosi-text: var(--text-color)`) on an *element* selector. From any button downward it
stopped meaning "the theme's text colour".

That reaches into anything embedded: an editor deriving its chrome from the page resolved
correctly in the document and to the brand colour on its own slotted toolbar buttons — oklab
L≈0.27 against an L 0.16 bar in dark mode, unreadable, with correct body text beside it. The
`pre, code` instance also put every syntax-highlighting token span in a poisoned palette.

Both now set `color`. A test encodes the distinction rather than an allowlist: a rule may
redefine the token **only if it owns a background** — `header` is brand-coloured, so its
contents genuinely need the matching text colour and should inherit it. A rule that only wants
to colour itself sets `color`.

### Packaging and API corrections

**`tosijs-ui/highlight-block` resolves under every condition.** It shipped as the only named
subpath without a `default` export condition — its ten siblings all had one. An exact key beats
the `./*` wildcard and *errors* (`ERR_PACKAGE_PATH_NOT_EXPORTED`) rather than falling through,
so a resolver under `['require','default']` — Jest's default, a CJS-targeting webpack/rollup
build, Vite SSR externalisation — hard-failed on that one subpath while `tosijs-ui/diff` beside
it resolved. `bun run test-consumer` now asserts **structurally** that every named subpath
carries `types` + `import` + `default`.

**`prismjs` is a real dependency, not an optional peer.** It first shipped declared *only* as
a devDependency while `dist/doc-system/highlight.js` imported it, so an adopter's build
resolved it by hoisting luck — and without it their doc site, ePub and print output had no
syntax highlighting at all, silently. Making it an optional peer then broke the build outright:
the grammar loaders are static imports a bundler must resolve, so an adopter without the
package could not build. This
is the fork already recorded for `tosijs-schema`: a literal import fails a consumer's build
when the package is absent, and a variable specifier cannot resolve in a browser. It costs one
lockfile entry and one audit surface; it costs **no bytes** unless something highlights, since
`<tosi-highlight>` is excluded from the root barrel and the grammars are lazy chunks. The iife
is the exception and pays **+31.8kb gzip** — see below.

**`ExamplePolicy` gained `'none'`** — "nothing here is live" — for targets that cannot run an
example at all. The ePub and the Print path pass it.

**`transformAvailable(dialect)`** is exported: the question a diagnostic must ask before it
accuses a document. `checkExamples` uses it to **skip** dialects whose transpiler could not be
resolved and report them as *unchecked*, instead of parsing them as JavaScript and blaming the
author (#154). `ExampleCheck.skipped` now survives the child-process boundary, so that field is
true on the path the build actually uses rather than always reading "nothing was skipped".

**The "writes outside `outputDir`" warning fires under `bun start`.** It was gated on
`!opts.skipAudit`, which means *interactive* — so it never appeared in the dev server, the
exact workflow the report came from.

### The iife is +31.8kb gzip — and that is above our own gate

`dist/iife.js` went **435.7kb → 467.5kb gzip (+7.3%)** between 1.14.1 and 1.15.0. An iife
cannot code-split, so it inlines Prism core and all 27 grammar modules. This lands on every CDN
`<script>` user and every `tosijs-ui/site` adopter without `bundleEntry`, on every page load,
and it compounds #120 where CodeMirror is already most of that bundle.

`CLAUDE.md` sets the gate for a new runtime dependency as the printed gzip delta, and this
exceeds it. It ships anyway because the alternative was a highlighter that did not work in a
browser — but the number is stated here rather than left in a commit body, and the unexplored
remedy (capping the grammar map, or marking prism external for the iife specifically) is
tracked in `TODO.md` so tagging cannot evaporate it.

**The ESM path is unaffected**: an app importing a button ships no prism at all.

### Need more icons?

The icons page now points at [lucide](https://lucide.dev) — a maintained fork of Feather in the
same style with far more options, ISC licensed. It emits the same geometry Feather does
(`24×24`, `fill="none"`, `stroke="currentColor"`, width 2, round caps), so a downloaded SVG
drops into `defineIcons()` and inherits colour and sizing like a built-in.

### Smaller corrections from the pre-release review

- **`checkExamples` no longer hard-fails on a fence that will never run.** It reduced
  ` ```js:static ` to `js`, so a block marked illustrative — or any block under
  `liveExamples: 'opt-in'` that never asked to run — failed the build over its syntax, advising
  the author to retag it `typescript`: exactly the mislabelling this release abolishes. It now
  keeps the fence mode and gates on the same predicate the highlighter and `insertExamples` use.
- **`highlightBlocks` asks that predicate too**, rather than relying on `insertExamples` having
  already wrapped live blocks. That held only by ordering, which nothing enforced, and
  tokenizing live-example source is what cost seven doc tests mid-cycle.
- **`ensureGrammar` distinguishes "no such grammar" from "failed to load".** The first is a
  plain code block; the second is a real problem and now says so. Conflating them is why a
  browser-side failure shipped silently.
- **The `docPaths` escape from the default `reviews/` exclusion actually works** — the basename
  test ran on the roots too, so naming the directory produced nothing while three shipped
  places said otherwise.
- **The `-test-only` class** lets a stylesheet present a test-only example's results as its
  body rather than as an annotation under an empty preview.
- **The barrel guard checks two properties**: a plain component build ships zero prism, and
  every grammar import lives inside a thunk.

### A test-only example shows its results instead of an empty box

A ` ```test ` fence with no `js`/`html`/`css` beside it has nothing to render. The preview came
up empty and the results panel stayed hidden unless a test **failed** — so a passing suite
looked like a broken example, and hid the one thing the block existed to show.

Now the results are the example's body: always visible, in flow, with the empty preview taking
no space. Ordinary examples are unchanged — their results stay a floating panel, shown on the
test tab or on failure.

Useful for documenting a pure function, an invariant, or any behaviour with no visual output.
There is a worked example in the `example` docs, which is also what the Playwright test asserts
against — the corpus had none when this was written.

### Four adoption defects from tjs-lang (#151, #153, #154, #156)

All four are silent — the build succeeds and something is quietly wrong, which is #61's thesis
in four more instances.

**`SiteConfig` gained `ignoreDocPaths`, and `reviews/` is excluded by default (#153).** A
`docPaths: ['docs']` takes the directory wholesale, and one adopter's first successful build
**published 13 internal pre-release review reports as public pages** — including BLOCK
verdicts naming an adopter. `extractDocs` already had `ignore`; it simply was not reachable
from the config. The practices doc that tells you to write review reports is the same one
warning that publishing them is the bad outcome, so the safe thing is now the default rather
than the informed choice. An explicit `docPaths` entry still wins — naming a directory skips the basename exclusion,
which applies only to directories found while walking. And when a default exclusion withholds
files it now prints the directory, the count and how to publish them, rather than dropping
pages with no receipt.

> **If you built a site on 1.14.1 or earlier, upgrading is not enough.** The default only
> protects the *next* build. Scraped review pages carry no `noindex` and are listed in
> `sitemap.xml` and `llms.txt`, so anything already deployed stays public and indexable until
> you replace it. Check your **deployed** output — not your working tree — for pages derived
> from a `reviews/` directory, then rebuild on 1.15.0 and redeploy. If they were indexed, the
> rebuild removes the pages but you may also want a removal request.

**The build says which files outside `outputDir` it will overwrite (#154).** `outputDir` reads
as a box the build stays inside and is not one: `docsJson` defaults to `demo/docs.json` and
`llms.txt` is written at the project root. An adopter who set `outputDir` to a scratch
directory *specifically to evaluate without touching their repo* lost both — including the
`docs.json` their playground reads at runtime. Warned only when `outputDir` has been moved off
its default, which is the "I am containing this build" gesture; a project on the default has an
ordinary setup where these writes are the point.

**`checkExamples` no longer blames the document for a transpiler it could not load (#154).**
`loadTransform` degrades to identity when tjs-lang is unresolvable — right at runtime, wrong
for a build-time check, because the block is then parsed as raw JavaScript. The reporter got
~30 syntax errors in **valid TJS**, each advising them to fix correct code, with the one line
explaining the degradation printed far away. Unparseable dialects are now **skipped and
reported as unchecked** — "we did not look" and "we looked and it is fine" must not produce the
same output, and neither may masquerade as "your document is broken".

**Dev rebuilds bust the browser cache (#151).** Assets were stamped with the package version,
which does not change while you work — so `hydrate.js?v=0.2.0` was byte-identical across every
rebuild and the browser kept executing the bundle it cached hours ago, through edits and
reloads. It looks exactly like "my change had no effect": the reporter spent a long session
editing CSS that was already correct and already being served. Dev builds now stamp with a
**content hash** of the bundle, the iife and the stylesheet, so it moves when the output moves
and *only* then. **Every** build does — there is no separate release mode.

Two defects in the first cut, both caught by the 1.15.0 review. `doc-system.css` was named in
the hash inputs but generated ~80 lines after the stamp was consumed, and the hash loop skips a
missing file silently — so a `theme`-only change deployed new CSS under an unchanged URL, a
regression against the version stamp it replaced. And the "release builds keep the version"
branch keyed on `opts.lock`, which no shipped caller passes and which means *acquire the build
lock*: it never ran, and an adopter using `lock` for its documented purpose would have silently
changed stamping mode. The stylesheet is now generated before the stamp, and the branch is
gone.

**A doc that documents the metadata format is no longer classified by its own example
(#156).** `<!--{…}-->` matched anywhere in the file, so a page teaching the convention was
published with the illustration's `title` and `order`. Fenced code is now stripped before
scanning and the block must start a line — the same rule `/*#` doc comments already follow.

### Static code is syntax-highlighted — on the page, in the ePub, and in print

Display-only code was highlighted **nowhere**. The pre-rendered page emitted a bare
`<pre><code class="language-ts">`, the client never touched it, and the ePub stripped the
language class entirely — so a doc system that publishes books had no highlighting in the
books.

Highlighting now happens at **build time**, which is the only thing that reaches an ePub
(readers may run no JavaScript), print, a no-JS reader, or a crawler. Token markup is in the
HTML; all it needs is CSS.

- **Site**: **two palettes**, light (the default) and dark under `.darkmode`, each
  contrast-checked against the `--code-bg` it sits on — worst case 5.32:1 light, 6.01:1 dark,
  against WCAG AA's 4.5:1. Every colour is a `varDefault`, so a single token type can be
  overridden in one mode without replacing the set.
- **Book and print**: a separate LIGHT palette, contrast-checked against the `#f6f8fa` code
  background (the weakest is ~4.6:1, above WCAG AA).
- Both the ePub and the **Print** path pass a `'none'` example policy, because neither can run
  a live example — so an executable fence is highlighted there rather than skipped. Without it
  the ePub left **237 of 284 code blocks plain** (every `js`, `html`, `css` and `test` block,
  which is most of the corpus) while this entry claimed otherwise, and the print path never
  highlighted at all, so the book stylesheet's token rules matched nothing.
- **Client**: the same pass runs after client-side navigation, and skips anything already
  highlighted, which is what keeps the pre-rendered page and its hydrated self identical.

**Executable fences are never highlighted** — they are live-example *source*, and tokenizing
them hands the example markup where it expected code. The build and the client now ask that
question through one shared predicate (`doc-system/example-policy.ts`), because for one build
they disagreed and seven doc tests failed as "Expected 0 to be 4": an example that rendered
nothing, reported as a broken component rather than a broken pipeline.

### New: `<tosi-highlight>` — by subpath, not from the root barrel

**Import it as `tosijs-ui/highlight-block`.** It is deliberately *not* re-exported from the
root barrel, for the same reason the doc-system cluster is not (#133): its import chain
reaches the static Prism grammar map, and a bundler emitted **15 grammar chunks / 357kb** for
an app that imported a rating and a select. Measured before and after — 30 chunks / 373kb → 1
chunk / 287kb.

The iife pulls it explicitly, so CDN `<script>` and doc-site users are unaffected. Bundling
grammars is right there and wrong in the barrel: an iife cannot code-split, and its consumers
want highlighting.

A tree-shakeable component for a fetched snippet, a generated example, an API response — the
cases the build cannot know about. Renders readable plain text synchronously and upgrades when
the grammar arrives, so a reader never sees an empty box and a missing grammar degrades to
plain code rather than failing.

Prism is a **lazy chunk**: an app that imports a button ships none of it. `src/index.test.ts`
asserts that against a real split bundle, since the property is easy to lose to a stray static
import.

Prism rather than reusing the CodeMirror we already ship, because #120 is an open issue about
CodeMirror being in every IIFE at 94% of the bundle — and routing static highlighting through
it would deepen exactly the dependency that issue wants to escape. It also covers the languages
a prose corpus uses and the editor never bundled: shell, python, rust, yaml, diff.

### Live examples can be opt-in, and a single fence can opt out (#140, #146)

Six fence languages execute — `js`, `ts`, `tjs`, `html`, `css`, `test` — and until now a
**static sample in one of them was impossible.** The only workaround was to mislabel the fence
(`xml` for HTML, `scss` for CSS), which changes the syntax highlighting to a language it isn't.

Two escapes. **The default does not move**, so no existing corpus changes:

```
```js:static          one fence, opted out — works under either policy
liveExamples: 'opt-in'   nothing runs unless its fence asks (`:inline` / `:iframe` / `:ide`)
```

`'opt-in'` is the setting for a **prose or book site**, where code is overwhelmingly
illustration. `html` and `css` are the ones that bite, because neither looks like something
you are asking to run: a fence showing how to style the component *in your app* is injected as
a page-wide stylesheet — a white slab in dark mode — and a fence showing the markup something
compiles to renders as an unstyled demo. Neither fails a build, so a site can ship both and
nobody finds out.

The policy reaches the client the same way `__TJS_LOCAL_BASE` does, and is **only emitted when
it differs from the default**, so an `auto` site's HTML is byte-identical to before.

`:static` is honoured under both policies, so one corpus can be written to work either way.
## 1.14.3

A single-fix patch cut from `v1.14.2`. Carries no 1.15.0 work.

### tjs-lang 0.13.13 — a quoted test block was executed in the HOST page (#135)

On tjs-lang ≤ 0.13.11, a `test '…' { … }` written inside a template literal or a
double-quoted string was mistaken for a real test block: the body ran at transpile time and
the text was **deleted from the output**. Both silent.

That is the bad case for a doc site specifically, because documentation about a language
quotes the language. `transform()` is called from the component — the **host page** — and its
output is then injected into the iframe, so anything the transpiler executes at transpile time
runs outside the sandbox that exists for exactly this. Any tjs example *showing* a test block
had it deleted from the rendered example and executed in the host page. Upstream lost 52 of 99
failures in their own conversion gate to it before finding the cause.

Fixed in 0.13.12; this moves to **0.13.13** (latest), verified across all four lanes rather
than on the release note alone. 0.13.5–0.13.13 also carry the AJS/TJS parser split, a
prototype-chain lookup fix in the VM, a source-size cap on `Eval`/`SafeFunction`, and further
literal-blindness fixes.

The pin lives in **two** places by design — `package.json` and `TJS_VERSION` in
`code-transform.ts`, which governs the CDN bundle a doc site actually loads — and both moved
together. The peer floor is unchanged at `^0.13.1`: raising it would warn on install for
people whose own pin is older, and the CDN pin is what fixes the behaviour on the default
path. **If you install tjs-lang yourself, upgrade to ≥0.13.12** — your copy wins over ours.

Reported by tjs-lang, which was blocked on this while moving its playground onto the doc-site
system. Released as a patch so nobody waits for 1.15.0 for it.

## 1.14.2

A single-fix patch, cut from `v1.14.1` rather than from `main`, so it carries none of the
in-progress 1.15.0 work.

### `import 'tosijs-ui/doc-browser'` now actually defines `<tosi-doc-system>` (#158, #159)

**If you are building a doc site with `tosijs-ui/site`, upgrade.** The one import every
adoption doc tells you to write did not register the doc system.

`doc-browser.ts` is a library module — types, helpers, `createDocBrowser()` — while the element
lives in `doc-system/doc-system.ts`, which imports *from* doc-browser. The dependency arrow ran
the wrong way, so that import could never have reached the registrar however it was written.

What let it survive several releases is that it was a **partial** success. The leaf components
(`tosi-sidenav`, `tosi-example`, …) registered fine, so your bundle looked healthy, your own
elements worked, pages served 200 — and `<tosi-doc-system>` sat in the markup inert. The
expensive half was quieter: no doc system means no `window.__docTestResults`, so an entire
inline doc-test corpus **silently stopped existing**. Not a red suite; a suite reporting
nothing. Two projects hit this independently, one of them reaching a release candidate first.

`tosijs-ui/doc-browser` now resolves to an entry that imports the registrar and re-exports the
whole documented API. **No code changes** — the line you already wrote starts working.

A more precise spelling, a named `tosijs-ui/doc-system` export, is coming in a later release.
**This one keeps working**; it is an addition, not a deprecation.

Two sentences were separately false and are corrected: `import 'tosijs-ui'` does **not** also
register the doc system. The root barrel deliberately excludes that cluster (#133 — it was 77%
of the barrel's weight, landing on every app that imported a button), so the root import gives
you buttons and an undefined doc system. That text shipped in the emitted `.d.ts`, the adoption
page and the adopter reference.

A test now imports the built entry in a subprocess and fails if the element is undefined.
Verified by mutation: strip the side-effect import and it goes red.

## 1.14.1

### The doc-test gate can tell a dropped page from a passing one

`__docTestResults` reported `{ passed, failed, pages }`, so a consumer gate could assert that
tests ran and that none failed — but not that **all** of them ran. A runner that silently lost
half the corpus satisfied every check.

That is not hypothetical. The false green fixed in 1.14.0 was a page-**selection** defect: a
substring match counted 17 pages where 16 had tests, and ran a prose page's examples in a
background iframe. Fixing the selection did not add a guard against the next one.

The resolved object now carries `pagesWithTests` and `pagesTested`, and
`tests/doc-tests.pw.ts` asserts three things where it previously asserted one: the corpus
contains test pages at all, every such page reported, and some assertions executed. A corpus
with genuinely no tests resolves with honest zeros, so "no tests exist" stays distinguishable
from "tests exist and ran".

Framed by tosijs-platform in #142, from a suite that reported **140 pass, 0 fail while twelve
cases had never executed** — skip-guarded behind emulators that were not running, asserting
`expect(true).toBe(true)`. When the emulators were finally started it found three production
bugs in ten minutes. The tests were fine; the reporting was a lie, and a comfortable one, which
is why it survived. **"We didn't look" and "we looked and it's fine" must not produce the same
output** — a reported total is that same lie one level up.

### New page: "Adopting the doc system"

Three of the four onboarding defects this release fixes (#144, #145, #146) would have been
prevented by a page that did not exist — a narrow path to read *before* the first build, as
opposed to a configuration reference nobody reads until something is already wrong.

It is organised around the thing that makes this system hard to adopt: **its failure mode is a
site that serves perfectly and shows nothing.** Not a crash, not a 404 — pages return 200, the
markup is in the HTML, the adopter's own elements register, and the page is inert. So the page
leads with a "how to tell it actually worked" checklist (is `tosi-doc-system` defined, is the
chrome there, read one emitted `<head>`, do the examples run) and then explains the four silent
failures behind those checks.

Includes the complete `bundleEntry` file rather than naming it, since "your entry should import
what your pages need" was the sentence that cost #145's reporter the most — it reads as *your*
content, not as the doc system that renders the entire site.

### `<tosi-table>` filters and sorts the whole table, not the first `maxVisibleRows` (#147)

The window was applied **first**, so both the filter and the sort saw only the first N rows.
Two consequences, both reported from production at 300k rows:

- **A matching row past the cap could never be found.** A newly-created company appended to a
  12,000-row list, with the cap at 10,000, did not exist as far as search was concerned.
- **Sorting did not sort the table.** You got the first N rows in *data* order, arranged
  nicely — not the top N by the sort.

This was known and the answer had been a warning: the comment in the render path read *"every
count, filter and sort then ran on the truncated set, so the numbers were self-consistent and
wrong"* (#82). A warning does not help someone whose search returns nothing, and it named row
counts rather than the consequence — so "showing 10,000 of 12,000 rows" gave no clue why a
search failed.

The order is now **filter → force → sort → cluster → window**. `maxVisibleRows` is a *layout*
ceiling — the browser's maximum element height — so it belongs at the end, against what will be
drawn, rather than at the start against the data.

**Cost, measured on 300k rows**, since this moves the sort from "at most `maxVisibleRows`" to
"every match": the common case (no sort set) is **0.9ms, down from 2.9ms**; a filter narrowing
to ~1% is 2.0ms; a broad sort over all 300k is **108ms**. That last one is the price of sorting
the real table, and it is paid when you click a column header — but `pinColumns()`, a schema
change and a column resize all trigger a render without changing row order, so the pipeline is
memoized against the identities that determine it (array, filter, sort, groupId, forced ids).
A render that changes none of them reuses the result.

The truncation warning now counts against the **filtered** set — "10,000 of 300,000" is
alarming and wrong once a filter has narrowed things to 40 — and says that the rows shown are
the right ones, there are simply more than the browser can lay out.

### `menuClass` — theme one component's menus without restyling the page (#148)

Menu metrics are all theme variables, and setting them on your component **did not work**.
The popup is built per-invocation and mounted in a body-level `<tosi-float>`, so it is not a
descendant of whatever opened it, and custom properties have nothing to inherit from. The only
selector that reached it was `:root` — which restyles every menu on the page, including the doc
system's own when your component is documented on a `tosijs-ui/site` site. The reporter had not
shipped their denser menubar for exactly that reason: a component reaching out to re-theme the
page around it is the wrong direction.

```
popMenu({ target, menuItems, menuClass: 'my-editor-menu' })
<tosi-menu menu-class="my-editor-menu">
<tosi-select menu-class="my-listbox">      // its listbox mounts the same way
```

The class lands beside `xin-menu tosi-menu`, takes a space-separated list, and **propagates to
submenus** — which are separate popups, so without that the theming would apply at the top
level and silently revert one level down. `menuWidth` was already a per-menu attribute, so
per-menu styling is not new here; this is the same idea for the rest of the knobs.

Chosen over the two alternatives in the report: copying the trigger's computed variables onto
the popup needs no change from adopters but is considerably more magic, and stamping a
`data-menu-owner` back-reference solves selection without solving theming.

### Adoption blockers found by tosijs-editor onboarding (#144, #145, #146)

Three defects and a credential, all found by one project adopting `tosijs-ui/site` for the
first time. Every one of them failed silently.

**A Mapbox token in `dist/` blocked adopters' pushes (#145).** Two `<tosi-map>` doc examples
carried a literal `pk.` token, which compiled into `dist/mapbox.js`, inlined into `iife.js`,
and landed in every adopter's sourcemap. It matches Mapbox's published secret pattern, so
**GitHub push protection blocked adopters the first time they committed their built site** — a
wall with someone else's name on it.

To be clear about what this is and is not: Mapbox `pk.` tokens are **public by design** —
Mapbox's own docs tell you to put them in client-side JavaScript — so the scanner match is a
false positive and there was never a secret here. The demos still use the token; it is now
assigned programmatically and base64'd, which is **not** a security measure and would be a
poor one. It exists solely so the literal string stops matching a regex. `atob()` protects
nothing from anyone, which is precisely why it suits a value that needs no protection.

`src/no-secrets.test.ts` fails the build on any credential-shaped string in `src/`, and says
plainly what it cannot see: an encoded value, deliberately. It catches accident, not intent.

**`bundleEntry` REPLACES the default bundle rather than extending it (#145).** An entry that
imports only your own library produces a site where every page renders its prerendered markup
and nothing else — no header, no nav, no menu, no live examples — with no console error, no
build warning and no 404. `<tosi-doc-system>` sits in the HTML, inert, because nothing defined
it, while your own elements register fine and make the bundle look healthy. The jsdoc now says
this in the imperative with the two-line fix, and the build **scans the emitted bundle and
warns** when `tosi-doc-system` is absent (and `tosi-example`, when the corpus has executable
fences). Grepping is sound here for once: `customElements.define` needs the literal tag, so
minification cannot erase it.

**`baseUrl` meant two different things (#144).** `generate-site` treated it as the origin and
added `basePath` on top; `make-llms-txt` treated it as origin-plus-path and ignored `basePath`
entirely. On a GitHub project page no configuration satisfied both — matching the canonical
URLs doubled the prefix in `llms.txt`, matching `llms.txt` dropped it from every canonical and
sitemap entry. Underneath were **two copies of `withBase`** (`generate-site`, and `epub`, whose
comment read "mirrors generate-site's withBase") plus this third consumer with none: one rule,
three implementations, one of them empty. `withBase` now lives once in `routing.ts`, `llms.txt`
applies it, `baseUrl` is documented as origin-only, and the build warns on the combination that
is always wrong.

**Which fence languages execute was undocumented (#146).** Six run — `js`, `ts`, `tjs`,
`html`, `css`, `test` — and the only place the distinction appeared named a subset, so the
mental model everyone formed was "js/ts/tjs/test run, everything else is display". A ` ```css `
fence showing how to style the component in your app is injected as a page-wide `<style>`; a
lone ` ```html ` fence showing the markup something compiles to renders as a broken-looking
example. Neither fails the build. Now documented as a table with display-only alternatives
(`xml` for markup), along with the grouping rule — adjacent fences form one example, prose
between them starts a new one — which is why moving a `test` block under its own heading
silently detaches it from the demo it was written against.

## 1.14.0

### Quote style no longer decides whether a live example runs (#141)

The live-example import rewriter accepted `'single-quoted'` specifiers and rejected
`"double-quoted"` ones — the same import, the same `{ named }` form, differing only in quotes.

On its own that is a papercut. What made it serious is **Prettier**: it formats fenced code
inside markdown and normalises quotes to double, so on any project that formats its `.md`,
every live example was silently converted into a non-running one. The only symptom was a
build-time warning that reads as advisory.

Reported from tosijs-3d-ensemble, where the README's headline example — the one showing what a
game imports — had never run for the life of the repo. Because it never ran, nothing noticed it
also named a function the package has never exported. Two defects in the most-read code block
there, both invisible for the same reason. Found by the doc-test corpus, not by reading.

All three context forms (`{ named }`, `* as ns`, default) now accept either quote style, as
does the `.elements` accessor.

**And the error message named the wrong thing.** It listed the supported forms and the context
packages — so a failing `import { x } from "pkg"`, a listed package in a listed form, was
answered with a sentence that ruled out its own cause. The reporter spent ~40 minutes rewriting
a multiline import that was never the problem. The message now separates the two real causes: a
specifier the context does not carry, versus a clause shape the rewriter cannot handle.

### `<tosi-code>`'s own diff overlay is readable by default (#143)

`<tosi-diff>` resolves its surface as `--tosi-diff-bg || --background`. Inside `<tosi-code>`,
`--background` is still the page's white while `--text-color` is deliberately the light code
colour — so the diff `showDiff()` mounts rendered near-white text on white, and its unchanged
lines were invisible. The one place the component mounts a diff itself was the one place the
default did not work.

`--background` is now redefined for that subtree to follow `--code-bg`, so the nested diff
inherits the editor's palette. Deliberately **not** by setting `--tosi-diff-*` here: an inner
definition beats an inherited one, so that would have overridden the very custom properties
adopters are already using as an escape hatch — a silent break of the thing the issue was
about. A consumer's `--tosi-diff-bg` is still checked first and still wins.

### Live `ts` examples load a pinned TypeScript compiler

tjs-lang's `fromTS` lazy-loads the TypeScript compiler from `DEFAULT_TYPESCRIPT_URL` —
`https://esm.sh/typescript@5`, an **unpinned major range**. It was the one hop in the entire
live-example chain that was neither same-origin nor pinned: everything else routes through
`__TJS_LOCAL_BASE` or a `TJS_VERSION`-pinned CDN. Because it is fetched at runtime it appears
in no lockfile, so `bun audit` cannot see it and "which compiler ran in a reader's browser" had
no answer.

Now pinned, via the `typescriptUrl` option tjs-lang already exposes, to the **exact version
this repo type-checks with** — so a doc example is lowered by the same compiler that validates
the source it was extracted from. A drift test asserts the pin against the **installed**
compiler rather than the declared range, because `typescript` is a caret dependency here and a
range check would call `^5.9.3` satisfied while the pin said something else.

esm.sh stays as the host — tjs-lang measured it as the only CDN that reliably serves
`typescript` as ESM. The fix is the pin, not the host; a same-origin copy is the upstream ask.

Nobody was exposed: this repo's corpus contains no executable ` ```ts ` fences.

### `MenuAction.action` is optional, so a drop-only menu item is expressible

`acceptsDrop` + `dropAction` with no `action` — a target you can drag onto but not click — is
a shape the runtime supports and `popDropMenu`'s docs describe, but the type required `action`.
When the new typecheck lane compiled the tests for the first time, four literals were silenced
with `as unknown as MenuItem` rather than the type being corrected.

The casts are gone. Two tests now pin what the widened type admits: a drop-only item appears
**disabled** in a click menu and is removed only under `hideDisabled` — the documented
`hideDisabled` contract. (The first draft of that test asserted it was filtered out entirely,
and failed; the finding implied a behaviour the code does not have.)

### `buildSite` docs: `dist/` is reset only when the build owns it (#130)

Three shipped surfaces still promised an unconditional reset — the config table and the
gotchas list in `doc-site-system.md`, and the `prebuild` JSDoc, which reaches
`dist/doc-system/site/site-config.d.ts` and is what an adopter reads on editor hover.

`dist/` is cleared only when `emitLibrary` or `libraryTsconfig` means this build produces it.
A `libraryBuild` function owns the directory and may deliberately emit a subset, so it is never
cleared — **if you supply `libraryBuild`, cleaning `dist/` is yours.** The output dir is still
always reset, which is the part `prebuild` authors actually need to know.

### Security: the source editor can no longer write files the build executes (#128)

`editableSources` writes were contained only to the project root — which includes
`.git/hooks/*`, `bunfig.toml` (preload), `package.json` scripts and `bin/`, every one of which
runs on the developer's next ordinary command. With CSRF closed (#90/#121) there was no known
path to an unauthorised write; this removes the *consequence* of a future one.

`editableSourcePaths` / `mayEditSource` build an **allow-list derived from the doc corpus**,
not a deny-list of dangerous names: the endpoint exists to edit the source of a page you are
looking at, so the writable set is exactly what the extractor scraped. A deny-list needs
extending every time someone invents a new way to make a file execute and is wrong by default;
this is right by default. It **fails closed** — an unreadable corpus permits nothing.

**The first version of this was not enough, and the pre-release review caught it.** A doc
source can itself be an executed script: `bin/make-icon-data.js` carries a `/*#` block, so it
was a doc source, so it was writable — and `bin/dev.ts` spawns it on *every* build. The
original tests passed because the synthetic corpus omitted `bin/`, and the live check used
`.git/hooks/pre-commit`, which the corpus never contained. Both proved something true and
irrelevant. There is now an `isExecutedByBuild` deny layer (all of `bin/`, the spawned `-cli`
scripts, `generate-css`), and the tests run against the **shipped** corpus with an assertion
that the dangerous entry is really in it — otherwise the refusal test proves nothing.

The **read** half is gated by the same predicate. It was left open initially on the grounds
that the reported issue was a drive-by write; the review was right that the argument for
symmetry was stronger, since "view source" only ever reads a doc's own source.

### The dependency audit knows where a package sits (#56)

`runtimeReachable` / `classifyReach` walk the manifest graph and label an advisory's package
**runtime** or **build-only**. Reported from an app monorepo running `buildSite`: 18
high/critical advisories of which the runtime-reachable subset was a small fraction, so
blocking on all of them would have bricked local dev over risk they do not carry.

Conservative by construction — root `dependencies`, `optionalDependencies` **and
`peerDependencies`** are runtime seeds (a peer ends up in the consumer's app), and an
unresolvable package counts as reachable. A finding silently downgraded is the one outcome
worth engineering against.

`audit.blockOn: 'severity' | 'runtime'` defaults to `'severity'` — today's behaviour. Set
`'runtime'` and a build-only finding is reported but does not block. If the manifest walk fails
the filter is disabled entirely, because "we could not tell" must not read as "not reachable".

A build that passes **only because of an active gate** now says so:

```
🟡 Build: PASSING ON A WAIVER — 1 finding(s) at or above high are suppressed
   by an active gate. This is not a clean audit.
```

Only when the waiver is load-bearing: a gate suppressing something below the threshold would
have passed anyway, and flagging that would train people to ignore the marker.

### The doc-test runner stopped executing pages that only *mention* tests

`docsWithTests` matched ` ```test ` as a **substring anywhere in the document**, so a page
merely writing about the test tier qualified — and was rendered in a background iframe with its
examples executed. A failure in an example on a page with *no tests* therefore surfaced through
the doc-test tier, and only sometimes, depending on how quickly the TypeScript compiler loaded.

That produced a **false green**: a standalone doc-tests run reported "62 passed" against a build
the full suite failed on. Page selection now requires a fence at the start of a line, the same
rule the doc extractor applies to `/*#`.

The example it was hiding was ours: a ` ```ts ` fence in `doc-site-system.md` — added in the
#93 write-up — was **executable**, so the doc-test lane ran `Bun.spawn` and `process.on` in a
browser and threw `ReferenceError: process is not defined`. It is now ` ```typescript `, which
is display-only. One character, and it had been red on `main` from the moment it was pushed.

Related and still open (#138): an example that *compiles and then throws* is caught by nothing
on a page without a ` ```test ` block. The build-time check catches what will not compile, not
what compiles and fails.

### An existing `firebase.json` is checked against `outputDir` (#134)

`host: 'firebase'` scaffolds a config for a fresh project and keeps its hands off an existing
one — but checked nothing, so `buildSite` could write `docs/` while `firebase deploy` published
`.demo/`. Both commands succeed and the site serves whatever was there last. It now warns,
handling multi-site arrays, framework-aware hosting with no `public`, and `./docs` vs `docs`
spelling, and warns only when *every* declared target disagrees.

### tjs-lang 0.13.4 → 0.13.11, and the two pins can no longer drift (#135)

We were pinned to a version npm has **deprecated**, seven releases behind. The deprecation's
threat model does not reach live-example — it is about a server transpiling submitted source —
but sitting on a deprecated version with seven releases of fixes is its own problem.

`TJS_VERSION` (what the *published site* fetches from a CDN) and the exact devDependency (what
tests and local builds use) live in different files, so a test now asserts they agree. Drift
there is invisible exactly where it matters: every lane stays green while the deployed site
transpiles with a different version.

### `<tosi-example>` warns when executable fences collide (#139)

`js`/`tjs`/`ts` all write the same single-valued slot, so a second executable fence silently
overwrote the first — the earlier block vanished from the page, the example ran as the later
dialect, and nothing said so. Behaviour is unchanged (last one still wins); it now names the
file and which dialect survived. This is the seam a dialect selector needs, which is why it is
worth fixing before that feature rather than during it.

### `getSelectText` returned the empty string on every call

`<tosi-filter>` read `select.options[select.selectedIndex]`, and `selectedIndex` has never
existed on `TosiSelect` — it selects by value. So every filter chip read ` "needle"` with two
blanks where the field and condition belong, which looks like a styling bug. Found by adopting
tosijs 1.10.0, whose removal of `Component`'s `[key: string]: any` index signature made the
compiler able to see it for the first time.

### Markdown is no longer formatted, and `bun format` is green again

`*.md` is in `.prettierignore` by request from tosijs. `proseWrap` was already `preserve`, so
prettier's only remaining markdown behaviours were escaping literal characters
(`a * literal` → `a \* literal`), padding table cells, and rewriting `*` bullets — the first of
which edits a document's *content*, and markdown is the product here.

Separately, `bun format` had been exiting 1 on seven ESLint errors: CI gates `format-check`,
which runs prettier only, so the ESLint half of the documented pre-commit command was red
without failing anything. Among the fixes: two dead locals in the CSRF same-origin check
(checked rather than assumed — the logic was sound, only the initialisers were dead); a dead
`explanation` in `dev-server.ts` that was built and never used, left over from before
`invite-page.ts` took ownership of that copy in #114; and an
`AsyncFunction` construction failure that now rethrows with `{ cause }` so the engine's own
error — the one naming *which* parameter it choked on — is not discarded.

### `icons.*` was typed `SVGElement`, and composites are spans

**This is why the release is a minor.** `SVGIconMap` declared every icon as
`ElementCreator<SVGElement>`. A **composite** icon — any name containing `$`, the stacking
syntax `icon-composition.md` documents and the demos use throughout — returns an
`HTMLSpanElement`.

Measured rather than reasoned about:

```
icons['tosiHat$tosi']()  → SPAN, instanceof SVGElement === false
icons.lock()             → svg,  instanceof SVGElement === true
```

`resolveIcon` has always returned the honest `Element`; only the cast on the exported proxy
disagreed. It is now `ElementCreator<IconElement>`, and **`IconElement` is exported from
`icon-types.ts` for consumers holding an icon** — defined as `SVGElement | HTMLSpanElement`.
Both members carry `.style` and `.dataset`,
which is what callers actually reach for — widening all the way to `Element` would break
working code to no purpose.

**Why a minor rather than a patch.** Nothing about the runtime changed; a composite has always
been a span. But code that annotated an icon as `SVGElement` compiled before and does not now,
and "a patch never breaks you" is the contract. This breaks you out of a false belief, which is
still breaking.

**The lie was load-bearing.** Correcting it surfaced **14 sites in this repo** — `data-table`,
`notifications`, `rich-text`, `segmented` — that stored or passed an icon as `SVGElement`. Any
consumer doing the same has the same latent bug, which is the argument for fixing the type
rather than the tests. If your build newly complains, that is the bug being shown to you: the
fix is `IconElement`, or `as SVGElement` at a site you know is not a composite.

### The test suite is type-checked now (#73)

`tsconfig.json` excludes `*.test.ts` — right for emit, and it meant nothing ever compiled them.
**48 errors had accumulated unseen** (the issue counted 40 when it was filed; it grows).
`tsconfig.tests.json` plus a CI step brings it to zero and holds it there.

The icons defect above is what that gate found on its first run. Two test files carried
duplicate imports — `sessionCookie` and `nodePath` — which is invalid ESM that the runtime
tolerated and nothing checked. The other 46 were genuine test-side noise, fixed honestly
rather than with blanket `any`.

A type-level test asserts nothing if nothing compiles it — which was already the argument for
`typecheck-guards`, applied to everything else.

### The test lane itself, honestly

Three things the pre-release review found in the tests rather than in the code, recorded
because a suite that is quietly wrong is worse than one that is loudly red.

**Six tests were deleted and the suite stayed green.** Adding the #139 cases to
`insert-examples.test.ts`, `cat >` was used instead of `cat >>`, wiping two `describe` blocks
that covered still-shipping behaviour — grouping across a baked `<script>`, and fenced
execution modes. Nothing complained, because fewer tests all passed. That is the exact shape of
a coverage regression no gate catches: green is not a measurement of how much you asked. The
blocks are restored, and the #139 cases rewritten against the same stub creator the originals
used.

**`ENOENT reading "https://esm.sh/typescript@5"` during `bun test`** came from the same
rewrite: the real `liveExample` transpiled a `ts` fence, and the rejection leaked across test
files, printing under an unrelated file's header. Zero occurrences now — and worth noting that
the message named a URL while claiming `ENOENT`, which is why it read as noise for as long as
it did.

**`caddy-install.test.ts` was flaky about one run in ten.** Each of its tests cold-starts a
real `bun` to exercise the generated remote script, so its cost is interpreter startup — ~1.2s
each, with the file swinging 3.5s to 10.1s under load. Under bun's 5s default that put single
tests over the line whenever the machine was busy. They now carry a 20s budget that describes
the work; a test exceeding *that* is a real defect rather than machine load.

### Every bin answers `--help`, and refuses what it does not understand (#85)

`caddy-install`, `deploy-preview`, `tunnel` and `release-notes` each carried their own
`flag()`/`has()` pair. One shared `parseArgv` replaces all four.

**Refusing unknown flags is the load-bearing part.** `tosijs-caddy-install --status` — a flag it
never had — used to read as "no flags at all", and the bin went on to ssh into the configured
host. The same shape was still live in `tosijs-deploy`. A tool that contacts a machine must not
read an instruction it does not understand as consent to proceed.

`--help` comes with it, and is parsed **before** the site config is read, so it works from any
directory. That matters because no lane runs a shipped bin's argument handling —
`smoke-consumer.ts` checks shebangs and deliberately does not execute the bins that reach the
network — so `tosijs-release-notes --help` shipped unrecognised and died on a raw `ShellError`
outside a git repo.

Also: `deploy:index` now asks for `PREVIEW_HOST` like the bins, instead of the `PREVIEW_SSH`
they moved off.

### Magic-link codes are eight letters, no digits (#132)

```
🔗 code:  JCYGCCVX   (case-insensitive)
```

The code is typed by hand into a floating keyboard on a headset. Crockford base32 was already
chosen for typo-resistance and that was right as far as it went — but it contained **digits**,
and on a headset the letter↔number switch is a trip to a different keyboard page. A reported XR
session minted `A70MDCD` and `X9Q3Z53`: three digits each in seven characters, so three round
trips for a code being gaze-and-pinched one glyph at a time.

**The extra character is free; the mode switch is not.** Eight letters of a 22-letter alphabet
is **35.7 bits against the old seven-of-32's 35.0** — slightly _stronger_, while never leaving
the alphabetic keyboard.

`I`, `L`, `O` and `U` stay excluded. `I`/`L` are indistinguishable across case, which matters
_more_ now that there are no digits, because the code is case-insensitive so the reader cannot
use case to tell them apart. Redemption also maps back the digits someone might type for a
letter they misread (`5`→`S`, `2`→`Z`, `8`→`B`, `6`→`G`) — pure upside, since a digit can never
be part of a real code — and ignores hyphens and spaces.

Rate limiting is unchanged and still does the heavy lifting: redemption is serialized at ~10
attempts/second, so 22⁸ takes ~174 years to exhaust and a five-minute window is about 1 in 18
million.

`dev-auth.ts` itself went on arguing from **32⁷** — ~111 years, 1 in 11 million — for the whole
of the change that made the token 22⁸. The conclusion never stopped holding, which is precisely
why nothing surfaced it: a safety argument that survives its own premises being replaced is not
being read. The figures are corrected in place, with the superseded ones kept beside them, since
this is the one file where the numbers *are* the argument rather than a description of it.

The invite page's code box (shipped for #75) covers most of the rest of the ask: bookmark the
host once, then each session is eight letters into a text field. The `dev.tosijs.net/<code>`
short URL needs host-side infrastructure and is tracked in `TODO.md`.

### BREAKING (narrow): the root barrel no longer re-exports the doc system (#133)

`import { … } from 'tosijs-ui'` no longer gives you `code-editor`, `doc-browser`,
`doc-system/doc-system` or `live-example`. **Import them by subpath instead** — they were
already reachable that way, and nothing else changes:

```typescript
import { tosiCode } from 'tosijs-ui/code-editor'
import { createDocBrowser } from 'tosijs-ui/doc-browser'
```

**Why.** `code-editor` pulls CodeMirror; `live-example` and `doc-system/doc-system` each pull
`tjs-lang` independently. This package has no `sideEffects` field — correctly, since
`elementCreator()` registers elements at import time and a blanket `sideEffects: false` shakes
a bare `import 'tosijs-ui'` down to zero registrations — so a bundler must treat every
`export *` target as side-effectful and cannot drop it. All four were therefore eager in any
app that imported _anything_ from the barrel.

Measured by snowfox-app moving a 15MB React bundle to the bun bundler, and reproduced here:

|                         | size                                 |
| ----------------------- | ------------------------------------ |
| barrel as shipped       | 1.68 MB                              |
| barrel without the four | 0.38 MB                              |
| real app bundle         | 15.21 → 13.86 MB (**1.35 MB, 8.9%**) |

The doc cluster was **77% of the barrel**. For contrast the 3D, map and lottie components —
the ones you would expect to be the heavy tail — cost about 10kB, because they already
lazy-load properly.

The doc site and CDN `<script>` users are unaffected: `index-iife.ts` imports all four
explicitly, so the weight lands on the bundle built _for_ the doc site rather than on an app
that imports a button.

### New: `tosijs-ui/codemirror` — extend `<tosi-code>.editor` without a silent no-op (#131)

`<tosi-code>` exposes the raw `EditorView` and the 1.7 notes invite you to configure CodeMirror
through it. But CodeMirror 6 keys facets, `StateField`s and gutters by **object identity**, so
an extension built from a _second copy_ of `@codemirror/state` or `@codemirror/view` is not
mismatched — it is **silently ignored**. No error, no warning; the gutter never renders.

Reported with a real repro: a consumer's `@codemirror/view` resolved to `6.43.6` while ours
hoisted to a nested `6.43.10`, so their `gutter()` and `GutterMarker` came from a different
instance than the view. `@codemirror/state` happened to dedupe, which made it worse — enough
worked that the failure looked like a bug in their gutter code.

```typescript
import { gutter, GutterMarker, StateField, StateEffect } from 'tosijs-ui/codemirror'
```

Only `@codemirror/state` and `@codemirror/view` are re-exported — the two needed to extend a
view, exactly as the report identified.

**Why not peer dependencies**, which the issue proposed and is the obvious fix: a required peer
would make every consumer install two CodeMirror packages including the overwhelming majority
who never render an editor (`elementCreator()` registers eagerly, so importing tosijs-ui is not
evidence of using it), and an _optional_ peer is worse than either — the component silently
does nothing until you discover you needed 12 packages. Re-exporting removes the failure by
construction rather than policing it: there is no version to match and no `overrides` to write,
because there is only ever one copy.

### `buildSite` no longer wipes `dist/` — it is your package output, not a site artifact (#130)

The prebuild ran `rm -rf dist/` on **every** `buildSite`, including from `devServer` and
including for projects that configure no library build at all. For the repos this API exists to
serve, `dist/` is the **published package output** — an input to `npm publish` — so the wipe
destroyed build products the current run does not rebuild.

Reported from tosijs with the full chain: two bundles (`module.debug.js`, `module.safe.js`,
exposed as the `./debug` and `./safe` subpath exports) are produced only under `--build`,
because they need a 53-file transpile too slow for the dev loop. `bun start` — or Playwright's
`webServer`, which runs it — wiped `dist/` and rebuilt five of seven. A publish from that tree
ships two subpaths that throw `ERR_MODULE_NOT_FOUND`, and it reached a commit once already.
Nothing local could catch it: size and smoke gates iterate the bundles the current run _built_,
so a bundle that was **deleted rather than skipped** is invisible to all of them.

The rule is now "clean only what you wholly generate":

| configuration                                                  | `dist/` cleaned? |
| -------------------------------------------------------------- | ---------------- |
| `emitLibrary` / `libraryTsconfig` (tsc emits the complete set) | yes              |
| `libraryBuild` — a consumer function that may emit a subset    | **no**           |
| none configured — nothing here owns it                         | **no**           |

The doc `outputDir` wipe is unchanged and stays: that directory is wholly generated, and the
build announces it. `dist/` is different.

Incidental: `emitLibrary` runs `tsc --incremental`, whose `.tsbuildinfo` lives in `dist/`.
Wiping it every run meant incremental compilation had never once done anything.

### `bun start` serves in ~3.6s instead of ~23s, and prints 13 lines instead of 37

**20.1 of those 23 seconds were the dependency audit**, running synchronously before the port
was bound — where it hit its own 20s timeout, gave up, and **failed open**. So the stall
bought nothing on that run: it was the cost of a check that did not happen. Measured against
the live registry, `bun audit` takes **79.5s**.

The code justified blocking on the grounds that "the audit is sub-second… a gate you wait for
cannot be raced". The premise was false, so the conclusion was buying a twenty-second stall on
every start. It now runs after the server is up and reports when it lands, prefixed loudly
enough that a late advisory cannot be mistaken for part of the startup banner — non-blocking
must not mean easy to miss. **Release builds still block**: `buildSite` audits synchronously,
and `--test` gates there too, which is where waiting is the right trade.

**Build output is 13 lines, down from 37.** Eighteen of them were `bun build` listing every
chunk and every sourcemap — the same size information the build already summarises, only
longer. Captured now, and replayed **in full** on failure; verified by deliberately breaking
the bundle entry, which still prints the errors, exits 1, and restores the last good build.

Spam is not free: it trains people — and agents — to stop reading build output, which is
exactly what lets a real failure through.

The ePub was the assumed culprit and is innocent: 0.34s of the 23. Measuring first is the only
reason the actual cause was found.

### `docs.json` is cache-busted, so a stale corpus stops surviving a deploy

Every generated URL went through the asset stamp — the stylesheet, the hydration bundle, the
IIFE — **except the one that matters most**. `docs.json` is the entire corpus: nav, routes and
every page's content, and it was emitted bare.

A static host sends no `Cache-Control` for it, so browsers apply **heuristic** caching — free
to invent a freshness lifetime when you decline to state one. The site then renders a previous
deploy's corpus against the current bundle, and nothing in the console says so. It presents as
pages that will not update, and the fix is a hard reload nobody thinks to try. It cost a
maintainer a long debugging session before it was traced to a Chrome cache entry.

The corpus gets **its own stamp**, hashed from `docs.json`'s own bytes rather than the project
version. The version is right for the bundles — it moves once per release, which is when they
change. The corpus does not work that way: it changes whenever anyone edits a doc, many times
within one version, and a preview host redeployed mid-version is the normal way this project
reads its own site. A version-keyed corpus URL would have been stale for exactly that workflow.

The dev server was already correct here — it sends `no-store, must-revalidate` on everything,
verified. This was only ever the built static site.

### `<tosi-table>` stops silently capping at 10,000 rows (#82)

`maxVisibleRows` defaulted to a flat `10000` and `slice`d everything past it **without a
word**. A 25,000-row table showed 10,000, and every count, filter and sort then ran on the
truncated set — self-consistent, and disagreeing with the data. The property was documented
nowhere, so the only way to find it was to lose rows and go looking.

The number was wrong because it was sized as a _rendering-cost_ limit. It is a **layout**
limit. A virtual `listBinding` renders only the visible window, so the UI cost is O(1) in row
count — 300,000 rows scroll like 300 — and the array size is irrelevant. What binds is the
spacer element's height against the maximum height a browser will lay out.

So the cap is now derived at runtime: `maxElementHeight / rowHeight`, which at the default
30px rows is on the order of half a million to a million rows. **Probed, not hardcoded**,
because the ceiling is engine- _and version_-specific — two Chromium measurements in this
project sit a factor of two apart (Chrome 151 clamps at 16777214px, the Playwright Chromium
at 33554428px). Any constant would be wrong for somebody, in the direction that loses rows.

And when a cap does bite, it now **says so in the console** with both numbers and how to
raise it. `rowHeight: 0` stays conservative on purpose: with no fixed row height there is no
virtualisation, every row is a real DOM node, and the cost genuinely is O(n).

Set `table.maxVisibleRows = n` to override, exactly as before.

### A large `rowHeight: 0` table now complains, loudly (#84)

`rowHeight: 0` turns virtualisation off — every row becomes a real DOM node, and sorting,
filtering and group toggles walk all of them on every render. It is documented for "smaller
tables, or tables with variable row-heights", and that is the only thing it is for.

Past **1,000 rows** the table now warns **on every render** until a `rowHeight` is set. That
is deliberate, and deliberately unlike every other notice in this codebase, which fire once:
those describe states a developer may not be able to change, whereas this is a one-line fix
that persists until made, and a single notice scrolls out of the console and is gone.

The originally suggested fix was to binary-search the scroll-anchor walk. That was declined:
it optimises a configuration nobody should be in, and makes a misconfiguration marginally
less bad instead of surfacing it. A table big enough for the cost to matter is big enough to
virtualise, and virtual tables are O(1) in row count.

### `<tosi-3d>` is deprecated — use `tosijs-3d`

Nothing breaks: it still works, still ships, and stays until a future major. But new work
should use **[`tosijs-3d`](https://www.npmjs.com/package/tosijs-3d)**.

It is a much better library, and maintaining both is duplicated effort spent against the
weaker one. `<tosi-3d>` is a thin Babylon wrapper that grew inside a general-purpose UI
library; `tosijs-3d` is a project actually about 3D and XR, with the declarative component
set and XR support that implies. Every hour spent here is an hour not spent there, and the
result would still be the lesser of the two.

`tosijs-3d` grew out of this component, so migration is mostly a rename.

The deprecation appears in three places, deliberately: the doc page (what people read), the
`@deprecated` tags on `B3d`/`b3d` (what editors surface), and a console notice printed **once
per page on first use** — not at import, since `elementCreator()` registers eagerly and a bare
`import 'tosijs-ui'` is not evidence anyone is using this; and not per instance, because a page
with several scenes would spam its own console, which is how a deprecation notice teaches
people to filter warnings rather than act on them.

### The machine-health guard now reports 13GB of orphans instead of skipping them (#93)

`isDevProcess` decided what was even a _candidate_ for the runaway check, and excluded
`build` — because we spawn the bundler ourselves and a build peaking mid-run must not look
like a runaway to its own parent. But the exemption did not say "our bundler". It said
**every `bun build` on the machine, forever, regardless of owner or age**, so an orphan whose
parent died a week ago was exactly as exempt as one that started 200ms ago.

Reported with measurements: **69 orphaned `bun build --watch` processes holding 13.09GB**, ~55
of them spawned inside a single 61-minute window, on a 128GB box running these very dev servers
and sitting at 116MB free. The guard looked, and skipped every one — while resting on the
premise that "nothing noticed the runaways because nothing ever looked".

`--watch` is the distinguishing signal, and it was right there in the argv: a watcher is a
server whoever started it. The mid-rebuild false positive the exemption existed for is already
handled, and handled _precisely_, by parentage — the assessor filters `process.pid` plus
`descendantsOf(process.pid)` before assessing anything. That is the exact test where a
subcommand name was a blunt proxy for it, so this costs no false positives. `bun run` with
`--watch` is caught too; the reported machine had four such servers up to 18 days old.

**Visible is not the same as caught**, and the first pass at this shipped the difference. Every
trigger in the assessor asks "is _one_ process too big?", and 194MB is nowhere near any of them —
so dropping the exemption walked the guard past all 69 orphans and returned `ok` for 13GB, while
the note above claimed they had been dealt with. The assessor now carries a rule that never
consults RSS: **a watcher whose parent is gone (`ppid` 1) is waste at any size**, reported with
the `kill` command. One 12MB orphan warns. It warns rather than refuses, because a build that
will not start because of someone's stray process is a guard people switch off.

"A bunch of similar names" was the other candidate signal and is deliberately _not_ used: three
identical `bun --watch bin/dev.ts` lines are usually three sibling projects each running their
own server. Identical argv is not duplicate work — the distinguishing fact is the cwd, which
`ps` does not report.

**And the other half, in `doc-site-system.md`:** "shell out so the OS reclaims the memory on
exit" is only true if the child _exits_. A `dev.ts` that spawns `bun build --watch`, itself run
under `bun --watch dev.ts`, leaks one bundler per keystroke — the previous child is never
signalled. The doc now carries the reaping idiom (kill the previous child; reap on
`SIGINT`/`SIGTERM`/`beforeExit`) beside the advice that produced the leak. This project's own
builds are one-shot and were never affected.

### `layout: "full-screen"` finally gets its scroll container (#119)

`.doc-content`'s `overflow` is an **inline** style set by the doc-browser, so the full-screen
layout's stylesheet `overflow: auto` was competing with an inline style and always lost. The
`height: 100%` landed and the scroller did not — a full-screen page taller than its box was
clipped and unscrollable. It sat one line below a `padding` that correctly went through a
variable, with a comment explaining exactly why it had to.

Both now go through `--doc-content-overflow` (default `hidden`), on the inline style **and**
the pre-hydration rule — they have to match, or an override would take effect only after
hydration and the page would visibly change shape.

The second consequence is the one worth knowing about: `overflow: hidden` on an ancestor
kills `position: sticky` in its subtree, so a page wanting a sticky viewport-sized window
inside the article previously needed `.doc-content { overflow: visible !important }`. An
`!important` in a consumer's stylesheet is this component failing to offer a seam.

### A page with tests says so, even when tests are off (#113)

Tests default to on for localhost and off everywhere else, and when they are off the widget is
hidden — so **a page with failing tests looked identical to a page with no tests**. There was
an override, `localStorage['tosijs-ui-tests-enabled']`, but you had to know the key, which
means you had to already suspect there was something to see.

That bit hardest down the path we recommend: `tosijs-tunnel` necessarily serves from a
non-localhost hostname, so the sanctioned way to view a dev site remotely is exactly where test
state went invisible. A maintainer read a clean tunnelled page as "the failure must be
localhost-specific" and lost the thread.

The app menu now offers a **Tests** toggle — but only on a page that has tests, where it reads
`Tests (N not run)` while they are off. `pageTestCount()` is exported from live-example for
this: how many examples carry tests, regardless of whether tests are enabled — the number that
lets a UI say a runner exists here without turning it on. A published page with no tests offers nothing, so the
default stays as quiet as it should be. The default itself was never the problem; the silence
was.

### `/version.json` stops restamping a site that did not change (#122)

`docs/` is committed here and in the sibling projects, and the stamp named
`git rev-parse HEAD` — so **every commit's `version.json` named its own parent, forever**, and
no build→commit cycle converged:

```
build at A → version.json says A
commit     → B contains a version.json saying A
rebuild    → version.json says B → dirty
commit     → C contains a version.json saying B → …
```

`build-stamp.ts` already argued against a wall-clock stamp for exactly this reason and missed
that the argument applies one level up: `HEAD` is not a clock, but in a repo that commits its
own output it moves on the same cadence. Small, and it cost something real — a false positive
on `git status` at the moment you are trying to confirm a release tree is clean, so it gets
checked, re-checked, and eventually ignored.

The fix is the reporter's: if nothing else in the output changed, leave the stamp alone. A new
`contentHash` field records what the rest of the output hashed to, so the next build can tell
"changed" from "unchanged" without keeping a copy of the old tree — though as it happens the
previous build is still on disk (the build moves it aside as `<outputDir>.last-good` rather
than deleting it, and drops it only on success), so the comparison costs no extra bookkeeping.

Verified end-to-end, which is the only way to check a convergence claim: build → commit →
rebuild now leaves a **clean tree**.

The stamp is also more honest for it. It now names the last build that actually changed the
site, rather than the last one that happened to run. **Deleting `version.json` still forces a
fresh stamp** — the escape hatch for re-identifying an unchanged build, at no extra flag.

### `describeHolder(null)` says nothing is building (#123)

`describeHolder(currentHolder(root))` is the natural spelling, and `currentHolder` returns
`null` whenever nothing holds the lock — the ordinary, healthy case. It rendered the alarm
template with the fields unfilled (`pid undefined … building in undefined`), reporting a scary
problem that did not exist, in a reader whose whole purpose is answering "who holds this?".

## 1.13.0

### tosijs `^1.9.1` — and a console with nothing in it

The peer floor moves to `^1.9.1`. **The binding reason is a type-surface change, not the console
cleanup** — our emitted declarations now reference `TosiProps` and `TosiStyleSheet` (renamed
upstream from `XinProps`/`XinStyleSheet`), so `dist/*.d.ts` _hard-requires_ a tosijs that exports
them. An adopter on an older tosijs gets type errors, not warnings. The floor also carries
tosijs's **agent-surface security fix**, and the deprecation cleanup below.

**Migration note if you are on tosijs 1.7.x:** this floor takes you through **tosijs 1.8.0**,
which its own notes describe as deviating from semver — it removed `data-ref` and `<xin-slot>`.
Read tosijs's 1.8.0 entry before upgrading; that step is not automatic.

Before 1.9.1, every page of every doc site logged warnings a consumer could not act on:

- `xinValue, tosiValue, xinPath…` — tosijs reading its own deprecated proxy property
  ([tosijs#31](https://github.com/tonioloewald/tosijs/issues/31)). Nothing in any consumer's
  code caused it.
- `bindText` / `bindList` — deprecated in 1.8, then **withdrawn as a category error**
  ([tosijs#33](https://github.com/tonioloewald/tosijs/issues/33)). They are a pair: `bindList`
  supplies the per-item template and `bindText` fills it, and `{ textContent: proxy }` was
  never a replacement for the path form (measured: `textContent: '^.name'` inside a template
  renders the literal string, `bindText: '^.name'` binds). `bindText`'s runtime warning
  outlived its typings by one release and is gone in 1.9.1.

Verified on a from-scratch build rather than assumed: **the three deprecation warnings above are
gone.** An earlier draft of these notes claimed _zero console warnings_, and that was false — one warning remains and it is ours, not tosijs's. Instantiating `<tosi-tabs>` still logs an
on-event collision from our deprecated `onCloseTab` alias, which the elements factory reads as
event-handler sugar.
`onCloseTab` is a deprecated alias we keep for compatibility, so removing it is a breaking change
and waits for the next major. Stating it rather than claiming a clean console we do not have.

### Live examples: pick a side by clicking, and see what actually changed

Both diff views — the resolvable one and the read-only one behind `<tosi-code>`'s review
overlay — now let you **click a coloured line** to take that side, and mark **the words that
actually differ** more strongly than the line containing them. Dragging to select text does not
change anything; those lines are also what you copy from.

`<tosi-example>`'s widget bar used to stay **open after every action** — you clicked "maximize"
and it sat there covering the corner of the thing you had just maximised, until you remembered
to click the handle again. It now closes once you have actually done something, its controls are ordered by reach (maximize nearest the handle), and the handle is the
tosi owl on a status-coloured badge — orange running, green passed, red failed.

**Tapping that handle on a touch device no longer flashes the menu open and shut.** A touch
device has no hover, but the browser still fires `pointerenter`/`pointerleave` around a tap, so
the hover _peek_ opened the bar and then undid itself before the click pinned it — you tapped
again believing it had failed, and that second tap closed it for real.

### Source moved into the app menu

The floating `<> Source` chip that sat over the content is gone; Source is a submenu of the app
menu beside Language and Color Theme, and it disappears entirely on a page with no source file.

### Security: cross-site requests can no longer reach the dev server's write endpoints

`POST /__docstore/source` and `POST /__build` were gated on the **peer address alone** — no
credential — and the handlers parse JSON regardless of `Content-Type`. That made a cross-site
`fetch(…, {mode:'no-cors'})` a _simple_ request: no preflight, peer is `127.0.0.1`, gate passes.
Any page you happened to visit while `bun start` was running could write any file under the repo
root, `.git/hooks/*` included — code execution at your next git operation
([#90](https://github.com/tonioloewald/tosijs-ui/issues/90)).

All three state-changing endpoints — `/__docstore/source`, `/__build` and `/report` — now
require the request to be same-origin, via one shared predicate rather than a term repeated per
call site. It checks **both** `Sec-Fetch-Site` **and** `Origin`: the first is unforgeable but
absent on Safari < 16.4, Firefox < 90 and older WKWebViews, and those browsers send the second.
An earlier pass checked only the first and left exactly that population unprotected. Applied
**on the non-tunnel path only** — the tunnel is legitimately cross-site and
is gated by a session cookie instead. Non-browser callers (the CLI, `curl`) send no fetch
metadata and are unaffected; the attack this closes is specifically a _browser_ being induced to
make the request, and a browser always tells us.

The prose in `dev-auth.ts` had claimed `SameSite=Lax` gave "free CSRF protection". That was true
only of the tunnel path, which needs a cookie — the default loopback path needed nothing, so the
stated defence never covered its own primary case.

### New dev-server endpoint: `POST /__build` (loopback only)

Called out under its own heading because it is a new HTTP surface on a tool people run all day.

It exists so `bun run build` can ask a running dev server to build instead of refusing (below).
It is gated exactly like `/__devlink`: **loopback and NOT via the tunnel**. Verified against a
real server rather than reasoned about — an anonymous tunnel request gets `401` from the session
gate, and an **authenticated** one gets `404` from this gate, which is the case that matters. A
remote visitor triggering builds would be a denial of service at best, and runs the project's own
build code at worst. `GET` does nothing.

Nothing else about the build lock changed: two builders racing `rm -rf` on one output tree is
still refused, and a stale lock still gets the old message.

### ePub dates, and an `epub.modified` option

`dcterms:modified` is the only date an ePub reader can surface, and it is baked into a
committed, redistributed file — so it has to be deterministic _and_ coherent. The
version-derived encoding introduced in this release **wrapped** — twice. First `1.13.24` and
`1.14.0` produced the same instant; the repack then made `1.13.1-beta.1` byte-identical to
`1.13.0`, collided `1.0.1000` with `1.1.0`, and sent a CalVer `2026.9.3` to the year 5872. Each
attempt shipped a docblock asserting the ordering that execution disproved.

So the promise is narrowed to what is actually needed and can actually be held: **the same
version always yields the same instant, and different versions yield different ones.** It is no
longer ordered, because nothing reads these dates as a sequence — a reader sees one date on one
book — and a claim nobody needs, made three times and false three times, is worse than no claim.
The test sweep is generated rather than hand-picked, which is what would have caught the
adjacent-version collision.

New: **`epub.modified`** in the site config. The derived date is synthetic — it looks like a
date without being one — and until now an adopter had no way to publish the real one.

That option shipped broken for a few hours and is worth recording: `modified` was written
**above** a `...epubOpts` spread, and `epubOpts` _is_ `config.epub` — so the adopter's raw value
overwrote the sanitised one. `'2026-09-03'` reached the OPF unnormalised and `'not a date'`
reached it verbatim, and EPUB 3 requires exactly `CCYY-MM-DDThh:mm:ssZ`, so the obvious spelling
of the new option produced a file EPUBCheck rejects. The option assembly is now an exported
**`epubOptionsFor()`** with tests that drive **the call site**, not just the date function beside it
— the third time in this release that a green test asserted a pure function whose only consumer
threw the result away.

### The book this project ships carries a real date again

`docs/tosijs-ui.epub` is redistributed, and its OPF has no `dc:date` — so `dcterms:modified` is
the only date a reader ever sees. The version-derived scheme hashed 1.13.0 to **2012**, fourteen
years adrift, where v1.12.8's book carried a correct contemporary date. This repo now sets
`epub.modified` explicitly, which is exactly what that new option is for; adopters who care
about the date should do the same, and adopters who don't still get a deterministic one.

A related tidy: a docblock asserting the monotonicity this function does not have had survived
three rewrites by sitting forty lines above it in a different file, and was shipping in `dist/`.
The behaviour is described in one place now.

### One place decides the ePub date

`buildEpub` — the public export, and what `bun book` calls — now defaults and validates
`dcterms:modified` itself. It previously used `new Date()` with no validation, so the
determinism fix and the `epub.modified` handling lived at a single `buildSite` call site that
`bun book` never executed: that path still stamped a wall clock, re-dirtying the committed ePub,
and still passed a date-only string into an OPF that EPUBCheck rejects.

That is the fourth instance in this release of one shape: **a green test asserting a function
whose only consumer discards the result.** The date function was correct and tested; the
assembly threw its return value away. The predicate was correct and tested; two of three
endpoints used it. The touch guard was correct; the test retyped it. And the fix for the
_first_ three introduced the fourth, by testing a helper that one of two shipped paths called.

The answer is not another helper — it is a single choke point every path already goes through,
tested from the public entry point by asserting the OPF text.

### Tests that can actually fail

Two "regression tests" added earlier in this release were **tautologies**: each re-declared the
production predicate inside the test file and asserted on the copy, so both passed forever and
stayed green when the real code was broken. A mutation test in the pre-release review killed
zero of them. They now mount the real `<tosi-pocket-bar>` and import the exported delegation
predicate, and were re-mutated to confirm the opposite — deleting the touch guards and the
pin-clear turns two tests red.

A re-review then found the same shape one layer down: the delegated-build queue —
`buildNow`/`buildWaiters` — had **zero** coverage, and an unconditional infinite loop passed the
whole green suite. The loop is fixed (it started a build on every pass instead of once, spinning
the dev server into ~30 builds and a `process.exit(1)` that blamed the user's watcher config),
and the decision is now a pure exported function, `nextBuildStep()`, with tests for the
contended orderings. `POST /report` was also found never to have received the CSRF gate — the
term had been spliced into two call sites by hand and the third missed, which is why all three
now share one named predicate.

Recorded here rather than buried because it is the same failure this project's notes already
describe from 1.9.0: work asserted in prose that the code did not do. A test that retypes the
condition it is testing is that failure wearing a lab coat.

### What delegation guarantees (and what it refuses)

The delegation path above was hardened after a pre-release review found several ways it could
report a success nobody got. Worth stating, because "my build tool said it built" is a claim you
rely on:

- **It proves the build was yours.** `holder.port` is validated before it reaches a URL, the
  request carries `?root=`, and the reply carries `pid`/`root`/`outputDir` which must match the
  lock and the tree you asked for. A planted lock plus any listener answering `{"ok":true}`
  previously printed "rebuilt this tree" and exited 0 over an untouched tree — stale locks from
  other projects are ordinary on a dev machine, one of them usually on the shared default port.
- **It waits for the tree to settle**, not for whichever build finishes next — starting one
  build and then parking, with a cap so a watcher that keeps firing degrades to a message
  instead of a build storm. (The first attempt called `rebuild()` on **every pass**, and
  `rebuild()` sets `pending` when a build is in flight — so the settled check could never pass
  under contention and it re-armed forever, spinning the dev server into ~30 builds and a
  `process.exit(1)` blaming the user's watcher config. It was correct when idle, which is why
  it looked fine. A re-review caught it; the decision is now a pure function with tests for
  the contended orderings, which had no coverage at all.) A reply used to be
  released by an already-running build one line before a queued rebuild began wiping the output
  directory — success reported while the tree was being emptied, right before the documented
  `git add`.
- **It does not believe a reply it does not recognise.** A dev server predating this endpoint
  answers the POST with the SPA shell; that now reports "restart your dev server" instead of
  accusing your build of failing. Dev servers here live for days running the code they loaded,
  so that is the normal upgrade path.
- **Its timeout is the server's real one.** The client promised ten minutes while `Bun.serve`
  idles connections at 120s, so a _successful_ long build was reported as a failure. "Did not
  answer in time — the build may still be running" is now distinguished from "unreachable".

`<tosi-pocket-bar>` also gained a public `close()`. The live-example bar was setting `open =
false` directly, which left the component's internal pin set — so it re-opened on hover, stuck
open over the content, and the next handle tap did nothing. On touch, where this release removes
the hover path, there was no way back at all.

### Builds stopped fighting the dev server

`bun run build` with a dev server running now **asks it to build** instead of refusing, and
`bun run test-browser` uses its **own port (8798)** instead of reclaiming 8787 by killing
whatever holds it. Both produced the same workflow — kill the server, build, forget to restart
it — and the second twice took a live tunnel offline, where the symptom is a page reporting
"offline" with no hint which half died.

The generated ePub is **reproducible** now. It differed on every build, so it landed in every
diff and made a clean tree impossible after building — two independent causes:
`dcterms:modified` defaulted to `new Date()`, and the zip stored each file's mtime, and those
files had just been written. Entry mtimes are normalised to a constant before zipping (zip has
no flag for it; normalising the inputs is the standard fix), and the date is anchored to the
last commit that touched `package.json` rather than to a clock or to HEAD. After
commit-then-rebuild the tree carries one changed file (`docs/version.json`, which records the
commit by design) where it used to carry 68 plus a binary.

### Icons

`shift` (⇧) — the standard shift-key outline, asked for by tosijs-3d and general enough to live
here rather than in one consumer. `tab` (⇥) — an arrow pointing at a vertical bar, built on
`arrowRight`'s proportions. And `fatArrowUp`/`fatArrowRight`/`fatArrowDown`/`fatArrowLeft`: one
drawing and four rotations via the same redirect mechanism the arrow and chevron families use.

The shift glyph is centred on 12,12 (apex y=4, base y=20; it was 3..20, half a pixel high) —
invisible head-on, and what makes each rotation land square instead of drifting off-centre.

With `delete`, `cornerDownLeft` and `command` already present, an on-screen keyboard now has
everything except caps-lock.

### Smaller things

- **`<tosi-pocket-bar>` handles are brandable**: `--tosi-pocket-handle-bg`,
  `--tosi-pocket-handle-radius` and `--tosi-pocket-handle-size`, all defaulting to exactly their
  previous values, so nothing changes for an existing consumer. A circle needs all three —
  `border-radius: 50%` on a content-sized flex button is an ellipse.
- The live-example handle uses them for the owl badge, and its **controls were reordered** so
  maximize sits nearest the handle and tests furthest.
- The dead `.view-source` CSS went with the button it styled, rather than leaving rules for an
  element nothing creates.
- **`bindText` in `<tosi-table>` briefly moved to `textContent`, then reverted back to
  `bindText`.** `data-table.ts:2179` holds a proxy, which is the only shape the replacement
  accepts — but with the deprecation withdrawn upstream, keeping it would have left the file
  using two idioms for the same job with nothing gained. Net change: zero lines.

## 1.12.8

### Diffs: pick a side by clicking it, and see what actually changed

Two things, and they apply to **both** diff views — the resolvable one and the read-only one
used by `<tosi-code>`'s review overlay and the doc-browser's before-save screen.

**Clicking a coloured line picks that side**, equivalent to its button. The line is what a
reader is already looking at and pointing at; reaching for a small button to say "this one"
when the thing is under the cursor was friction with nothing behind it. Dragging to select
text does **not** change anything — those lines are also what you copy from, and without that
guard, selecting a line to copy it would silently flip the resolution and rewrite the value.

**Within a changed line, the words that actually differ are marked** more strongly than the
line around them. A whole-line wash says "something here changed", which for a one-word edit
throws away most of the signal — so the line colour tells you which side you are on, and the
run tells you what moved. `diffTokens(before, after)` and `tokenRunsForLines(lines)` are
exported if you want that data without the DOM.

Word runs, not characters: a character diff of `sat` → `sprawled` marks the shared `s` and
produces confetti. Whitespace and punctuation are tokens so the runs **reassemble each input
exactly** — a diff viewer that silently drops a space misrepresents the text it exists to show
— and whitespace between two changed words is absorbed into the change, or `sat on` renders as
two boxes with a hole punched between them. Lines over 400 tokens fall back to whole-line
marking rather than locking a tab on a minified bundle pasted into a diff.

The read-only view keeps its line order exactly. Pairing is computed per run of consecutive
changed lines, so an interleaved edit is marked correctly without the reordering that the
resolvable view's block grouping does.

`tokenRunsForLines` returns a **dense** array — one entry per input line, `undefined` where a
line has no runs. Worth stating because the obvious implementation (`new Array(n)`) is sparse,
and `map`/`forEach` silently skip holes: fine for the indexed read it was written for, a trap
for anyone iterating it.

### Generated asset URLs are build-stamped

`/doc-system.css` and the hydration bundle are now emitted with a `?v=` stamp taken from the
same build identity that writes `/version.json`, so a page cannot claim one build while
loading another's cached bundle. Stale filenames going stale is not hypothetical — it happened
on this project's own doc site and cost a round of "is this a layout regression?" before
turning out to be a cached bundle, with a hard reload as the fix nobody thinks to try.

A query rather than content-hashed filenames, deliberately: `docs/` is committed in this repo
and its siblings, so hashing would add and delete a file on every build and put churn in every
diff.

**The stamp is the project's own version**, so it moves once per release — the granularity at
which a published site actually changes — and a rebuild between releases is byte-identical. The
first attempt derived it from the commit, which was precise and unusable: it re-stamped all 68
generated pages on every commit, and because the test lanes build too, it did not terminate
(commit → build → 68 dirty → commit → build). The honest tradeoff of the version, stated rather
than left to be discovered: two builds of the same version with different code share a stamp, so
a mid-version redeploy can still serve a cached bundle. That is exactly the previous state — no
worse — and it closes the case that actually bit, a cached bundle surviving _across_ releases.

Cross-origin URLs are left alone — `scriptUrl` may point at a CDN, and busting someone else's
cache was never the point — as are URLs that already carry a query or fragment.

Consumers of `tosijs-ui/site` get this automatically; `generateSite` also takes an explicit
`assetStamp` if you are driving it directly. Leave it unset and the output is unchanged.

## 1.12.7

### Live-example and doc-test failures now tell you where

A failing doc test used to report one entry for the whole block, with no file, no line and no
stack:

```
✗ Test execution: Arg string terminates parameters early
```

The only way to narrow that is to bisect the file by hand, which is what it cost the person who
reported it ([#111](https://github.com/tonioloewald/tosijs-ui/issues/111)) — and both theories
the bisection bought were wrong, because the message named nothing that was actually involved.

Three things were wrong, and none of them were the transpiler:

- **The stack was discarded.** The catch kept `err.message` only — fifteen lines below the code
  that tags the body `//# sourceURL=inline-test` precisely so that stack would carry usable line
  numbers. Failures now read `message | the offending source (line N)`.
- **Live examples never tagged a sourceURL at all**, so a broken example's stack pointed into
  the minified bundle. Now tagged, and the preview error carries the same location.
  `notDefinedAnywhere is not defined` became
  `notDefinedAnywhere is not defined | notDefinedAnywhere() (line 3)`.
- **The line numbers were off by two, and always had been.** The `Function` constructor
  synthesizes a header, so every line an engine reports is shifted from the line the author
  wrote — measured at **+2** in Chromium and Firefox, for `Function` and `AsyncFunction` alike,
  regardless of parameter count, and additive. So assertion failures have been naming the line
  _two below_ the failing assertion for as long as that feature has existed, and quoting that
  line's source alongside it. That is worse than saying nothing: it reads as correct, because
  the quoted text is real code from the same file.

The offset is **calibrated at runtime** by one cached probe rather than hardcoded to 2 — it is
an unspecified detail of whichever engine is running, and a stale constant would reintroduce
the defect it fixes. If the correction would produce a line below 1, no line is reported rather
than a false one. WebKit produces no locatable frame for constructed functions, so it falls back
to the bare message, exactly as before.

### A constructor failure now names the context keys

The remaining case has no line to report at all, because nothing ran: the `AsyncFunction`
constructor rejected the code before executing it. That was #111's actual failure.

If the same body compiles with **no** parameters, the body is fine and the caller's `context`
keys are at fault — so the message now says that and lists the parameter list they became. A key
that cannot be an identifier (a subpath like `pkg/sub`, a scoped name, a leading digit) is the
usual cause, and none of that was inferable from `Arg string terminates parameters early`.

## 1.12.6

### `<tosi-diff resolvable>` — "do I want to accept this?"

The diff viewer can now be operated, not just read. Set `resolvable` and every change gets a
pair of buttons; `value` is the text those choices imply and `change` fires when one moves. It
is a review surface for a **proposed** edit — an AI suggestion, a collaborator's revision, a
file that moved under you while you were editing it.

The labels are yours: `originalLabel`/`modifiedLabel`. "Mine"/"Theirs" is git's framing,
"Current"/"Proposed" suits reviewing a suggestion, and this component's own vocabulary is
original/modified — none of which is right for everyone, so none of it is hardcoded. Also
`acceptAll()`, `rejectAll()`, `changeCount`, and a `resolutions` array you can read and write.
`diffBlocks()` and `resolveDiff()` are exported too, if you want the model without the DOM.

The unit of decision is a change **block**, not a line. The LCS walk can emit
remove/add/remove/add for a single multi-line edit, and offering four independent choices there
lets a reviewer accept half an edit — producing text neither side wrote.

The read-only path is deliberately unchanged: grouping reorders interleaved edits, which is
better to resolve and a **different rendering** for the two components already shipping this
one. Additive means additive.

Two notes on how this was tested, because **both** bugs that nearly shipped were invisible to
the unit lane, and for the same underlying reason.

The first version rendered **nothing**: `dataset: {…}` in an element creator assigns
`el.dataset`, a read-only accessor, so `render()` threw and left an empty shadow root.
Twenty-six green tests sat behind it, every one asserting `value` — which is computed from the
model and is correct whether or not a single element reaches the DOM. tosijs renders on an
animation frame and happy-dom runs none, so a unit test that merely _mounts_ an element asserts
against an empty shadow root; appending hydrates, and `render()` must be called explicitly.

The second was worse, because the render-level tests added for the first one _also_ passed. The
buttons did nothing when clicked, in every browser. `<tosi-diff>` is a shadow-DOM component and
tosijs delegates `on*` handlers from above the shadow boundary, so a real click is
**retargeted**: `event.target` is the host, not the button, and `event.target.closest(…)` — the
idiom this project documents, and which is correct for a light-DOM component — is `null` every
time. The fix is `composedPath()`, the only one of the two that survives the boundary.

The happy-dom test could not see it: it called `.click()` on a node it had already looked up and
then asserted the value moved. Measured against a deliberately broken build, **30 unit tests
pass and all 3 browser tests fail** — so `tests/diff-resolve.pw.ts` now clicks what a person
clicks and reads what a host reads. The rule this leaves behind: **shadow-DOM interaction needs
a browser test**, not a DOM-shaped unit test.

### Selective revert in live examples

The diff resolver is wired into `<tosi-example>`'s **View changes**, and into `<tosi-code>`
generally via a new `diffResolvable` (plus `diffOriginalLabel`/`diffModifiedLabel`).

Revert was all-or-nothing, which is the wrong shape for what actually happens: you tried four
things and three worked. Each change in the diff now carries keep/revert buttons — labelled
**Source** and **Yours** in an example — defaulting to _keep_, so the feature costs nothing to
ignore and doing nothing leaves your edit exactly as it was.

Choices are applied when the diff **closes**, not as you click, and a `change` event fires if
the text moved. Writing each choice straight back into `value` would feed the new text into the
overlay's `modified`, and `<tosi-diff>` resets its decisions when either input changes — so live
application would wipe the choices making it and renumber the hunks under the pointer.

### Fixed: `<tosi-diff>` and notifications ignored dark mode

Both hardcoded a light palette — `#fff`/`#222` for the diff, `#fafafa`/`#444` for
notifications — so on a dark page you got a white block, and in the notification's case the one
element in the library that appears unbidden over whatever you are looking at was also the one
that ignored your theme.

Both now fall back to the theme (`--background`/`--text-color`) with the old literals as the
final fallback, so an unthemed page is unchanged and every `--tosi-diff-*` /
`--notification-*` override still wins exactly as before.

The diff needed both halves at once: darkening the surface alone would have been **worse** than
leaving it, because light text on a solid `#e6ffed` row is unreadable. The add/remove tints are
now translucent (`color-mix` with `transparent`), so one value reads correctly as a wash over
either surface.

### Fixed: a consumer could not use the build lock ([#117](https://github.com/tonioloewald/tosijs-ui/issues/117), [#118](https://github.com/tonioloewald/tosijs-ui/issues/118))

1.12.5 added a build lock that records `pid`, `role`, `root` and `port` per project, so a
`stop` command can identify what is running instead of `pkill`-ing sibling checkouts. It worked
— and none of it was reachable from outside the package. `import('tosijs-ui/site')` gave
`currentHolder === undefined`, and the deep path is blocked by the exports map.

`currentHolder`, `describeHolder`, `lockPathFor`, `lockDecision`, `isProcessAlive` and the
`LockHolder`/`LockDecision` types are now re-exported from `tosijs-ui/site`.

The reporter deliberately did **not** re-derive the lock path locally, and was right not to:
that means copying the FNV-1a hash of the resolved root, and if the hash ever changed the
consumer would report "nothing running" _while a server runs_. Silently-wrong is the exact
failure this whole area exists to eliminate; duplicating the algorithm reintroduces it one
layer out.

### haltija floor raised to `^1.12.6`

The floor encodes fixes, not a date. 1.12.6 is where a `--private` instance finally gained a
**lifetime bound** (haltija#39, filed from here after one was found 12 days old at 5.7 GB and
~150% CPU on a machine at load average 212); where **LAN and Bonjour access started working at
all** (served scripts handed the browser `localhost:<port>`, and `localhost` in a served script
means the _browser's_ machine, so a phone was told to connect to itself); where the widget sends
`X-Haltija-Token`, so a `--token` server and a page that can talk back stopped being mutually
exclusive; where results carry `paintAgeMs`, making a rAF-starved tab detectable rather than
silently wrong; and where Electron went 40.6.1 → 43.4.1, clearing two context-isolation
bypasses in the thing we spawn.

(Internal, but worth recording: our own `CLAUDE.md` documented this pin as `^1.6.1` long after
the code had moved to `^1.11.2` — a stale number in the one place a reader would trust for it.
Corrected.)

### Docs: our haltija gate covers the bridge, not the port

The `haltijaDev: 'tunnel'` documentation read as a complete security story — session cookie,
404-on-unauthorized, relay rather than hole — and a reader could reasonably conclude the whole
surface was gated. It is not. Enabling `haltijaDev` at all means haltija's **own** port is
running, and a default instance binds beyond loopback with `Access-Control-Allow-Origin: *` and
no token, where `POST /terminal/command` reaches a shell. Set `HALTIJA_TOKEN` on a network you
do not control; it only became genuinely usable in haltija 1.12.6, which is why older advice
said to skip it.

## 1.12.5

### Fixed: 49 controls a screen reader announced as "button" and "edit text"

Lighthouse scored the doc site 91 on accessibility, and the cause was entirely mechanical.
`<tosi-table>` builds a column-options button that contains only an icon, and an editable cell
that is an empty `<input>` — neither has any text to derive an accessible name from. On the
data-table page alone that is 37 unnamed buttons and 12 unnamed fields. The doc-browser's search
box made it worse in a quieter way: it had a `placeholder` and nothing else, on **every page of
every site built with `tosijs-ui/site`**. A placeholder is not a label — it is an unreliable
accessible name and it vanishes the moment anything is typed.

The controls now carry a name: `title` on the column-options button, where the tooltip is a
genuine win for sighted mouse users too, and `aria-label` on the cells and the search field,
where a tooltip on every hover would just repeat the column header as noise.

To be precise about what `title` buys, because its two halves land on different people: the
**accessible name** is real and works — `title` feeds the accessible name computation, so
VoiceOver on iOS/iPadOS announces these controls, as do NVDA and JAWS. The **visible tooltip** is
mouse-only, and a screen-reader user was never relying on it.

No visible label is added, deliberately. The vertical-more glyph is the standard overflow
affordance and reads as one; spelling it out on every column header would be clutter that makes
the header worse for everyone in order to restate what the icon already says. The defect was that
the control had no name in the accessibility tree, and that is what is fixed.

Localized tables get this through the existing `data-tosi-localized` directive rather than a
`<tosi-localized>` child, because an `<input>` cannot contain a child element. Add
**`Column Options`** to your localized strings; the editable cells reuse the column names you
already localize, so an editable table wants those present even if you are happy with
untranslated headers.

The regression test is in `tests/a11y-names.pw.ts`, and its first version **passed with both
fixes reverted** — it waited for `buttons.length > 2`, which the nav chrome satisfies on its own,
and then asserted an empty list against a page that had not built the controls yet. `toEqual([])`
is trivially true when there is nothing to check. Every assertion there now has a positive
precondition that the things under test exist, and the mutation that removes the fixes fails it
by name.

Not fixed, and not ours: the two images without `alt` on the 3D page come from Babylon's own
default loading screen.

### Fixed: on a narrow screen, tapping a nav link left you looking at the nav

A regression from 1.12.3's full-screen work, reported from a phone-width window. The sidebar
fills the screen, you tap a link, the URL changes — and the nav stays up. The article you asked
for never appears.

Leaving full-screen had been written as the tidy mirror of entering it: `navVisible = true`. But
that setter's show-the-nav branch writes `contentVisible = false`, and it ran on **every**
navigation to a non-full-screen page — immediately after the nav click handler had set
`contentVisible = true` to do precisely the thing the reader asked for. The layout code was
overwriting a decision that belongs to the person reading.

The two directions are asymmetric now, and that is the point rather than an oversight: entering
full-screen states what it needs, and stepping out of it releases only what it claimed
(`alwaysCompact`). Which pane is showing at a narrow width is not the layout's business.

**Nothing in the test suite could have caught this.** Every layout test ran at 1400px, where
normal mode shows the nav and the content together and `contentVisible` has no visible effect —
the bug was invisible at every width the tests used. There are now narrow-viewport tests for
both directions, and the mutation that restores the old line fails them.

### `<tosi-sidenav>` no longer races a timer to hide its own load-time animation

Its defaults are the compact arrangement — 50/50 columns, margin -100% — so the first layout pass
moves it to wherever it actually belongs. With a transition live, that move _animates_: the
sidebar visibly slides into place on load.

The suppression was `setTimeout(…, 250)`, which is a race rather than a rule — it assumes the
first successful layout pass lands within 250ms of the element upgrading. Measured here it lands
~70ms after (chromium 236→297ms, webkit 213→291, firefox 229→304), leaving ~175ms spare, which
is exactly why this never reproduced locally. But `handleResize` returns early while
`offsetParent` is null, so on a bigger corpus or a slower device the first pass can be deferred
past the window, and then the animation is visible.

It now releases on the first pass that actually computes something, one frame later so the
corrected values paint before transitions come back. No duration is correct for every page; a
rule needs no number.

**This does not eliminate the hydration flash, and it is worth being honest about the limit.** It
removes one race. What remains is the interval between the element upgrading — at which point its
shadow defaults apply — and the first layout pass correcting them: ~70ms here, invisible, but a
real window in which the element is painted wrong rather than not painted. The principled next
step is for the defaults to _be_ the common case, and it is written up in `TODO.md` rather than
attempted blind, since the flash does not reproduce on this machine and changing paint defaults
you cannot observe is how you trade a flash you cannot see for one you can.

### Decided: a nav-toggle override does not survive Back

Left open in 1.12.3 and now settled — **metadata wins on arrival**. A page declaring
`layout: "full-screen"` is full-screen however you reach it: a link, Back, or a fresh load. The
override is a transient viewing choice rather than a property of the document, and since the nav
button is always offered on such a page, restoring it is one tap.

Worth recording that the alternative was real. Browsers restore scroll position on Back, so "put
the view back how I left it" is an established expectation, not an exotic one. It was rejected
because the state would have to live in `history.state`, which makes the same URL behave
differently arrived-at-by-Back than clicked — trading the current surprise for a new one — and
adds state to a path that has none.

No behaviour change; the tests that pinned it now say it is settled rather than pending.

### The invite screen has a code box, so an installed PWA can let itself back in ([#75](https://github.com/tonioloewald/tosijs-ui/issues/75))

The screen offered only a command to run _on the machine hosting the server_. The devices a
tunnel exists for have no such keyboard — and an installed PWA has no address bar either, because
iOS gives a Home Screen app its own cookie jar and its own launch URL. An unauthenticated launch
was a black rectangle with no way out of it.

It now offers the 7-character code box directly: the same affordance as the `dev.tosijs.net`
landing page, moved to where it is actually needed. A plain GET form produces `/?t=CODE`, which is
the path the link already takes, so this needed no new server code. The input disables
autocapitalize, autocorrect and spellcheck, because the code is case-insensitive and a phone
keyboard's help is noise; there is a viewport meta, because a code box you cannot hit is not much
better than none.

**What this deliberately does not attempt** is carrying a session across the install. The
separate cookie jar is a privacy feature rather than a bug, and the issue's suggested "don't
redirect" fix would bake a live credential into a home-screen icon — working once, then failing
forever with no address bar to correct it. Seven characters is the better trade.

### A leading metadata comment is no longer the page title ([#100](https://github.com/tonioloewald/tosijs-ui/issues/100))

The title came from line one, literally. A file opening with its metadata block — the documented
way to set `order` or `parent` — published under the title `<!--{ "order": 2 }-->`, while `order`
itself parsed correctly. So the metadata visibly worked and the title visibly did not, which is a
confusing pair to be handed. Hit while adding `CHANGELOG.md` and `Migration.md` to a doc site,
which is precisely where a leading metadata comment is most natural.

Leading HTML comments are skipped now, including ones that wrap across lines, and an
unterminated comment yields no title rather than lifting text out of it — publishing something
the author had deliberately hidden would be worse than an empty string. One rule, used by both
call sites that previously had their own copy of the line.

No URLs move: slugs derive from the filename, not the title, so an affected page gets a correct
title at the address it already had.

**The other half of that issue was already fixed**: `docPaths` entries have been in the dev
server's watch set since #49, so editing a root `Migration.md` does trigger a rebuild.

### `debugSink` — telemetry back from a page that is not on this machine ([#99](https://github.com/tonioloewald/tosijs-ui/issues/99))

`haltijaDev` is localhost-gated for good reason: an injected drive-my-browser channel reachable
from the network is RCE-adjacent. But that leaves a page served to a headset or a phone with no
way to say **anything** — and in a WebXR session there is no console and `requestAnimationFrame`
is suspended, so every usual readback path is gone at once. The remaining channel is a human
reading an in-scene panel aloud.

`debugSink: true` accepts `POST /__debug-sink` and appends one line per event to a JSONL file
whose path it prints at startup, so an agent can `tail -f` it:

```javascript
navigator.sendBeacon('/__debug-sink', JSON.stringify({ t: Date.now(), tag, ...event }))
```

`sendBeacon` is the point — it needs no headers, reads no response, and survives the page going
away.

**Why this is reachable off-loopback when `/__docstore/source` is not.** It appends opaque bytes
to a scratch file, outside the repo, that the build never reads and nothing serves back. No code
runs; nothing is reconfigured. The source endpoint is remote code execution by design and stays
gated. This is a log.

Still **off by default**: an unauthenticated write endpoint on a LAN-reachable server should be a
decision you made rather than one you inherited. Bounded so a looping page cannot fill a disk —
bodies over 64 KB refused (413), the file stops at 32 MB (507). Newlines in a payload are
escaped, because one event must be one line or `tail -f` shows a split event and the reader
cannot tell.

### The dev cert covers the machine's LAN IP ([#92](https://github.com/tonioloewald/tosijs-ui/issues/92))

Every name the cert carried was useful only _on_ this machine, except `<host>.local` — which is
precisely the one another device is most likely to fail to resolve. Reported from a Quest:
`tosi.local` resolved to **IPv6 only**, including a link-local `fe80::…` a headset gets and
cannot route, while the LAN IP worked. The host never sees it, because it resolves mDNS
natively, so it fails only on the devices the name was added for.

Private ranges only (10/8, 172.16/12, 192.168/16) — a public address on a dev cert would be a
name this machine has no business claiming. A DHCP lease change moves the address and the cert
does not follow; the script says so, and prints the URLs another device can use.

Note this fixes the **name mismatch**, not the untrusted CA: mkcert's root still is not on the
headset, so you either install it there or use the tunnel, which has a real certificate.

### A dropped session says the server restarted, instead of blaming your cookies ([#114](https://github.com/tonioloewald/tosijs-ui/issues/114))

Sessions live in memory and die with the process. That is the design, not an oversight: a
credential should not outlive the thing that granted it, and nothing about a session — or the
token exchanged for it — is ever written to disk. **That has not changed and will not.**

What was wrong is that a cookie from a previous run and a cookie the server had never seen
produced the _identical_ screen. So a reader whose dev server had restarted was told "invite
links expire", and reasonably concluded their cookie was expiring — the one explanation the
evidence ruled out. The reporter's own words: the misleading failure "is the part I would most
want fixed".

The cookie now carries a per-process boot id (`<bootId>.<token>`), so the next process can tell a
cookie it _issued_ from one it has never seen. Both are still refused, identically; only the
sentence differs:

> **The dev server restarted, so your session ended with it.** Sessions are held in memory on
> purpose — they never outlive the process that issued them, and they are never written to disk.
> Your browser still holds the cookie; there is simply nothing on this side to match it to.

The prefix is not a credential and admits nobody: a cookie with the right run id and a wrong
token is refused exactly as before, and there is a test asserting that. An absent or malformed
cookie is _not_ reported as a restart — claiming one we cannot evidence would be its own lie.

### A bundle that references an unserved sourcemap now says so at build time ([#103](https://github.com/tonioloewald/tosijs-ui/issues/103))

`--sourcemap=linked` appends a `sourceMappingURL` comment, so devtools fetches that file on every
load. When the map is not in the served root the fetch fails — in exactly the session where
someone is reading the console carefully for something else. It cost the reporter a wrong
hypothesis while chasing a slow load.

The uglier half was already fixed by #116 in this same release: the SPA fallback used to answer
`.map` requests with the **HTML shell**, so devtools received a web page where a JSON map should
be. Missing assets 404 now.

The remaining half is a build-time warning naming the file and where it was expected. It checks
what is **served**, not what was built — those differ whenever `bundleOutDir` is set, and the
copy across is best-effort by design, so trusting the step is what let this ship. It warns rather
than fails: a map is a debugging convenience and no site should fail to build without one.

The check is a pure, tested function rather than an inline block, because the branch it lives in
is one **this repo's own build never takes** — our bundle comes from `bin/dev.ts`, not from
`buildSite`. That is exactly why the bug reached an adopter instead of us.

### `resolveUnder` asserts path containment instead of inheriting it ([#96](https://github.com/tonioloewald/tosijs-ui/issues/96))

Defence in depth: there is no live traversal, and the reporter checked the classes carefully —
`/../../../etc/passwd`, percent-encoded and double-encoded forms all stay inside the served root
today. But they stay inside for two reasons the static handler does not own: the WHATWG URL
parser collapses `../` before `.pathname` is read, and `.pathname` is never the raw request line.
Both are properties of the **caller**, so a future call with a path from a config value, a
manifest entry or a header inherits nothing. The asserted version already lived ten lines away
in the same file.

Now an exported, tested predicate — a test that went through the server would prove nothing,
since the URL parser makes a violation unreachable from outside.

### `bun run stop` — stop THIS project's dev server, not every one on the machine ([#117](https://github.com/tonioloewald/tosijs-ui/issues/117))

There was no way to stop a dev server, so what everyone reached for was
`pkill -f 'bun bin/dev.ts'` — which matches **every** dev server on the machine, because every
project on this pipeline runs an identical command line. A sibling checkout, a worktree or
another agent's session died to a command that reads as "restart mine". Observed five times in
one working day, twice while a tunnel link was in use by a remote reviewer.

The record needed for this already existed: the build lock stores `pid`, `port` and `root`,
per-project, with staleness decided by liveness rather than age. It needed a reader and a
command. `bun run stop` (or `bun bin/dev.ts --stop`) signals that pid and nothing else, and says
plainly when nothing is running.

### The dev server can no longer die silently ([#91](https://github.com/tonioloewald/tosijs-ui/issues/91))

Reported after a ~9.4h run: `pgrep` said running, `curl` got nothing, the last log line was a
**successful** build, and there was no error, signal or exit anywhere. Every diagnostic a person
would reach for answered "fine"; it was found the next day because a human said the site was
down.

Worth correcting the report's own diagnosis, because it does not survive the evidence: the idle
path **announces itself** before exiting, and no such line was logged — so the idle timeout is
not what stopped that listener. What did is still unknown, which is exactly why the fix targets
the symptom rather than a cause.

The health tick now probes the server's own listener (`GET /__alive`, a bare 204 that
deliberately does **not** count as activity — a self-check that did would hold the idle timer
open forever). Two consecutive failures and it exits non-zero with an explanation. A process that
cannot serve has no value, and the one useful thing left to it is to stop claiming to be alive.

### `tosijs-tunnel --link` stops printing links that cannot work ([#94](https://github.com/tonioloewald/tosijs-ui/issues/94))

`--link` asks the dev server for a token, which succeeds whether or not a tunnel exists. The URL
is well-formed and the token is real — it just answers 503, because nothing is listening
upstream. Printed with exit 0 and no comment, that is indistinguishable from the far side being
down.

It still prints the link, since you may be about to start the tunnel, but it now says so and
exits non-zero, so a script treating 0 as "a usable link was produced" is telling the truth.

### Doc tests survive a context key with a slash in it ([#111](https://github.com/tonioloewald/tosijs-ui/issues/111), [#112](https://github.com/tonioloewald/tosijs-ui/issues/112), [#109](https://github.com/tonioloewald/tosijs-ui/issues/109))

Context keys become **function parameter names** — the runner builds each block as
`new Fn(...contextKeys, body)`. `test-harness.ts` carried its own copy of the rule that turns a
key into an identifier, `key.replace(/-/g, '')`, which strips hyphens and leaves slashes and
`@`. So an ordinary specifier like `'tosijs-3d/demo-utils'` became the parameter name
`tosijs3d/demoutils`, and **every test in that file died before one of them ran** with V8's
`Arg string terminates parameters early`. The examples on the same page rendered fine, because
they went through the shared sanitiser — so nothing looked wrong except a red badge.

One implementation now, in `code-transform.ts`, used by all five call sites. It also handles
what the shared version had never needed to: a leading digit (`'3d-tools'`), a reserved word
(`'class'`), and a key that reduces to nothing.

**Two keys that reduce to the same identifier now throw, by name.** Quietly suffixing the second
would leave one module bound to a name no rewritten import references — undefined at runtime with
nothing to read. The message says which two keys and what to do:

```
example context keys "tosijs-3d" and "tosijs/3d" both reduce to the identifier "tosijs3d",
so an import from one of them could not be told from the other. Rename one of the context keys.
```

There is also a structural test asserting **nothing outside `code-transform.ts` sanitises a
context key**. That is the one that would have caught this: every unit test of the shared helper
passes with the divergent copy restored, because they exercise the helper rather than the call
site — which is exactly how the bug survived.

### A missing asset 404s instead of impersonating a page ([#116](https://github.com/tonioloewald/tosijs-ui/issues/116))

The dev server fell back to the SPA shell for **any** unknown path, so a missing
`waterbump.png` answered `200 text/html`. Babylon's `Texture` fetched it, failed to decode a web
page as an image, and substituted its checkerboard — a missing asset presented as a _styling
choice_, and was complimented before it was diagnosed.

Unknown **routes** still get the shell, because client-side routing depends on it. The
distinguishing signal is a dot in the last path segment: an extension was asked for, so a file
was meant. `/v1.2/guide` is still a route; `/thing.png` is not. The 404 names the path, because
a bare "File not found" sends people to the wrong layer — the reporter went through their
texture pipeline before suspecting the server.

### `staticDirs` are watched, so a replaced asset is not served stale ([#110](https://github.com/tonioloewald/tosijs-ui/issues/110))

`buildSite` copies `staticDirs` into the output on every build, but nothing watched them — so
re-exporting a GLB over `static/model.glb` left the previous copy served indefinitely, with no
error and no hint. Both files exist and only their contents differ, which is the hardest version
of stale to see, and the workaround people find is touching a source file to provoke a rebuild.

Same shape as #49, where `docPaths` was the omission. Verified end to end: a replaced static file
is now picked up in about half a second.

### A lone custom element is no longer wrapped in a paragraph ([#115](https://github.com/tonioloewald/tosijs-ui/issues/115))

Reported by tosijs-3d: a doc page whose entire content is one custom element — the case
`layout: "full-screen"` exists for — got an element that would not fill. marked classifies raw
HTML by tag name and cannot know whether an unknown tag is block or inline, so
`<my-editor></my-editor>` alone on a line came out as `<p><my-editor></my-editor></p>`. That
paragraph is auto-height, so `height: 100%` resolved against a **33px** box inside an 842px
content area.

Measured before and after on the same page: 33px, then **842 of 842** on all three engines. The
comment promising "a definite height, so a child asking for `height: 100%` gets one" is finally
true rather than aspirational.

The unwrap is deliberately narrow, since a heuristic here earns its own bugs: it fires only when
a paragraph's entire content is a single element whose tag name contains a hyphen — a custom
element, the one case marked provably cannot classify. `<p><img></p>`, `<p><em>text</em></p>`
and `<p>see <my-thing></my-thing> here</p>` are all left exactly as they are, and mutation
testing covers both directions — unwrapping everything fails three tests, unwrapping nothing
fails one.

### The inline doc tier runs at two widths now

The same gap, one level down: the doc-test runner's iframe was hardcoded to 800x600, so **every
inline `test` block had only ever run wide**. Its size is now overridable, and the Playwright
doc-test spec runs the whole tier a second time at 390x844.

An iframe is a real viewport, so this is not a simulation — verified that
`matchMedia('(max-width: 600px)')` matches inside a 390px frame and not a 1200px one, and that
the override reaches the runner (390px frame, 386 inner; 800/796 without it).

**Widths only, deliberately.** Touch cannot be emulated from inside a page: an iframe inherits
the host's input characteristics, so `(pointer: coarse)` stays false and `maxTouchPoints` stays 0
however small the frame is. `navigator.maxTouchPoints` _can_ be redefined, and doing so would be
worse than skipping it — the property would lie while the media queries told the truth, so a test
could pass against a page behaving as desktop. Touch belongs to a Playwright context
(`hasTouch`, `devices[…]`); noted in `TODO.md` as worth its own spec, since everything
touch-shaped here is currently tested only with a mouse.

## 1.12.4

### `.tjs` source files are documented again ([#108](https://github.com/tonioloewald/tosijs-ui/issues/108))

Converting a source file from `.ts` to native `.tjs` **silently deleted its documentation page**,
and its `llms.txt` entry with it. Reported from tosijs, mid-port: one page lost per converted
module.

The silence was the real defect. The build exited 0, and the internal-link check passed —
a page that was never generated is linked from nowhere, so "41 slugs, no 404s" is a _pass_. The
only signal was a slug count nothing asserts on. `'x.tjs'.endsWith('.ts')` is `false`, so the
`llms.txt` filter excluded it too, without even the mercy of an accidental match.

`.tjs` is now scraped, in both places. The doc-block syntax is identical across these languages —
a doc block is just a comment — so there is nothing language-specific to support. The list _is_
the feature, which is exactly why an omission from it is invisible.

**And a guard for the next one.** A file carrying what looks like a doc block, in an extension we
do not scrape, is now **named** in the build output rather than passed over:

```
⚠️  1 file(s) look documented but are not scraped, so they have no page:
    src/thing.vue
    Scraped extensions: .ts, .js, .tjs, .css. Add one to SCRAPED_SOURCE_EXTENSIONS if it should be documented.
```

It warns rather than fails: a file may legitimately contain the sequence without wanting to be
documentation, and a doc site that refused to build over a comment would be worse than the bug.
But whatever the next extension turns out to be, its absence can no longer happen quietly.

## 1.12.3

### Back now has layout coverage, because a reader noticed before the tests did

Reported from the keyboard as "sometimes the full-screen page and the full-screen page with the
nav open look like different history entries". Measured, it is neither history nor
inconsistent: the nav toggle creates **no** history entry (`history.length` unchanged across
repeated toggles, URL unchanged). What happens is that **Back re-asserts the page's declared
layout** and discards the override — so leaving a page with the nav open and returning finds it
closed.

That is defensible and it is what ships: a full-screen page is full-screen whenever you arrive
at it. Whether Back should instead restore what you left is now an open decision in `TODO.md`,
with the measurements attached, rather than whatever fell out of the implementation.

The gap the report exposed was in the tests, not the feature: `doc-system.pw.ts` already covered
Back for URL, title and content, but nothing checked its effect on **layout**, and every layout
test reached its pages by clicking links. Both directions are covered now — and labelled by what
they actually do, since one is a genuine regression test (dropping `removeAttribute('data-layout')`
on navigation fails it) and the other is a contract tripwire that survives every mutation tried
against it. A test that cannot fail is usually decoration; that one is deliberate, and saying so
is the difference.

### The dev server sends `Cache-Control: no-store` on everything

It previously sent no `Cache-Control` at all, and that is not neutral: a browser may invent a
freshness lifetime when you decline to state one, and Safari is the most willing to. So the dev
server would rebuild correctly while the browser kept serving the previous build — which
presents as "my fix did not work", and is only escaped by emptying the cache by hand. It cost
exactly that here: a bug was reported against code that had already been fixed.

All three of the file-server's exits are covered — injected HTML, compressed assets, and
binaries that stream untouched — because they are separate responses and adding the header to
the obvious one is precisely how the other two get missed. Each is independently
mutation-verified.

**Dev server only.** The built site in `docs/` is unaffected; it is a static site for a real
host, and telling a CDN never to store it would be actively wrong.

### `layout: "full-screen"` — and `<tosi-sidenav alwaysCompact>` underneath it

`full-width` shipped in 1.12.2 and `full-screen` did not, because it needed something from the
sidenav that did not exist. It exists now, and it is one line rather than a new layout.

**`<tosi-sidenav>` gained `navVisible`** — the one control a "show me the navigation" button
needs. Read it, flip it:

```js
sidenav.navVisible = !sidenav.navVisible
```

It resolves what hiding the nav _means_ at the current width — forcing compact on a wide screen,
simply showing the content on a narrow one — so no caller has to know, and a narrow-screen
request does not outstay itself when the window is widened. `layout: "full-screen"` uses the
same property, so the feature is general rather than something the doc-browser knows a trick
about.

**And `alwaysCompact` underneath it.** Compact mode already shows the nav _or_ the content
and takes turns between them — which is precisely what a full-screen page wants — so this just
removes the width test rather than adding a second way to lay out. It is a named state and not a
`minSize` no viewport can reach, because the second one works and reads as a bug to the next
person.

**`layout: "full-screen"`** then means: no reading measure, no nav, no gutter, and a content area
with a real height — so a demo, a canvas or an embedded app can ask for `height: 100%` and get
it. There is a [demo page](/full-screen-demo/), and the navigation button works there: it
returns you to the normal layout rather than replacing the content with a full-screen nav, and
it **stays offered** so you can go back. Riding `compact` directly, as it used to, meant the
button vanished on its own click and stranded you in the normal layout.

Two inline styles were quietly breaking the promise. `.doc-content` receives both its
`max-width` and its `padding` inline from the doc-browser, so no stylesheet could override
either — a full-screen page rendered inset by 32px, in a box that collapsed to its content
height instead of filling. `padding` now routes through `--doc-content-padding`, the way the
measure already did.

Navigating away restores the nav, and a reader's override lasts until they navigate rather than
beyond it — **including Back**, so a full-screen page is full-screen whenever you arrive at it.
That is predictable, but it does mean leaving a page with the nav open and returning finds it
closed; whether Back should instead restore what you left is noted in `TODO.md` as a decision
rather than left to whatever fell out of the implementation. Both directions are mutation-verified, which caught a test that _looked_ like it
covered the second one and did not: it routed through a prose page, which reset the state as a
side effect.

Before hydration there is no sidenav at all, just static markup, so a stylesheet rule covers the
first paint. Those rules select `.doc-nav.doc-nav` — the class deliberately repeated — because
the pre-hydration layout further down the same sheet has identical specificity, and winning on
source order works right up until someone reorders the spec.

## 1.12.2

### `haltijaDev: 'tunnel'` — let an agent drive a page running in a headset (#104)

`haltijaDev: true` is localhost-gated, and that gate is exactly what stopped the feature
working where it is worth the most. Debugging in a VR headset is close to blind: no readable
console, no devtools, no way to inspect the DOM without taking it off — and taking it off is
the thing you cannot do, because the bug is usually _about_ being in it.

Set `haltijaDev: 'tunnel'` and the channel is also served **same-origin over the tunnel**.
Redeem an edit link on the device, load the page, and it shows up in `hj windows` like any
other — `hj eval`, `hj navigate`, `hj tree` all work against it, and `hj` never learns the
page is remote.

**Why it works at all, and why it needed no change from haltija.** The page **dials out**:
`component.js` reads `window.__haltija_config__.serverUrl` and falls back to localhost only
when that config is absent. So nothing has to reach _into_ a headset — the dev server sets
the config itself, serves the component from the page's own origin, and relays the WebSocket
to the local channel. The upstream `dev.js`/`inject.js` chain is skipped entirely; both files
carry their own localhost gates and all three of their hardcoded `https://localhost:8701`
URLs would resolve on the **headset** rather than on your machine.

**A separate opt-in, deliberately.** `true` means "an agent may drive the page on this
machine". Upgrading must never silently turn that into "wherever it is reachable", so
`'tunnel'` is its own value — and `HALTIJA_DEV=1` does **not** enable it, because that
variable is a convenience toggle and this is not a convenience.

**What authorizes it:** a live dev session cookie — the same credential that gates source
writes, via the same predicate (`mayDriveWithAgent` delegates to `mayWriteSource`, and a test
asserts they agree on every input). Driving a page with an agent is at least as powerful as
writing source, so it gets no weaker a gate. What authorizes is the **listener**, never the
peer address: a reverse tunnel counterfeits "local" by construction. Unauthorized requests
get **404**, not 403 — whether a project enabled the bridge is nobody's business but the
session holder's.

Verified end to end rather than by inspection: a real browser on the tunnel origin stores the
session, receives the loader, resolves `serverUrl` to its own origin (not `localhost:8701`),
attaches the widget, opens the relayed socket, appears in `hj windows`, and answers `hj eval`
and `hj navigate`.

### Dragging works on Firefox now — every draggable, not just table columns ([#107](https://github.com/tonioloewald/tosijs-ui/issues/107))

**The first drag on a freshly-loaded page did not work in Firefox.** The thing you grabbed
stayed put and random text highlighted instead; it only started behaving once you had clicked
somewhere — anywhere — to settle Firefox's selection state. Land on a page and drag a column
edge, which is an entirely ordinary thing to do, and the first attempt failed.

This affected **every** `trackDrag` consumer — `<tosi-sizer>`, `<tosi-float>`,
`<tosi-editable-rect>` and `<tosi-table>` column resizing — and had done since the drag tracker
landed in **v0.5.1**. Measured with no prior click: `<tosi-sizer>` did not move at all, a table
column applied one 12px step and froze. All three engines now behave identically (150 → 210 →
270 on the same gesture, and nothing selected).

Two parts, because either alone does nothing. `trackDrag` calls `preventDefault()` on the
initiating mousedown, which is narrow rather than blunt — it only ever runs once a component has
already decided a drag is starting, so ordinary clicks, focus and selection are untouched. And
`<tosi-sizer>`, `<tosi-float>` and `<tosi-editable-rect>` had to stop registering their
**mousedown** listeners as `passive`, because a passive listener makes `preventDefault` a silent
no-op — which is exactly why the table (whose listener was never passive) improved first while
the others stayed dead. Nothing is lost: `passive`'s practical value is silencing the console
warning about handlers that delay scrolling, and that warning only ever applied to touch and
wheel. `touchstart` stays passive.

It is Firefox behaving badly, and that is a diagnosis rather than an excuse — the workaround is
cheap and users do not care whose bug it is.

### Page metadata: `layout: full-width`, so a route can leave the reading column ([#105](https://github.com/tonioloewald/tosijs-ui/issues/105))

Prose wants a measure — 44em is roughly what people read comfortably, and it stays the default.
But a demo, a dashboard, a wide table or a canvas is _worse_ squeezed into a column, and having
to pick one habit for the whole site is what makes people build a second site. Now a page can
opt out, the same way it sets `pin` or `order`:

```markdown
<!--{ "layout": "full-width" }-->
```

or `layout: full-width` in YAML frontmatter. **This project's own README uses it.**

Stamped into the served HTML rather than applied on load, so the first paint is already right —
a layout applied by script would show the reading column and snap wide, the flicker
pre-rendering exists to prevent. Client-side navigation keeps it in step **in both directions**;
that second direction is the one a naive implementation forgets, and it has its own test
(mutation-verified: dropping the clear-on-navigate fails it, dropping the static stamp fails
three more including first paint).

**`layout: "full-screen"` is not implemented and is rejected with a message rather than
half-honoured.** It needs a collapsed state on `<tosi-side-nav>`: after hydration the nav
carries an inline `display`, and side-nav sets its width as an inline property, so a stylesheet
cannot override either without `!important` — and hiding side-nav itself hides the content too,
since it wraps both. A value that dropped the measure while leaving the chrome exactly where it
was would be reported as a bug, correctly. Tracked in `TODO.md`.

### `<tosi-table>`: a column drag no longer dies when `columns` is reassigned under it

The other half of the reporting app's "resize funkiness", and a different defect from #102's
torn grid — this one is not a mismatch at all. `resizeColumn` captured the `ColumnOptions`
object and mutated it for the whole drag, and that object stops being the table's the instant a
caller assigns a new `columns` array. Which the app does on a page-size change, and a page-size
change is a very likely thing to happen while someone is dragging a column edge. From then on
the drag wrote widths into an orphan: pointer moving, column not moving, nothing reported.

The drag now re-resolves the column by `prop` each step — ending cleanly if it is gone, and
rebasing on the new width if the object was merely replaced. Confirmed pre-existing rather than
introduced by 1.12.1: reverting that fix leaves the drag equally dead.

### Known: column resize does not work on Firefox at all ([#107](https://github.com/tonioloewald/tosijs-ui/issues/107))

Found while investigating the above, and it is the bigger problem. On Firefox a **plain** column
drag — no reassignment anywhere near it — applies one step and freezes: +60px reaches 162 and
+120px still reads 162, against 210 then 270 on chromium and webkit. A document-level capture
listener shows Firefox stops delivering `mousemove` to the page entirely after the first event,
so no callback can run.

The cause looks to be `trackDrag` listening on a full-screen overlay while Firefox keeps
implicit mouse capture on the element that received the `mousedown`. The fix is Pointer Events
with explicit capture, and `trackDrag` is shared by every draggable affordance — so it wants
doing deliberately, with the others re-tested on Firefox at the same time, rather than as a
footnote to this release. Ruled out first: it is not the DOM being rebuilt under the drag
(deferring renders changes nothing) and not #102 (reverting that changes nothing).

### `tosijs-release-notes`: the bump gate stops blocking on dev tooling

Two fixes, both hit while cutting this release — which is the only way anyone finds them.

**A dev-tooling path with a security surface now WARNS instead of blocking a patch.** Every
path on that list is development tooling — a dev server, a tunnel, a deploy script — and none
of it runs in an end user's browser as part of an adopter's app. Halting a release over it
spends the maintainer's attention on the release with the least at stake, and a guard that
cries wolf gets overridden or deleted, taking the case it was _right_ about with it (#79, a
loosened dev-server default shipped as a patch). The warning keeps that signal and stops
charging for it. A gate that genuinely should block would have to key on code an adopter
**ships**, which none of these are.

**The blocking branch offered advice it could not honour.** It said "explain in the notes why
this does not reach anyone" — but `bumpConcerns` never sees the notes, so nothing you wrote
could satisfy it, and the only way forward was to work that out by reading the tool's own
source. Same defect this project already fixed in the tunnel's 401 page. It now says "cut a
minor", which is the only thing that ever worked.

### Security-relevant paths that changed, and why this is still a patch

`dev-auth.ts` — the module that decides who may do privileged things — changed in this
release, and a patch touching it deserves a heading rather than a footnote.

**The change is purely additive, and that is checkable rather than asserted.** The diff against
1.12.1 removes **zero lines**, and `mayWriteSource` is **byte-identical** to 1.12.1. What was
added is one new exported function, `mayDriveWithAgent`, whose entire body delegates to
`mayWriteSource` — with a test asserting the two agree on every combination of `viaTunnel`,
`hasValidSession` and peer address, so they cannot drift apart later without that test going
red.

**Nothing reaches an existing adopter.** The new predicate is consulted only by the
`/__haltija/` routes, which exist only when `haltijaDev: 'tunnel'` is set. Every existing
authorization decision — source writes, link minting, session validation — is executed by the
same bytes as in 1.12.1. If you have not opted in, the reachable behaviour of this release is
identical to 1.12.1.

**What it would mean if you do opt in:** a live dev session cookie becomes sufficient to let an
agent drive your page over the tunnel, in addition to editing source. That is a real widening
of what a session can do, which is why it is a distinct config value and not implied by
`haltijaDev: true`.

**A patch, not a minor**, despite reading like a feature: it is a new optional _value_ on an
existing config field, plus routes that exist only under it. `haltijaDev: true` behaves exactly
as before and nothing is removed or renamed, so the contract a patch carries — it does not
break you — holds.

## 1.12.1

### The Playwright lane no longer fights itself

Local workers are pinned to **6** rather than Playwright's default (~half the cores, 9 here).
Every engine had taken a turn producing a `page.goto` timeout or a `docs.json` load failure —
roughly one full run in five, always passing in isolation — because 9 browsers were hitting one
dev server. 1.11.0 fixed the client half (`fetchCorpus` retries); this reduces the contention
instead of surviving it.

Measured, not guessed: 9 workers ran the suite in 82s and 6 in 81s, so the lane is not
CPU-bound at 9 and a third of the load is free; 4 workers costs 106s and buys nothing further.
6/6 clean afterwards. Contributor-facing only — CI already runs at 1 worker.

### `<tosi-schema-form>`: all three perf guards, and they cover different things

The keystroke perf guard counts DOM lookups, and provably could not see `syncValues` — which
runs on add/remove-item, not on keystrokes. A root-scoped per-field scan reintroduced there
passed the existing test. There is now a sibling test on that path; mutation-testing takes it
from 1 lookup per add to 41 → 161 as field count grows, while the keystroke test stays green
under the same mutation.

Wall-clock is back alongside them, too. It was removed in 1.11.0 for flaking; with the lane
quiet the isolated and loaded distributions now overlap (3.20-4.57 vs 2.64-4.14 across all
three engines, against 4.0 for linear and 16 for quadratic), so a threshold of 8 separates
them. It earns its place by catching what counting cannot: an O(N) scan per field that uses no
selector at all fails it at 9.63x while both count guards pass.

### `<tosi-table>`: the header and body can no longer describe different columns (#102)

Reported from a host app that changes column visibility when the page size changes. `set
columns` updates the column list **synchronously** but defers the rebuild to the next frame,
so between the assignment and that frame anything recomputing widths — a resize drag, a scroll
handler, or the host's own page-size code — wrote a grid template for the NEW columns over the
cells the OLD ones had built.

Why that reads as _disagreement_ rather than a missing column, which is what made it confusing
to report: with fewer tracks than cells, CSS Grid auto-places the surplus into **implicit**
tracks, and implicit tracks size to content. Header text and body text differ, so the two rows
resolve them to different widths and the columns visibly step apart.

`setColumnWidths()` now describes the column set the DOM was actually built from, refreshed by
`render()` on the frame that installs those cells. A column change is therefore **deferred, not
dropped** — and because column objects are shared by reference, a resize drag still moves
widths immediately: it is the shape that is pinned to the DOM, not the widths. All three
properties have tests, and the fix is mutation-verified (reverting it fails with
`tracks: 3, headerCells: 4`).

The caller arguably should not reassign columns mid-render. It does not matter — a table that
can show a header and a body describing different columns is our defect whenever the
assignment lands.

## 1.12.0

### tosijs-ui is Apache-2.0 as of this release (MIT through 1.11.1)

**Nothing already published changes licence.** 1.11.1 and everything before it stay MIT under
the terms they shipped with — a licence grant already made cannot be withdrawn, and this is not
an attempt to. The change applies from 1.12.0 forward.

**Why.** tosijs went Apache-2.0 in its 1.8.0, and `dist/iife.js` inlines tosijs, so this package
already redistributes Apache-2.0 code and already had to carry its NOTICE. Matching the licence
of the thing we are built on removes the mismatch rather than papering over it, and it is the
right direction on its own terms: Apache-2.0 adds an explicit **patent grant** and a
patent-retaliation clause that MIT simply does not address.

**What it means for you, concretely:**

- **Using tosijs-ui in an app, or hosting what you build with it — unaffected.** No new
  obligation.
- **Redistributing it** (vendoring, forking, shipping a bundle that contains it) — §4(d) asks
  you to carry the attribution text from `NOTICE`, and §4(b) to mark files you modified.
- **GPLv2-only projects can no longer use it.** This is the one real loss and it is worth
  stating plainly rather than burying: Apache-2.0 is incompatible with GPLv2-only. GPLv3+ is
  fine. If you are on GPLv2-only, 1.11.1 remains MIT and remains available.
- **You gain a patent grant** you did not have under MIT.

The root `NOTICE` now carries our own attribution plus tosijs, marked, Feather Icons and
CodeMirror. It ships in the package (`files`), so a redistributor has it without hunting.

### Upstreams: verified against tosijs 1.8.0 and tjs-lang 0.13.4

Both are admitted by our existing peer ranges (`^1.7.8`, `^0.13.1`) — so adopters of 1.11.0 resolve to them today whether or
not we bump. All six lanes pass against both: unit (1118), Playwright ×2, haltija (60),
consumer (44 checks), typecheck, format.

The peer floors are **unchanged**. Nothing here needs anything new in either release, and the
floor encodes required fixes rather than a date. `TJS_VERSION` in
`src/live-example/code-transform.ts` moved to 0.13.4 in lockstep with the dep — it is the CDN
pin used when an adopter has no tjs-lang peer installed, and the two silently diverging would
mean testing one transpiler and shipping another. Both CDN assets verified reachable at that
version.

### Licence: a NOTICE file, because the iife redistributes Apache-2.0 code

**tosijs is Apache-2.0 as of 1.8.0** (BSD-3-Clause through 1.7.x). `dist/iife.js` inlines
tosijs and marked so a page can use the components from a single `<script>` tag, which makes
this package a redistributor, and Apache-2.0 §4(d) asks redistributors to carry the NOTICE
text of what they redistribute. There is now a `NOTICE` file at the root, shipped in the
package, reproducing tosijs's verbatim.

**This does not travel with `import 'tosijs-ui'`.** The ESM build bundles neither — tosijs and
marked are peer dependencies there, resolved from the consumer's own `node_modules`. The
obligation attaches to the iife and to anything built from it. tosijs-ui itself remains MIT.

### Known: the iife grew

`dist/iife.js` is **419.1 KB gzipped, up from 402.3 KB** — +16.8 KB (+4.2%) — entirely from
tosijs 1.8.0, whose own notes measure +2.9 kB on an app bundle that touches no new API (the
contract seam, path-segment guard and binding bookkeeping sit on the ordinary path). Ours is
larger because the iife bundles more of tosijs than a typical app does. Recorded rather than
absorbed silently: the gate on weight here has always been the printed gzip delta.

### Note for anyone on tosijs 1.7.x

tosijs 1.8.0 **deviates from semver and says so** — it removes `data-ref` and `<xin-slot>`
markup handling, reduces `<xin-blueprint>`/`<xin-loader>` to warning tombstones, and flips
`on<Event>` member precedence and type-contradicting attribute writes. A consumer on
`^1.7.9` receives all of it on a routine update. **tosijs-ui uses none of them** — verified by
grep and by the full lane run, which is why our peer range still spans both. If your own app
uses any, read tosijs's 1.8.0 notes before updating.

## 1.11.0

**Schema-driven editing.** Three new pieces that compose: `<tosi-schema-form>` renders a form
from a JSON Schema, `<tosi-crud>` puts search, list and edit over a store you supply, and
`hashState` keeps filter and selection in the URL so a filtered list with a record open is a
link you can send.

**The dev bridge is usable on a headset.** The edit-link token is now **seven characters** you
can read off one screen and type on another, instead of twenty-two of mixed-case base64url.
That change came from watching the feature fail in practice: people gave up and typed LAN IP
addresses instead, and a credential too painful to use is not protecting anything.

> **If you are on 1.10.0, this is a three-version jump.** 1.10.1 and 1.10.2 were tagged in git
> but **never published to npm** — `latest` has been 1.10.0 throughout. Nothing is lost: every
> change in those releases is in this one, and their notes are below where they always were.
> They are simply not installable, so `npm i tosijs-ui@1.10.2` will fail.

### Breaking

- **`tosijs-schema` floor is `^1.8.0`** (optional peer). 1.8.0 enforces `oneOf`,
  `exclusiveMinimum` and `exclusiveMaximum` — which is what
  [tosijs-schema#8](https://github.com/tonioloewald/tosijs-schema/issues/8), filed from this
  work, asked for — and exports `unenforcedKeywords`. The form now asks the **registered
  validator** which keywords it does not check, falling back to a local list only when a
  validator cannot answer. Against 1.7.0 a `oneOf` field carried "oneOf is not validated";
  against 1.8.0 it carries nothing and the value is genuinely checked. Verified with
  `bun bin/verify-schema-dep.ts --version=1.8.0` (17/17).

- **Validation is supplied, not imported.** `setSchemaValidator({ validate, inferSchema, unenforcedKeywords })` —
  one line, anywhere, before or after render. The CDN `<script>` build and any `tosijs-ui/site`
  doc site register it themselves, so only an ESM consumer writes it.

  The reason is worth knowing, because it is not a preference. A bare `import('tosijs-schema')`
  in shipped code is either **resolved** by your bundler — which fails the build for anyone who
  did not install it, including people using only `<tosi-table>` — or left **external**, which
  cannot resolve in a browser and kills validation for everyone. Both were measured. There is
  no third option, so the component asks for two functions instead of a package. The upside is
  that they are just functions: an Ajv wrapper, a house validator or a test stub all work.

- **`tjs-lang` moves to `^0.13.1`** (from `^0.12.0`), and `TJS_VERSION` in `code-transform.ts`
  moves in lockstep. 0.12.0 is **deprecated on npm**, and the deprecation names this release's
  own combination — _"tosijs-schema >=1.5.0 breaks the battery atoms' output validation in these
  versions. Upgrade to 0.13.1."_ Worse for adopters, `^0.12.0` does not admit 0.13.x, so anyone
  on current tjs-lang got a hard `ERESOLVE`. The tjs CodeMirror extension is still bundled
  rather than externalized — the constraint that silently no-ops highlighting if it breaks.

- **The edit-link token is 7 Crockford base32 characters**, and `linkTtlMinutes` defaults to
  **5** (was 15). A link minted by an older server is not redeemable by a newer one; both are
  in-memory and per-process, so this only matters across a restart mid-session.

### New

**`<tosi-schema-form>`** — a form from a JSON Schema. `value` is the state and `change` fires on
edit, the same contract as every other component here.

- Scalars, enums, `const`, and `format`-driven input types. Anything it cannot render is a
  labelled placeholder saying so, never a silently omitted field — a field that vanishes is
  indistinguishable from a schema that never mentioned it, which is how an editor loses data.
- **Nested objects** become `<details>` sections to any depth, with fully-qualified leaf paths
  (`address.geo.lat`), so value sync and error keying work at depth with no special cases.
  `required` is scoped to the object that declares it, which is what JSON Schema means: a
  required `city` inside an optional `address` says _if you give an address, it needs a city_.
- **Arrays** with add, remove and reorder. Each element expands against `items`; scalar items
  are a single field at the index (`tags.0`), object items expand to their properties
  (`items.0.sku`).
- **Unions** (`anyOf`/`oneOf`), each rendered as what it actually is: `[X, null]` is just an
  optional X, an all-`const` union is a `<select>`, an all-object union is a variant picker plus
  the matching branch's fields. Anything else names the shapes it saw ("a union of string |
  object"). Discriminators are derived from the property every branch pins to a different
  `const`, or declared as `x-discriminator`.
- **Format plugins** — `registerFieldPlugin(format, plugin)` claims a schema `format`. The
  plugin owns the control; the form keeps the label and the error slot. Plugins are dispatched
  first and for every node kind, so `unsupported` is the fallback, not the ceiling. Register
  whenever you like: styles are injected on registration and live forms rebuild.
- **Validation is optional.** Without a validator the form still renders and edits and simply
  reports no errors, which is a smaller failure than refusing to render.
- **No schema? It infers one** from the value, via `inferSchema`. Read it back from `.schema`,
  edit it, set it again — that round trip is the point.

**`<tosi-crud>`** — search, list, edit over a `CrudStore` adapter (`list` / `save?` / `delete?`,
promise-returning). There is **no transport in the component**: REST, a DocStore, IndexedDB, an
array in a closure and a mock all satisfy the same three methods. Omit `save` and the form is
read-only; omit `delete` and its button is not shown. `columnsFromSchema()` lets one description
of the shape drive both surfaces — strictly better than the table's own inference, which reads
`Object.keys(array[0])` and silently loses the column for a property the first row lacks. The
search term and selected id live in the URL hash, so typing **replaces** the history entry while
selecting a record **pushes** one: back leaves the record rather than un-typing your search a
letter at a time.

**`hashState({ namespace, mode })`** — key-value state in the page hash. Every key is namespaced
in the URL and bare in the API, and a write never touches a key it did not put there, so two
instances (or a hashState beside a hash router) share one URL without deleting each other. That
is the failure that made `createDocBrowser` grow a `'memory'` routing mode, so `mode: 'memory'`
is here from the start.

**Editable `<tosi-table>`** — set `editable` and cells become inputs, per-column overridable
either way; a column with its own `dataCell` is never made editable. A `schema` drives the
control type and validates edits, using the same model the form uses. A `change` event carries
`{ item, field, oldValue, newValue, error }` and commits on `change`, not `input`: an event per
keystroke would make `3` a legitimate intermediate state of typing `35`.

**Localization** — `localize(pattern, values)` fills `{name}` placeholders **after** translating,
so the key is the whole sentence (`'Add {item}'`, never `'Add ' + item`). A concatenated
sentence is untranslatable in principle: the translator sees a dangling fragment and cannot move
the placeholder to where their language puts it. An unknown placeholder stays visible as
`{name}` rather than blanked. `<tosi-schema-form>` localizes its own chrome and rebuilds on
locale change.

**The dev bridge.** `mintLinkToken()` / `normalizeLinkToken()`: Crockford base32 excludes `I`,
`L`, `O` and `U`, so the `0`/`O` and `1`/`l` mistypes that hurt most on a floating keyboard are
impossible rather than merely unlikely. `randomInt`, not `randomBytes % 32`. Redemption folds
case, accepts `I`/`L` as `1` and `O` as `0`, and ignores hyphens — normalising on **redemption**
rather than only when minting, because case-insensitivity that exists in the alphabet but not in
the comparison is a claim rather than a behaviour, and the failure it produces is a correct human
being told they typed it wrong. `--link` prints the code on its own line as well as in the URL.

The TTL is 5 minutes because the link is a **bearer token** for its lifetime — which the
`dev-auth` header comment now says plainly instead of describing the single-use rule it stopped
following in 1.10.0. About 35 bits is ample for what this actually is: an online-only guess
against a `Map` lookup, for a token redeemed seconds after minting, that mints only a write
session `mayWriteSource` still gates.

Guess-rate control: redemption runs **one at a time** and every attempt occupies at least 100ms,
so ten attempts a second against 32⁷ is ~111 years to exhaust and about 1 in 11 million inside a
five-minute window (measured — 20 concurrent attempts complete at 9.8/sec). After ten
consecutive failures the slot widens to a second. Not a lockout: the door never closes on a
human, it only gets slower to knock on. An earlier draft of this used escalating delays plus a
lockout, and replacing it was the right call — that version had two tunable constants, a counter
needing reset, and a lockout an attacker could trigger, which is a denial of service against the
developer on the one credential they need in order to work.

`tosijs-tunnel` prints the host **and where the address came from** before it acts —
`tosijs-deploy` is dry-run by default and shows its target, this one is not.

**Tooling.** `bin/verify-schema-dep.ts` runs the twelve requirements from tosijs-schema#6 plus
both directions of #7 against a candidate release and exits non-zero on any failure.
`PREVIEW_HOST` now also resolves from `~/local-secrets/tosijs-preview.env`, because the previous
advice — export it from your shell profile — is present for a human and **absent for every
tool**: non-interactive shells inherit no interactive profile, so an agent or CI step sees
nothing and reports a missing credential rather than a missing `export`. `readLocalSecret` parses
`KEY=value` only and never evaluates; a credentials file that needs a shell to read is one that
can run code. `bin/preview-host.test.ts` includes a guard that greps the committed config for a `user@host`
literal — and asserts the pattern would have caught the real one, so it cannot pass by being
wrong. `bin/smoke-consumer.ts` gained four things it could not previously see: resolution of the
new subpaths from an installed tarball, a `tsc --noEmit` over an installed consumer with
`skipLibCheck: false` and no optional peers, a bundle entry that actually imports the library,
and both vendored types pinned by assignability tests in both directions.

- **`<tosi-table>` gained `full-width-header`.** A table whose columns add up to less than its
  container left a strip of blank space on the right for no reason; with the attribute the
  leftover width goes to the last **unpinned** column. Right-pinned columns are skipped —
  they sit against the right edge by definition, so stretching one would push the space back
  into the middle. When the columns overflow, nothing changes: every column keeps its width
  and the table scrolls, because the row width is `max(sum, 100%)` and `1fr` then resolves to
  exactly the width it was given. Header and body share one `grid-template-columns`, so they
  stretch together.

### Process

- **`bun run release-check` must run AFTER the release commit, and it is the last thing before
  `git tag`.** The range is `<last tag>..HEAD`, so a bullet written _in_ the release commit is
  not in the range you checked a moment earlier — 1.11.0 shipped a commit whose body claimed
  the gate was green while it exited 1 on the state that commit created. The range is also
  exclusive of the since-commit, so tagging turns the gate green without the bullet ever being
  written: the annotation escapes in both directions. Now documented in `CLAUDE.md`.
- The `unenforced.ts` parity test is a **subset** check rather than equality. Upstream growing
  the set of keywords it enforces is good news, and an equality assertion turned that into a
  red build. Upstream _shrinking_ below our fallback is the dangerous direction — the fallback
  would claim a keyword is checked when it is not — and that is still caught.

### Fixed after the pre-release review

The nine-lens review ran **three times** against this release. Everything in this section was
found by it and fixed before tagging.

- **Editable numeric cells silently truncated what you typed.** Typing `19.95` into a
  `<tosi-table editable>` number cell committed **95**; `-4` committed **4** — and every
  commit fired `change` with `error: null`, so nothing reported that a digit had gone. Two-way
  `bindValue` wrote the model on every `input`, and `<input type=number>` sanitizes an
  intermediate `"19."` to `""` under the HTML value-sanitization algorithm, so the binding
  wrote `""` straight back into the focused input. The binding is one-way now and
  `handleCellChange` is the sole model write — which also makes "commits on `change`, not on
  `input`" true of the _model_ and not only of the event.

  It was invisible to every lane because every test assigned `.value` programmatically or
  typed with no delay, and neither ever lets a frame run between keystrokes. There are three
  tests that genuinely **type**, with a per-character delay, across all three engines.

The nine-lens review ran twice against this release. Everything below was found by it and
fixed before tagging.

- **`<tosi-crud>` destroyed unsaved edits** — one frame after any keystroke, with no user
  action, and `save()` then posted the unedited record.
- **`<tosi-crud>`'s table selection never followed the form**, so a `#?people.id=2` deep link
  opened the record with nothing highlighted — the feature these notes headline, producing a
  list that did not show where you were. **Back** also left the opened record on screen while
  the URL said otherwise, so back-then-reload showed you two different things; and a
  hash-driven selection change fired no `change` event. Saving a new record now writes its id
  to the hash, which is both correct and what makes "absent id means deselect" safe.
- **`<tosi-crud>` rebuilt the entire table on every keystroke.** Identity-guarded; measured at
  zero table renders per keystroke afterwards.
- **`<tosi-schema-form>` emitted duplicate `change` events** — a checkbox click produced
  three. `<tosi-table>` fixed the identical hazard in this release; the sibling shipped
  without it.
- **The "not validated" note skipped containers**, so `uniqueItems` on an array and `allOf` on
  an object — exactly the keywords a validator is most likely to skip — carried nothing.
- **`columnsFromSchema` re-derived property types** and got `{anyOf:[{type:'boolean'},
{type:'null'}]}` wrong, and emitted only `title` as the column name — so a title-less schema
  gave headers reading `firstName` beside a form showing "first name".
- **`<tosi-table>`'s column menu still concatenated localized words**, which the localize docs
  in this same release condemn by name. All five languages checked rendered wrong:
  `Sortieren Aufsteigend`, `숨기기 열`, `非表示 カラム`, `隐藏 列`, `Cacher Colonne`.
  `localizePhrase(key, fragments)` asks for the whole sentence and joins the fragments only
  when nobody has translated it — so an existing translation table keeps its behaviour exactly
  and adding one row upgrades it. Nothing is orphaned.
- **Six `on<Event>` members were shadowed by the element factory**, which warns about it in
  the console. Renamed to `handle<Event>` per the framework's own guidance.
- **The no-validator path had no coverage in any tier** — the default for every ESM adopter,
  while every lane ran with a validator registered. It has its own test file now. Two real
  defects were behind that gap: removing a validator left the warnings permanently
  suppressed, and a single once-flag meant whichever component spoke first silenced the rest.
- **A form with no schema and no validator rendered an empty box**, warning about validation
  when the problem was that it had nothing to render. It says so on screen now, and the
  warning names the right problem.
- **`change.detail.oldValue` was `undefined` for every cell edit after the first.** The
  baseline was deleted on each commit rather than rolled forward, and the delete ran _before_
  the equality guard — so a no-op commit wiped it, and clearing a numeric cell as the second
  edit fired no `change` at all and skipped the coercion write, leaving a schema-typed integer
  holding a raw string.
- **The documented `setSchemaValidator` recipe was wrong**, and only for the people it was
  written for. `{ validate, inferSchema }` omits `unenforcedKeywords`, so the form fell back
  to a keyword list frozen at tosijs-schema 1.7.0 and labelled every `oneOf` and
  `exclusiveMinimum` field "not validated" while 1.8.0 was checking it. Our own doc site was
  correct because the iife passed all three — so the failure was visible _only_ to the ESM
  adopters the docs instruct. Pass all three.
- **`crud.table` and `crud.form` threw before hydration** — the only two accessors on the
  class without the guard the rest have — so the documented way to reach the composed parts
  threw on the line the docs told you to write. They return `null` now. The snippet was also
  in a display-only fence, so no lane ran it; it is executable and tested. And its second line
  was wrong too: `crud.form.readOnly = true` was silently reverted on the next render.
- **`<tosi-crud>` wrote `history.replaceState` on every search keystroke.** WebKit throws past
  ~100 calls in 10s — a held key clearing a long term gets there — and the throw preceded the
  debounce, so search silently stopped working with an uncaught error. The URL write moved
  inside the debounce, where the store query already was.
- **An empty `PREVIEW_HOST` swallowed the whole resolution chain.** `??` only skips
  null/undefined, so `PREVIEW_HOST=''` — what a CI `env:` renders for a missing secret —
  counted as "set" and hid both the site config and `~/local-secrets`, the exact symptom that
  rung was added to abolish.
- **Adding or moving an array item dropped focus.** Every row is rebuilt, so the button you
  just clicked stopped existing: you could not press ↓ twice to move an item two places, or
  add two rows from the keyboard. Focus is restored by role and index, skipping controls that
  are disabled at the destination.
- **`<tosi-schema-form>` was quadratic in the keystroke path** — one root-scoped
  `querySelector` per field, twice, per keystroke. Measured on the built component: 800 fields
  cost **240 ms** per keystroke before and **0.64 ms** after. An index built with the DOM
  replaces the scans.
- **The redemption gate had 21 tests and its wiring had none.** Reverting the dev server to a
  bare `redeemLink()` call restored both the unbounded queue and the unthrottled guess rate
  while leaving every lane green — a security control held in place by nothing but the diff.
  The decision is an exported function now, like the others in that file, and is tested.
- **Notes read `root.uniqueItems is not validated`** on a field called Tags — upstream's
  `unenforcedKeywords` answers with paths. The leading `root.` is stripped and deeper segments
  kept, since for a container the keyword may be several levels down.
- **These notes themselves published the old preview-host precedence**, inverted against the
  code and against another paragraph thirty lines earlier. Corrected, with a test asserting
  all four places that state it agree — that sentence had been written three different ways.
- **A duplicate mid-file import** in `dev-auth.test.ts`, found only because nothing had ever
  type-checked the test suite. (~150 accumulated type errors remain; that is tracked, not
  fixed here.)
- **The two vendored-type anti-drift guards were never type-checked** — `tsconfig.json`
  excludes `*.test.ts`, so both were inert and one was **red**: `json-schema.test.ts` asserted
  our `JSONSchema` is assignable to theirs, which TypeScript cannot decide because both types
  recurse through `additionalProperties`. The guard now asserts the direction it CAN decide —
  theirs → ours, the assignment the code actually performs when storing an inferred schema —
  and the reverse is exercised at runtime by validating an ours-typed schema through their
  validator. `bun run typecheck-guards` (`tsconfig.guards.json`) runs in CI beside the
  existing typecheck; mutation-verified that drifting the vendored type turns it red.
- **`<tosi-table>`'s four column-menu captions** now use `localizePhrase`, so the docs and the
  docs' own worked example agree.

### Also fixed in the review passes

- `<tosi-schema-form>` let the control's native `change` escape to consumers, so a form edit
  fired a duplicate event — the `input`-only handler's `stopPropagation()` never saw a
  `change`. Three events per checkbox click became one.
- `localizePhrase` asked "did the output differ from the input?" rather than "does a row
  exist", so a locale that deliberately maps a phrase to itself fell back to joined fragments
  — the behaviour the row was added to replace. It also re-implemented annotation stripping
  with a weaker regex than the module's own, which already disagreed about `tag\#42`.
- `--tosi-spacing-50` is not a variable — the scale is `-xs/-sm/-lg/-xl` — and the `var()`
  fallback made the mistake invisible. The three genuinely-new customization points
  (`--tosi-error`, `--tosi-border`, `--tosi-border-radius`) are documented on the schema-form
  page rather than left accidental.
- `<tosi-table>`'s `fieldFor` was documented as "cached per render pass" and cached nothing,
  while allocating a schema and running the validator's keyword walker per editable cell per
  render.
- `SiteConfig.tunnel.linkTtlMinutes` said "Default 15" two lines above "Defaults to 5"; the
  summary line is what an editor tooltip shows.
- `tosijs-tunnel --link` now prints the 7-character code as well as the URL, which the docs
  already described and only the dev server's own banner did.
- The "No preview host" instructions promised a `700` credentials directory and gave a command
  that neither created nor secured it — and failed outright on a machine that did not have it.
- `src/docs/utilities.md` was a five-line stub duplicating a section `helper-libraries.md`
  already provides. Removed.
- `src/docs/migrating.md` has a **1.11.0 section** — the validator seam, the `tjs-lang` floor,
  and the edit-token change. It exists for "releases you may be upgrading past" and had no
  entry despite this release having a Breaking section.

- `<tosi-table>`'s `change` event reported `oldValue: undefined` whenever no `focus` event had
  fired on the cell — an unfocused browser window, or any consumer who sets `.value` and
  dispatches `change`, which is what an adopter's test does. The old value came from a
  `WeakMap` filled on focus, which existed because two-way `bindValue` used to overwrite the
  model as the user typed. The one-way binding shipped earlier in this release ended that, so
  the old value is now read from the model immediately before the write — no focus event
  required, and one mechanism instead of two. Caught by the haltija lane, which the other
  three lanes do not substitute for.

- `crud.save()` threw away the record it sent when the store returned nothing. A 204-style
  adapter satisfies `Promise<any>` by resolving `undefined`, which is a perfectly ordinary
  thing for a REST store to do, and the row went blank. It now keeps what it sent when the
  store has nothing to say.
- The `doc-system.pw.ts` nested-doc-system flake — the **fourth** instance this release of a
  spec racing the page's own inline doc tests. The runner is now disabled for that whole
  spec file rather than per test, because after four the fix plainly belonged at the file
  level. 6/6 clean since.

- `<tosi-doc-system>` gave up on the first failed `docs.json` fetch, so a doc site rendered
  nothing at all when one request lost — a real-user failure, not only the test flake that
  surfaced it. It now retries three times with full-jitter backoff (250ms base, 4s cap) and
  honours `Retry-After`. It retries **only 429, 5xx and network errors**: a 404 or a 403 is an
  answer, and re-asking cannot change it — which is what stops a retry turning an overloaded
  server into a self-inflicted DoS.

### Known issues

- **firefox intermittently reports `no WebAssembly compiler available`** under the full
  parallel test lane, which stops the inline-WASM example loading at all. Filed as
  [tjs-lang#36](https://github.com/tonioloewald/tjs-lang/issues/36): a `wasm {} fallback {}`
  block should take its fallback branch when the engine declines to compile, which is exactly
  what that branch is for. Affects the test lane, not shipped behaviour — the example
  degrades to not rendering rather than misreporting, and the doc-test guard against _silent_
  fallback is unaffected.

- **[#102](https://github.com/tonioloewald/tosijs-ui/issues/102)** — `<tosi-table>`'s header
  and body columns can disagree when a programmatic `columns` assignment lands while a render
  is already in flight. The reporting app changes column visibility on a page-size change,
  which is what produces the timing. The host app arguably should not reassign columns
  mid-render, but a torn header/body is ours either way. Targeted at **1.11.1**; the path
  predates 1.11.0.

### Fixed

**Packaging — this one affects existing `<tosi-table>` users.** `<tosi-table>` no longer reaches
for a schema library at all: it is the component people use _without_ one, and it must never make
anyone install something in order to build.

`tosijs-schema` was imported by
shipped `dist/` and declared in **no dependency field**, so a consumer's build failed on a
package they were never told they needed. It is now a declared optional peer at `^1.7.0` — which
is what the design note said from the start — and nothing we ship imports a type from an optional
peer any more: `import type` is erased from emitted JS but **not** from emitted `.d.ts`, and
declaring a peer optional does not help because TypeScript has no notion of an optional peer.
A second instance had been shipping for a while: `dist/code-editor-cm.d.ts` imported a type from
`tjs-lang/editors/codemirror`, a declared optional peer that TS2307s anyway (reported upstream as
tjs-lang#28). Verified from a packed tarball in a clean project: `tsc` and `vite build` both exit
0 with the peers absent.

- The `./*` subpath wildcard mapped `tosijs-ui/schema-form/fields.js` to
  `dist/schema-form/fields.js.js`, so the extension-ful spelling — what an adopter copying this
  repo's own ESM import style would write — did not resolve at all. A second `./*.js` wildcard
  fixes it, strictly additively.

**Localization was wrong in shipped data, and the keys were why.** `<tosi-table>`'s column-header
menu asked for bare words, and our own translation table shows the cost: `Right` came back in the
_correct_ sense rather than the direction in four of nine languages (sv `Rätt`, zh `正确的`, es
`Bien`, it `Giusto`), `Column` as `柱子` (a pillar), `Sort` as `种类` / `종류` (a kind of thing),
`Show` as `Espectáculo` / `Spettacolo` (the kind with a stage). Every one is a competent
translation of the word it was given — which is the point. The keys now carry sense annotations
(`Right#direction`, `Column#table`, `Sort#order`, `Show#reveal`, `Hide#conceal`, `Pin#fasten`,
`Ascending#sort-order`), and `demo/src/localized-strings.ts` has matching rows with the
wrong-sense entries corrected, verified in a browser across sv/es/zh. Annotating a key is
backward compatible, so this cannot orphan an existing table.

**`<tosi-crud>` destroyed unsaved edits.** Four specs now cover delete, its rejection path and
`createNew`→`save`; mutation testing confirms that restoring the defects turns two of them red.

`showSelected()` compared `form.value !== _selected`,
and `setByPath` is immutable, so after the first keystroke that was permanently true and every
render wrote the record as loaded back over the edit — one frame later, with no user action, and
`save()` then posted the unedited record. The fix keeps the load guard
(`_loaded !== _selected`) — one invariant, one place — and drops a form-change listener that set
`_selected = form.value`, which nothing needed: `value` and `save()` read `form.value` directly,
and `remove()` is better with the record as opened than with a half-edited copy of it. Mutation
testing settled that: the two were independently sufficient, so neither was pinned and either
could have been removed without a single test failing.

- `<tosi-crud>`'s destructive paths ran in no lane, and the gap hid two defects: `createNew()`
  followed by `remove()` sent `store.delete({})`, and the Delete button raised an unhandled
  rejection on a rejecting store.
- Grid navigation swallowed arrows, Home and End inside editable cells, so the caret could not be
  moved and focus jumped away mid-edit; on an enum `<select>` the cell could not be changed by
  keyboard at all. Escape and Tab stay with the grid.
- The cell's native `change` no longer escapes the cell. It bubbles, so a listener on the table
  received both — theirs with a `detail`, the input's with none.
- The "this keyword is not validated" note rendered **only for unions**, so the honesty it exists
  for covered exactly one keyword while the docs promised otherwise.
- `validate` is called with `strict: true`. Without it the validator samples: `maxProperties` is
  skipped entirely and a bad element deep in a long array is stepped over.
- The optional-peer degrade was silent. It now warns once naming the package and the seam, and
  `validationAvailable` lets a Save handler tell _"this conforms"_ from _"nobody checked"_.
- We read `x-discriminator` first. `discriminator` is an OpenAPI keyword that tosijs-schema's own
  `agentContract` refuses, so a schema written to pass their check spells it `x-`.
- The schema→control layer existed twice and had drifted: a `const` property was readonly in the
  form and a freely editable text box in the table.
- Variant detection scores branches on how many required keys are present, rather than demanding
  every key — a half-filled variant used to match nothing and show an empty box.
- `readOnly` hides the add/remove/reorder controls instead of disabling them.
- The array controls carry stable classes (`.schema-move-up`, `.schema-move-down`,
  `.schema-remove`) alongside their localized `title`/`aria-label`: a localized tooltip cannot
  also be a selector.
- The hash router treats `?…` in the hash as a query, not part of the path. `#/invoices/42?q=x`
  previously matched no route at all.

The intermittent Playwright failure was chased rather than re-run until green: `hash-state.pw.ts`
on WebKit, about one run in four, because the page's own inline doc test writes into the hash
asynchronously and could land after the spec cleared it. A doc page under test is also a page
running tests.

**The dev bridge's own guard was a denial of service.** Serialized redemption on an
unauthenticated path with no depth cap meant 50 junk requests delayed a legitimate link by 42
seconds, and a trickle outgrew the drain so the denial outlasted the attack. Depth is capped;
overflow is refused instantly with 503 and `Retry-After`. The clock is also read on **arrival**,
so a token valid when you clicked can no longer expire while queued.

**Preview host handling.** A project's own `preview.host` outranks
`~/local-secrets/tosijs-preview.env`; the machine-global file holds one host, and when it won,
running `tosijs-tunnel` in someone else's checkout pointed it at your box — and that bin does not
merely print a target. One `resolvePreviewHost()` now serves all three bins. The tunnel's link
line no longer claims "Single-use edit link (valid 15 min)", and `doc-site-system.md` no longer
shows `host:` in its example config. `UPSTREAM.md` claimed 0.12.0 was the latest tjs-lang; stale
by two minors.

### Housekeeping: we published a preview address, and rotated it

Our own `tosijs-site.config.ts` committed the preview host as an ssh target
(`user@address`) — an **address, not a credential**. Nothing an adopter installs is
affected, and this is not a security advisory for anyone but us. Recording it because the
practice it produced is worth adopting.

Verified rather than assumed, since "we're sure there were no keys in there" is exactly the
sort of claim that deserves checking:

- **no private key material anywhere in the repo's history** — no `BEGIN … PRIVATE KEY`
  block in any commit on any ref, and no `.pem`, `.key`, `id_rsa` or `id_ed25519` file ever
  added. `*.pem` is gitignored; the only tracked files under `tls/` are a README and the
  cert-generation script.
- access to that box always required a key that was never published, so the exposure was an
  address someone could try to connect to — not a way in.

It has been **rotated regardless**, because a value that has ever been committed to a public
repo is public: `git log -S` finds it in seconds, and rewriting history does not un-publish
anything.

The practice that came out of it, now in `tosijs-coding-practices` → `deployment.md`:
machine-local credentials live in `~/local-secrets/` — a `700` directory beside the repos,
never inside one, so committing them is structurally impossible rather than merely against
the rules. `tosijs-ui`'s bins resolve `--host=` > `PREVIEW_HOST` > `PREVIEW_SSH` > site config >
`~/local-secrets/tosijs-preview.env` — the machine-global file is **last**, so a project that
names its own host is never silently redirected at someone else's box — and a regression test
now greps our own
config for a `user@host` literal. The rule had been documented in `site-config.ts` the whole
time and violated in the same repo — which is why the guarantee is now a directory.

## 1.10.2

> **Never released.** This version was tagged but never published, and the tag has since been
> deleted. Everything below shipped in **1.11.0** instead — including the `killStrayServer`
> fix, which is why the withdrawal is safe rather than a dropped security patch. npm went
> straight from 1.10.0 to 1.11.0. Kept here because these changes are real and a reader
> tracing when something landed needs to find them. ([#101](https://github.com/tonioloewald/tosijs-ui/issues/101))

**The dev server no longer serves stale content after a rebuild.** This is the one that
presented as random flakiness across several sessions: a served `/docs.json` disagreeing with
the file on disk made the doc browser's route match fail, so live-example insertion silently
skipped — the page rendered, hydration reported success, custom elements were defined, and
**zero examples appeared, with no error anywhere**.

The cause was a cache key of `path + lastModified`, with a comment asserting that a rebuild
invalidates it naturally. It does not: `lastModified` is millisecond-granularity, and six
rapid writes to one file report the same value. A rebuild that rewrote a file inside one
millisecond kept serving the first compressed body until the process restarted — which is why
a restart "fixed" it. The cache is now emptied on every completed rebuild, and the key carries
the file size. (#50)

**`killStrayServer` no longer shoots an unrelated process.** Reclaiming the port checked that
the holder was a JS runtime (`bun`/`node`/`deno`) — but "a JS runtime" is not "our dev
server", and a colleague's dev server, a language server or a test runner would all pass. It
now requires the process to be working in **this project**, and skips with a message naming
the directory when it is not. If the working directory cannot be read it skips too: refusing
to start costs you ten seconds, shooting the wrong process costs you an afternoon. (#77)

**`bun run release-check` now checks the version against what actually changed** — it blocks a
patch that carries a `[break]` annotation or touches a security-relevant path, and warns on a
`[change]`. The nine-lens review triggers on the version _letter_, so it only ever fired when
the letter was already right; 1.9.9 was prepared, gated, tagged and pushed as a patch while
relocating a build artifact and loosening a security default, and was caught only because a
human asked for a review anyway. (#78, #79)

**Docs:** `bundleOutDir` is in the Bundle config table it belongs in, and `checkExamples` —
including `contextKeys`, which **extends** the default context rather than replacing it — is
documented where adopters actually look. (#80, #81)

## 1.10.1

> **Never released.** This version was tagged but never published, and the tag has since been
> deleted. Everything below shipped in **1.11.0** instead — including the `killStrayServer`
> fix, which is why the withdrawal is safe rather than a dropped security patch. npm went
> straight from 1.10.0 to 1.11.0. Kept here because these changes are real and a reader
> tracing when something landed needs to find them. ([#101](https://github.com/tonioloewald/tosijs-ui/issues/101))

Two fixes to 1.10.0's row grouping and scroll preservation, both reported by snowfox against
a real wide, grouped, pinned table.

**A pinned column that repeats a group value is no longer a hole.** A column that is both
`pinned` and listed in `nonRepeatingGroupedRowCells` used `visibility: hidden` on its repeated
cells — which suppresses _everything the cell paints_, including the opaque background
`.col-pinned` exists to provide. On every row but the first of its group, horizontally
scrolled cells showed through the sticky column **and were clickable there**: a visual hole
and a hit-testing hole from one rule. Repeated cells now hide their _content_ (transparent
text, `display: none` on element children, `user-select: none`) and keep painting their box.
(#83)

**`preserveScroll` now restores horizontal position too.** It restored `scrollTop` alone, and
the tables that most need it are the wide ones — normally read scrolled sideways, with pinned
columns as the identity and the interesting columns off to the right. Sorting or toggling a
group snapped back to column 0, so the row stayed put while appearing to say something
completely different. `scrollLeft` is captured independently of the row anchor, so it also
survives on a table that is scrolled sideways but still at the top — which used to return
early and lose the columns. (#86)

Group ids are memoized per render in a `WeakMap`; the map is replaced each render, so a
cached id can never outlive the values it came from.

## 1.10.0

Mostly a **silent-failure** release: several things that were quietly not working, or quietly
not being checked, now say so. If you use `tosijs-ui/site`, three of these have probably cost
you time already.

### `<tosi-table>`

**Scroll position survives a re-render.** Sorting, filtering, or toggling
`visibleGroupedRowIds` no longer throws you back to the top. The table anchors on the
**topmost visible row**, not on `scrollTop`, and restores that row to the same offset — the
distinction matters as soon as the row count changes, because the same pixel offset then
shows entirely different rows. Set `preserveScroll = false` for the case where a render means
"here is a different dataset" and starting at the top is right.

### The tunnel: links now work on a second device

A magic link is redeemable **repeatedly for 15 minutes** instead of dying on first use. The
old behaviour collided with the feature's own purpose: glance at a link and close the tab and
you need a new one; open it on a laptop then reach for your phone and it is dead — in a
workspace built for reading your uncommitted tree _on a phone_. One project had already
replaced it with a never-expiring link of their own, which is the tell.

The bound moved from _uses_ to _time_, not away. Set the level per project:

```typescript
tunnel: { linkPolicy: 'single-use', linkTtlMinutes: 2 }   // ratchet up
```

An expired link is refused under either policy — widening reuse never widens lifetime.

Every user-facing string — including the line the CLI prints at the moment you share a link
— is derived from the policy in force, rather than asserting "single-use" regardless.

### Silent failures, now loud

- **`docPaths` are watched.** A root-level doc was served and rendered but never watched:
  edit, save, refresh, stale page, no rebuild, no message — indistinguishable from a browser
  cache. (#49)
- **Two builders on one output tree are refused,** naming the holder's pid and port. Both
  wipe-then-repopulate `docs/`, so running a standalone build against a live dev server meant
  each deleted what the other was writing; it killed a dev server with no error anywhere and
  left `docs/` a fraction populated. A standalone `buildSite()` refuses; a second **dev
  server** only warns and continues, because `bun playwright test` and `bun run test-browser`
  each start their own and a hard failure there would break the lanes this protects. The lock
  is stale-safe (a dead holder's lock is debris), re-entrant, released on Ctrl-C, and fails
  open. (#51)
- **A truncated `/*#` doc block warns, by name.** Block comments cannot nest, so a `*/`
  inside a doc demo ends the doc there — previously surfacing as parse errors pointing at
  _prose_, never at the delimiter responsible. (#70)
- **Inline doc tests no longer under-report.** A page could report "done" before its examples
  had run, so their tests were omitted rather than failed — silently, with the suite green.
  **If you use `tosijs-ui/site` with async examples, your counts may have been wrong;** a
  number that goes UP after upgrading is this fix.

### Build output

**The hydration bundle now builds into your site output, not `dist/`.** It was written into
the library tree with only the `.js` copied out, stranding the sourcemap in a directory you
publish and commit but never serve — 65 MiB across 216 packed blobs in one adopter, ~35% of
that repo's packed blob store, for a file nothing could load. Set `bundleOutDir` if your
bundle is itself a published artifact. If you have that history:
`git rm --cached dist/iife.js dist/iife.js.map` and gitignore them. (#69)

### Also in this release

- **`checkExamples` accepts `{ contextKeys: [...] }`** as well as a boolean, so a library
  that documents ITSELF — whose examples import its own package name — keeps the guard on
  instead of disabling it. The keys are **added to** the `tosijs` / `tosijs-ui` defaults, not
  substituted for them. (#71)
- **`preview.tunnel.linkPolicy`** (`'window'` | `'single-use'`) and
  **`preview.tunnel.linkTtlMinutes`** (default 15) set the link security level.
- **`SiteConfig.preview.host` is optional.** The documented practice supplies it from
  `PREVIEW_HOST`, so a correct config used to fail typecheck. (#72)
- **`marked` peer is `^16.4.2 || ^17.0.0 || ^18.0.0`** — it was two majors behind `latest`, so
  a consumer on a current marked got a peer violation on a fresh install. (#60)
- **The live-example docs now state that `test()` bodies run concurrently** within a fence,
  and give the rule that follows: steps that depend on each other belong in one `test()`.
  (#43)
- **`llms.txt` carries the browser-control affordance** when a project sets `haltijaDev`, so
  an agent learns it can drive the running page instead of inferring behaviour from source.
  (#18)
- **Host bootstrap in `doc-site-system.md` now gives the actual commands** rather than
  describing the danger and leaving the dangerous step as an exercise.
- **`svg2DataUrl`: two real fixes, and one that is NOT done.** It wrote `strokeWidth` /
  `strokeLinecap` / `strokeLinejoin` — names SVG ignores — on any element carrying inline
  style, and explicit `fill` / `stroke` / `strokeWidth` arguments were clobbered by the style
  pass. Both fixed.

  **[#68](https://github.com/tonioloewald/tosijs-ui/issues/68) is NOT closed by this.** Most
  icons carry their styling on the **root** `<svg>`, which this does not touch, so a
  `stroked` icon — about 280 of ~287 — still serializes with no stroke at all and renders as
  a solid black wedge. Verified after the fix: `svg2DataUrl(icons.chevronRight())` emits no
  `stroke` and no `stroke-width`. Prefer inline `<svg>` over a data URL for stroked icons
  until this lands.

- **`acquireBuildLock` / `lockDecision`** in `build-lock.ts` — the
  output-tree lock behind the concurrent-build refusal. **Internal**: not exported from
  `tosijs-ui/site`, so do not build on it yet. Say so if you want it public.
- New **`tosijs-caddy-install`** bin — installs the Caddy snippets the deploy/tunnel bins
  need, **refuses** to install a template with placeholders left in it, keeps the outgoing
  config as `Caddyfile.bak`, and is dry-run by default. That refusal was the reason this
  existed as a private script; shipping the template without it was the wrong half to keep.
- The unit lane now covers `bin/` — `bunfig.toml`'s test root was `./src`, so tests anywhere
  else were silently never collected.

> **Versioning note.** This is a patch that adds public API. Minors are for breaking changes
> and feature rollouts; additive, non-breaking extensions ship as patches. The contract you
> rely on is unchanged: **a patch never breaks you.**

## 1.9.8

**Grouped tables can now say how much of a group is showing.** The piece missing from 1.9.7's
row grouping: a cell that reports on its group ("showing 2 of 7") or offers a **show-all**
toggle needs to compare what is rendered against what exists — and that is the one comparison
a cell renderer cannot make for itself. The filter has already run by then, so a consumer
sees the survivors with nothing to measure them against. The table sees both sides.

```js
const id = table.groupIdFor(row)
const { visible, total } = table.rowGroupCounts.get(id) ?? { visible: 0, total: 0 }
cell.textContent = visible < total ? `showing ${visible} of ${total}` : `${total} lines`

// the toggle is just a set of ids handed back to the table
expanded.has(id) ? expanded.delete(id) : expanded.add(id)
table.visibleGroupedRowIds = [...expanded]
```

- **`table.rowGroupCounts`** — a `Map` from group id to `{ visible, total }`, rendered rows
  against rows before filtering. Recomputed each render and readable while cells render.
- **`table.groupIdFor(row)`** — a row's group id, or `null` when ungrouped. This matters when
  the grouping was **inferred** from `nonRepeatingGroupedRowCells`: you never wrote that
  function, so its ids are otherwise unreproducible and the map would be keyed by strings you
  could not construct.

Groups the filter removed **entirely** stay in the map with `visible: 0`, so "nothing here
matched" stays distinguishable from "no such group" — the first being exactly when a show-all
toggle is worth offering. `rowGroupCounts` is always a `Map` (empty when ungrouped), so
`.get()` needs no null check. Pinned rows sit outside grouping and are not counted.

Additive only; ungrouped tables are unaffected.

## 1.9.7

**`<tosi-table>` can group rows.** The motivating shape is invoice lines: rows that belong
together, striped as a unit, with the values they all repeat shown once.

```js
table.rowGroupId = (row) => `${row.invoice}/${row.buyer}`
table.nonRepeatingGroupedRowCells = ['invoice', 'buyer'] // shown once per group
table.visibleGroupedRowIds = ['INV-1001/Acme'] // stays visible past the filter
```

Set `nonRepeatingGroupedRowCells` on its own and the grouping is **inferred** from exactly
those columns, so the common case is one line. Grouped rows get `table-cluster-even` /
`table-cluster-odd`, alternating **per group** — a five-line invoice is one stripe, not
five.

`visibleGroupedRowIds` keeps whole groups visible regardless of the filter, so a search
matching one line of an invoice can open the whole invoice without the filter needing to
know anything about grouping. It is additive to the filter's own result, so a filter that
ranks as well as selects keeps its ranking.

Two details worth knowing. **Clusters keep your sort**: groups appear in first-appearance
order, so a group lands wherever its best-sorted row landed. Sorting the clusters by their id
instead would have quietly thrown away the sort the user just clicked.

And repeated cells are **hidden, not emptied** — transparent text plus `display: none` on
element children, via `.tr:not(.table-cluster-first) .cluster-repeat`. Deliberately not
`visibility: hidden`, which also stops the cell painting its **background**: a pinned
column's background is the only thing masking the columns scrolling underneath it, so
repeated cells there became windows onto the scrolled content. And never `display: none` on
the cell itself — every cell is an item of the row's grid, so removing one pulls each later
cell a column left and the row renders under the wrong headers.
`table.isFirstInGroup(row)` answers the same question in JavaScript.

Group ids are **memoized per render** in a `WeakMap` — the id is computed several times for
one row in a single pass (clustering, parity, first-of-group, counts, then again per stamped
row), and the inferred form does a `JSON.stringify` each time. The map is replaced on every
render rather than kept, so a cached id can never outlive the values it came from.

Ungrouped tables are unaffected — all three properties default to off.

**The inline doc-test lane was under-reporting, and staying green about it.** If you use
`tosijs-ui/site` and have doc pages whose examples do async work, **your test counts may
have been wrong.** A test iframe reported "page done" as soon as its _first_ example
settled, so any example still awaiting a slow `js` block — a `fetch`, typically — never ran
its tests, and they were omitted rather than failed. On this repo's own table page that was
8 tests silently becoming 1, with the suite reporting "passed" either way.

Completion is now decided by which examples have `test` **source**, which is known before
anything executes, so an example that has not started yet is waited on instead of
overlooked. An example that never finishes now fails by name with an excerpt of its source.
The rule itself is exported as `unsettledExamples()` / `isSettled()` from
`src/doc-system/test-completion.ts`, pure and unit-tested — a bug whose symptom is a
_missing_ test cannot be caught by the browser lane it breaks.

Check your own counts against what your pages actually contain; a number that goes **up**
after upgrading is this bug, not a new one.

**The dev server no longer 404s while it rebuilds** — the actual cause behind the LAN
stalls in #63, traced by tosijs-3d.

`buildSite` moves the output directory aside (`mv docs docs.last-good`) and repopulates it
from empty, so for the length of every build **every served path is simply absent**. Two
failures follow:

- a request that _starts_ in that window gets a plain 404 — the page loads and never
  hydrates
- a request already _in flight_ has its file vanish underneath it, stops producing data,
  and idles until something closes it — the "loads then stalls" symptom

It only ever bit over the LAN because a loopback transfer finishes in ~22 ms and almost
never overlaps the hole, while a multi-MB transfer to a phone or a second machine is
easily still running two seconds later. It also explains why reloading never helped while
you were actively editing: every save reopened the window.

The dev server now falls back to the last-good tree for any path missing from the live
one. That copy exists for exactly the window's duration — created by the stash at the
start of a build, removed on success — so the fallback is self-scoping, and no stale
content can be served outside a rebuild.

Measured across real rebuilds: **2 failed requests before, 0 after**.

**Formatting is gated.** `bun format` was named in no gate, so drift only accumulated — 24
unformatted files at 1.9.0, 40 by now — and the cost always landed on whoever next ran it,
as three dozen unrelated files in their feature diff. New `bun run format-check`
(`prettier --check .`), run in CI. Adopters get the script; nothing about the published
package changes.

> **On versioning.** This is a patch that adds public API, which the project's own rule
> previously called a minor. The rule has changed: minors are for **breaking changes and
> feature rollouts**, while additive, non-breaking extensions ship as patches. The contract
> you rely on is unchanged and is the whole point — **a patch never breaks you.**

> The `idleTimeout` raise and asset compression in 1.9.6 both help, but neither closes this
> window — compression shortens the exposure, and `idleTimeout` only decides how long a
> stalled request waits before failing. If you are on 1.9.6 and still seeing LAN stalls,
> this is the release that fixes them.

## 1.9.6

**`<tosi-table>` sorts correctly** (#62, reported by snowfox). Two independent defects, one
in each half of the column comparator.

**Numeric strings sorted by first digit.** The comparator used a raw `>`, which compares
strings lexically — so `'9'` sorted after `'399'`, and `'1200'` before `'3.5'`. Real data
is full of numeric strings: CSV and TSV imports, BigQuery exports, JSON where numbers
arrived as text, anything id-shaped. Sorting now compares naturally:

- numeric values (including numeric strings) compare as numbers, decimals and negatives
  included
- very long integers stay exact — two 20-digit ids differing in the last digit order
  correctly, where a plain `parseFloat` comparison calls them equal
- text compares with a locale collator, so accented letters order the way the reader
  expects rather than the way ASCII does
- blank cells sort **last in both directions** — a descending sort that opens on a
  screenful of empty cells is never what was clicked for

The old comparator also never returned `0`, so two _equal_ values each claimed to be
greater than the other. `Array.sort` is entitled to turn an inconsistent comparator into
arbitrary output, not merely wrong output.

**New `ColumnOptions.sortValue`** — sort by what the cell _shows_, when that differs from
what the row stores. A column with a custom `dataCell` renders whatever it likes while the
sort keyed on `prop`, so the rows reordered by an invisible value:

```typescript
{
  name: 'Invoice #',
  prop: 'Customer invoice ID',   // what the row stores — CSV export, lookups
  dataCell: invoiceCell,          // what the reader sees
  sortValue: (row) => row['Invoice number'] || row['Customer invoice ID'],
}
```

Purely additive; `table.sort` still overrides everything for cases a per-column value
cannot express.

**New `naturalCompare` / `naturalSorter` / `isBlank`** exported from `tosijs-ui`, since the
same comparison is wanted outside tables.

### Build system

- **Code-split chunks land in `_chunks/` instead of the web root** (#64). `--splitting`
  emits one hashed chunk per dynamic import, flat beside `index.html` — for a corpus that
  pulls something large, that is thousands of files: tosijs-3d reported **2,473 hashed
  chunks among 2,604 files**, all tracked in git because `docs/` is the Pages source.

  The cost that hurts is not legibility. Any dependency bump rewrites every hash, so
  upgrading one package became a multi-thousand-file commit — which makes the standing
  "never commit `docs/` from a feature push" hazard far worse, since a stray `git add -A`
  moves thousands of files and the diff is unreviewable. Assets move to `_assets/` for the
  same reason.

- **The dev server no longer times out mid-transfer on LAN loads** (#63). `Bun.serve`
  defaults to a 10s `idleTimeout`, sized for small API responses — but a doc site's bundle
  can be multiple MB, and over wifi to a phone or a second laptop that legitimately takes
  longer, so the connection closed part-way. It never reproduces on loopback, which makes
  it look like a client-side stall. Now 120s, overridable with
  `DEV_REQUEST_TIMEOUT_SECONDS`. Testing on real devices over the LAN is exactly what the
  dev cert covers `<host>.local` for.

### Also

- **The release lane now checks, against the packed manifest, that each peer dependency we
  also develop against is pinned within the range we publish** (#57). A floor the library
  is not itself built against is a contract nobody tests, and the only signal was an
  adopter's install warning. `tosijs`'s `^1.7.8` floor is not arbitrary — it encodes
  tosijs#20 (the `parts` proxy crossing into nested components) and #21 — and that reason
  is now recorded in `CLAUDE.md` rather than only in an old changelog entry.

`makeSorter` deliberately keeps its existing comparison: switching it would change _case_
ordering (`'Zed'` before `'alice'` today) in a general-purpose utility where that has not
been reported as a problem. Coerce numeric strings in the valuator, or pass values through
`naturalCompare` yourself.

## 1.9.5

**`localize()` no longer destroys literal `#`** (#55, reported by snowfox).

`#` separates a string from a context annotation (`Okay#confirm`) and every `#` was
stripped, so any literal one was lost: `'C# Tutorial'` became `'C'`, `'Issue #42'` became
`'Issue '`, and `'#hashtag'` became `''` — an empty string, which renders as a blank label
rather than an obvious error.

An annotation is a **suffix identifier**, so only a `#` that looks like one is treated as
one — immediately preceded by a non-space, followed by letters/digits/`_`/`-`, at the end
of the string:

| string         | read as                       |
| -------------- | ----------------------------- |
| `Okay#confirm` | annotation                    |
| `C# Tutorial`  | literal — space after         |
| `Issue #42`    | literal — space before        |
| `#hashtag`     | literal — nothing before      |
| `C#`           | literal — no annotation after |

No author action and no data migration; every documented annotation form is unchanged.
Escape with `\#` for a literal that genuinely looks like an annotation (`tag\#42`).

**The worse half:** annotations were stripped from the _translated value_ too, so a
translation containing `#` was truncated **even when the source string had none** —
`'Sharp'` → `'Dièse #1'` came back as `'Dièse '`. A translator writing an ordinary string
had it silently cut, with nothing in the source to hint why. Translations are no longer
scanned for annotations at all: an annotation belongs to the lookup key, not to the text
it resolves to.

### Also

- **`brace-expansion` pinned to `>=5.0.9`** (GHSA-rgw5-rvv9-x895, HIGH, DoS-only). A
  follow-up advisory against a package already pinned at `>=5.0.8` for the previous one —
  the new advisory covers `<5.0.9`. The audit gate blocked the build on it, which is the
  case a one-time fix misses and a gate does not.

## 1.9.4

**The dev server now uses your own installed `haltija`** (#48, reported by tosijs-3d).

It spawned its own channel via `bunx haltija@^1.6.1` and ignored the project's dependency
entirely — and because **bunx caches the resolution**, a range that resolves forward never
_re_-resolves once its cache key exists. So an adopter who bumped `haltija` to `^1.11.2`
for a fix, restarted, and still didn't have the fix was running a cached 1.11.0: new
enough to look current, old enough to lack it. `hj where` reports the _spawned_ server, so
the version indicator agreed with them.

- Your `node_modules/.bin/haltija` is preferred when present. `HALTIJA_VERSION` still
  overrides everything.
- The fallback floor moves `^1.6.1` → `^1.11.2`. Its practical job is to make bunx
  re-resolve, not to express a minimum — a stale cache key was half the bug.
- **Both spawn sites now print which channel they used and where it came from** —
  `1.11.2 (this project's dependency)`, `haltija@^1.11.2 (bunx)`,
  `haltija@beta (HALTIJA_VERSION)`. The failure was never that a wrong version ran; it
  was that nothing said so.

If you were working around this with `HALTIJA_VERSION`, you can drop it.

## 1.9.3

Two adopter-reported fixes, both with the diagnosis largely done by the reporter.

### The dev server crashed instead of exiting (#47)

A long-lived `bun start` **segfaulted at the 8-hour idle timeout** rather than exiting —
so a workspace left up for a working day went offline silently, and the first sign was a
tunnel link failing. All six exit paths called `server.stop()` immediately before
`process.exit()`; that buys nothing (the OS closes the sockets) and was exactly where it
died. They now just exit.

Note for anyone hitting this elsewhere: **`try/catch` is not a mitigation** — a segfault
is not a catchable exception, so the only workaround is not calling the API. Filed
upstream as [oven-sh/bun#36788](https://github.com/oven-sh/bun/issues/36788).

### ePub volumes are now linkable (#46)

The build wrote an ePub per volume and linked to none of them, so a reader had no route
to a book that existed. The filename is _derived_ (`<project>-<volume>.epub`), so
hand-written links rot silently when a volume is renamed.

Three ways to surface them, and the ePub build now names its output through the same
helper, so a link cannot point at a name nothing wrote:

```text
<!-- epub-downloads -->        drop in any page → a link per volume
/epub-volumes.json             manifest: book, title, filename, url
listEpubVolumes()              exported from tosijs-ui/site
```

`epub.volumeTitles` renames what humans read without moving what published links point
at.

### Also

- **A valid session now trumps a stale invite link** (#45). Clicking an older link while
  already signed in — a second window, a link scrolled back to in chat, a bookmark —
  walled you with "that invite link has been used". The stale token is simply irrelevant
  to someone who already holds a session.
- **`tosijs-tunnel --close` confirms identity before signalling.** It SIGTERMed every
  `pgrep -f` match, and `pgrep -f` matches an argv _substring_ — a shell echoing the
  command or an editor holding a log path both matched. It now checks the process really
  is `ssh`, says what it signalled, and names what it skipped. It also interpolated `host`
  into a REGEX unescaped, so `me@a.b` matched `me@axb` and a host containing `+`, `(` or
  `*` changed the pattern's meaning outright.

### Internals, for anyone reading the source

- One `shutdown(code)` helper replaces the six copy-pasted exit sites. The 1.9.0 review
  had flagged that duplication as a tidiness follow-up; it turned out not to be untidy but
  to be six copies of the crash above — you cannot fix a bug once if it exists six times.
- `resolveLinkArrival()` joins `mayReadSite` / `isLockedDown` as a decision extracted from
  the dev server's request closure, where nothing could test it. Each of these was
  extracted only after shipping a bug in it.

## 1.9.2

**Accessibility: every default color pair now meets WCAG AA.** Found by running
`hj map` (haltija 1.11) over the doc site, which reports a measured contrast ratio per
node — none of these were visible as wrong to the eye.

| pair                      | was                                                | now                         |
| ------------------------- | -------------------------------------------------- | --------------------------- |
| test widget, running      | white on `#fa0` — **1.9:1**                        | `#2b1a00` on `#fa0` — 8.8:1 |
| test widget, passed       | white on `#0a0` — **3.1:1**                        | white on `#008a00` — 4.5:1  |
| `--tosi-accent` as text   | `#EE257B` — **4.05:1** on white, 4.3:1 on the page | `#d92270` — 4.6:1           |
| `--tosi-accent` as a fill | white on `#EE257B` — **4.05:1**                    | white on `#d92270` — 4.8:1  |

**The brand pink is 9% darker** (`#EE257B` → `#d92270`). It is imperceptible side by side
and it was the only value that clears AA in _both_ directions — as text on the page, and
as a fill carrying white text. Override `--tosi-accent` if you want the original.

Two more, found by sweeping wider — both in **example code we publish**, which matters
more than a component default because people copy it: the `.-negative { color: #e44 }` we
document for negative numbers in tables sat at **3.63:1** (now `#d32f2f`, 4.77:1), and
`#007AFF` — the accent in every theming example in our docs — was **4.02:1** under white
text (now `#0064d2`, 5.59:1).

Also fixed: the doc-site brand bar's text was a hand-tuned warm off-white
(`brandColor.rotate(30).brighten(0.9)` → `#fbeae9`) sitting at **4.09:1** on the brand —
under AA for the normal-size nav links in it. Every brighten value short of pure white
also misses, so it now uses `contrasting()`, which picks the readable extreme for whatever
the brand currently is and cannot drift when the brand moves. Three stale `#EE257B` /
`#007AFF` fallbacks were still live in `header.ts` and `doc-browser.ts` and now match.

Verified across 8 pages in both themes on haltija 1.11.2: **0 failures, 0 uncertain**.

**The browser-test lane now runs its own private haltija.** It used to reuse any running
instance and otherwise spawn with `-f`, which reclaims haltija's shared default port and
kills whatever held it. Both halves were wrong: adopting meant inheriting the _desktop
app's_ window, whose visibility depends on whatever else is on screen — and `hj` rightly
refuses to drive a hidden tab, since a backgrounded tab throttles `requestAnimationFrame`
and would produce plausible-but-wrong results. The lane failed roughly two runs in three
with no programmatic way out. It now takes an isolated instance on an ephemeral port:
**4/4 runs green**, and a concurrent haltija belonging to someone else survives untouched
(verified both). Closes #18 and #21.

The test widget's text color is now **paired** with its background rather than hardcoded
to white. Amber is a genuinely light color: darkening it enough for white text turns it
brown and stops reading as "in progress", so it keeps its hue and takes dark text, which
is the conventional warning-badge treatment. New `--tosi-test-text-color-{pass,fail,running}`
variables if you theme these.

In dark mode the brand color is **brightened** rather than reused: `invertLuminance` flips
the neutrals but leaves a saturated hue roughly where it was, so a pink dark enough for a
near-white page landed at 4.4:1 on a near-black one. The hue is the brand; the luminance
belongs to whatever it sits on.

## 1.9.1

**`tosijs-ui` did not import under Node.** Shipped `dist/` used extensionless relative
imports (`from './site-config'`), which bun resolves and Node ESM does not — so a Node
consumer got `Cannot find module` on entry points that had nothing to do with bun. Every
entry point is affected, and this goes back well before 1.8.0.

Everything shipped now uses explicit `.js` specifiers, which Node requires and every
bundler accepts. **If you are on Node, upgrade; 1.9.0 and earlier will not resolve.**

Two runtime requirements are unchanged, and are now documented rather than implied — they
are honest constraints, not bugs, but the failure messages named symptoms rather than
causes:

| entry point                          | runtime                                    |
| ------------------------------------ | ------------------------------------------ |
| `tosijs-ui/site`                     | **bun** — it shells out, builds and spawns |
| `tosijs-ui`, `tosijs-ui/<component>` | a **browser** or a bundler targeting one   |
| `tosijs-ui/icon-svg`                 | anything — deliberately DOM-free           |

`engines` now declares bun, and `doc-site-system.md` has a "Runtimes — what runs where"
section.

**Why four test lanes missed it:** all four run under bun. `test-consumer` packs the real
tarball, installs it and builds from a foreign cwd — but with bun, so it proved the
package works for people exactly like us. It now imports every entry point through **Node**
and asserts the absence of module-resolution errors specifically, separating "packaging is
broken" from "this needs a DOM / needs bun". Verified against the published 1.9.0: all
three checks fail on it.

## 1.9.0

Consumer repairs, remote access, and **authoring: many books from one corpus**.

**Anyone on 1.8.0 should upgrade, whether or not they want the new remote-editing
feature.** 1.8.0's `tosijs-ui/site` entry point does not import at all in a clean
install — `index.js` statically re-exports `devServer`, which imports `chokidar` at top
level, and `chokidar` was declared only in `devDependencies` (#32). 1.8.0 also shipped
5.2 MB / 16 files of doc-site hydrate bundle into every consumer's `dist/` (#31), and
broke the `tosijs-make-icons` bin (#30). All three are fixed here.

### Consumer repairs

- **`tosijs-ui/site` imports in a clean install again** (#32). `chokidar` is now an
  optional peer, imported lazily by the watch path only — a plain `buildSite()` never
  reaches for it.
- **The library package no longer carries the doc-site hydrate bundle** (#31). It built
  into `dist/hydrate/` and shipped to everyone though nothing references it; one adopter's
  package went from 0.62 MB / 398 files to **10.2 MB / 2888 files**. It now builds in a
  temp dir. Caught only by reading `npm pack` output, which is why there is now a lane
  that does exactly that (`bun run test-consumer`).
- **`tosijs-make-icons` no longer strips `fill-rule`** (#30), which rendered holes in
  compound `evenodd` paths solid — a keyboard drawn as one path came out filled. It was
  stripped in **three** places, not one. `fill-rule` is fill _topology_, not colour, and
  takes no part in the tinting the strip exists for. Ships as a bin, so it affects anyone
  generating their own icon data.
- **The shipped bins have shebangs** (#35, #36 — reported independently by both adopters).
  Without one, `node_modules/.bin` shims hand TypeScript to the shell.
- **`/version.json`'s `generator` reports tosijs-ui's version, not the consumer's** (#37).
  It read `package.json` from the _cwd_, so the one field that answers "which tosijs-ui
  built this?" named the wrong package entirely.
- **A failed "save to source" says why** (#34). The server answers 501 with an actionable
  reason and the client discarded it for a generic "Save failed.", so an unconfigured
  server was indistinguishable from a broken one and the edit appeared to vanish.
- **New: `iconSvg(name)` / `iconNames()`** (#33) — raw SVG markup with **no DOM
  required**, for build scripts, ePub passes and server-rendered templates. `defineIcons()`
  could write to the icon map but nothing could read it.

### Remote access — view and edit your dev server from anywhere

`preview.tunnel` exposes the dev server running on your machine at an authenticated public
URL over an SSH reverse tunnel, so you can read and edit real source from a phone or a
borrowed laptop. The box does no compute — it terminates TLS and routes — which is what
lets one small VPS front many projects. Two hostnames make the posture legible:

| host                             | what it is                    | gate            |
| -------------------------------- | ----------------------------- | --------------- |
| `<project>.dev.example.com`      | read-only snapshot, shareable | invite cookie   |
| `<project>.edit.dev.example.com` | live workspace, yours         | session, always |

`tosijs-tunnel --link` prints a single-use link; opening it once exchanges the token for a
durable `HttpOnly; Secure; SameSite=Lax` session cookie and redirects with the token
stripped, so it never lands in history or a `Referer`. `tosijs-deploy` publishes the static
snapshot and self-registers a Caddy fragment, so adding a project edits no shared file.

**Write authorization keys on the LISTENER, never on a peer address or a header.** Tunnel
traffic arrives on a dedicated loopback listener, and anything arriving there needs a valid
session — because a reverse tunnel counterfeits "local" by construction.

### Security

- The tunnelled workspace's **read** gate keyed on `X-Forwarded-*` rather than the
  listener, so any forwarder that omits those headers — `ssh -R` with `GatewayPorts yes`,
  `ngrok tcp`, `socat`, iptables DNAT, nginx `proxy_pass`, HAProxy without
  `option forwardfor` — read the entire uncommitted working tree unauthenticated, while
  `requireToken` promised "nothing at all, not even the page". Now keyed on the listener,
  like every other gate.
- `POST /report` was unauthenticated and reachable through the tunnel — a stranger could
  fabricate `{passed:N, failed:0}` and make the test lane **exit green on a suite that
  never ran**. Now local-only.
- Build-error text (absolute paths from your machine) was injected into every served page
  with no auth check. The label stays public; the detail needs a session.
- `rsync --delete` accepted any absolute path two segments deep — including `/usr/lib` and
  `/etc/caddy`, which it would have _mirrored_, i.e. emptied. Now an allowlist of preview
  roots, and never the root itself: one dropped path segment would otherwise have deleted
  every other project on a shared box, including the Caddy fragments that route them.
- `tunnel.requireToken` defaults to **`true`**. The hostname is not a secret — Let's
  Encrypt publishes every certificate to public CT logs — so the session gate carries the
  weight. See the docs for what the hostname discloses and what to do about it (#38).

### Fixed

- **A 403 could destroy uncommitted work.** An unauthorized read fell through to GitHub
  `main`, so you edited the _published_ file, and the save then handed you a download of
  it — applying which silently reverts your working copy.
- **`?t=` was intercepted on every request and method**, so any dev server answered
  `GET /?t=12345` with "that invite link has been used" instead of the page, and 401'd
  POSTs carrying `t`. (`t` is the classic cache-buster name.)
- **Tunnel ports are derived, not fixed** (#39). The bin fell back to a hard-coded 8788
  while the server used `PORT + 1`; they agreed only when `PORT` was 8787. `remotePort` is
  now derived per project too, so two projects on one host cannot collide. One resolver
  serves both, and `tosijs-tunnel --status` reports the ports it would use.
- **The last-good build protection moved to where the wipe is** — it lived in the dev
  server's watch branch, so `bun run build`, CI, adopters and `bun start`'s own initial
  build had none of it. A red `tsc` no longer discards a freshly generated site either.
- Two dev servers can coexist; the idle-exit timer counts requests again, not just builds;
  "Build failed" is no longer overwritten by test results seconds later.

### ⚠️ `hidden` now actually withholds

**If you have been using `draft: true` or `hidden: true` for working notes or unfinished
chapters, they were public.** `hidden` only removed a doc from the nav and the book — its
full text was still written into `docs.json`, and it still got a pre-rendered page at its
own URL. The code comment claimed "drafts don't ship"; a probe confirmed they did, twice
over.

`hidden: true` now means **not published at all**: absent from `docs.json`, from the
generated pages, from every book, and from `llms.txt`. It is filtered at _extraction_,
because the corpus is the thing that gets published and any filter applied after it is
written is too late.

It is inherited, and a child **cannot** un-hide itself from a hidden parent — accidentally
publishing one chapter of a withheld section is the failure worth preventing.

### Books

A `book` value on a doc selects which volume it binds into. Volumes are discovered from
the corpus, so a second book needs no configuration:

```text
<!--{ "book": "field-guide" }-->              → its own volume
<!--{ "book": ["default", "field-guide"] }--> → bound into both
<!--{ "book": "none" }-->                     → on the site, in no book
```

Like `hidden`, it is inherited down the `parent` chain — you mark a section, not every
leaf — with the **nearest declaration winning outright**, so a chapter can divert to
another volume, join several, or opt out. A list is what gets you shared front matter: a
glossary or licence page bound into every volume from one source file rather than copied
per book. `"none"` anywhere in a list wins, because a contradiction should resolve to
withholding. `"default"` exists as a writable name for the main volume precisely so a list
can include it — without one, an array could only ever express "not the default".

`config.book` (the manifest that curates and orders docs _within_ a volume) and a doc's
`book` metadata (_which_ volume) are different things that share a word; both are
documented.

### Release notes assemble themselves

`tosijs-release-notes` builds a CHANGELOG section from `[tag]` bullets in commit bodies,
so notes are written next to the diff rather than reconstructed from memory at the end —
which is where three false claims in the 1.9.0 notes came from. One bullet per
separately-interesting _thing_, layered on the conventional subject rather than replacing
it: a `type:` prefix can only ever represent one change, and the 1.9.0 review commit did
ten things.

    tosijs-release-notes            # assemble the section
    tosijs-release-notes --check    # gate: nothing since the last tag is unaccounted for

`--check` fails when an annotation is missing from the changelog, and separately reports
any commit whose `[fix]`/`[new]` bullets are contradicted by a **markdown-only diff** —
the shape of the worst 1.9.0 bug, where an auth-gate fix was asserted by a commit whose
diff never touched the file it named. Matching is deliberately loose: rewriting a bullet
into prose is the intent, and demanding a literal match would only train people to paste.

This section was assembled by the tool and then written up by hand, which is the intended
workflow — it generates a skeleton and a coverage gate, never the prose. The gate then
caught two things this very section had dropped, including a bug found by running the tool
over this repo's own 2026-04/05 history: a bullet used as the commit _subject_ swallowed
the entire body as continuation text, so one note came out as three paragraphs of
implementation detail.

### Also

- `tosijs-make-icons` emits clean, stable, formatter-conformant output, and honours your
  prettier config if prettier is resolvable. Generated icon data no longer churns against
  a formatter (and should still be added to `.prettierignore`).
- `resolveBook` → `resolveBooks`, returning a list. New in this release; no migration.

### Late fixes (rc.3 → rc.4)

Three review passes ran against this release. The last two found defects **introduced by
the previous pass's fixes**, which is why these are listed separately rather than folded
in silently.

**⚠️ `deploy/Caddyfile` is now a TEMPLATE — action required if you installed it.** It
shipped in every tarball carrying a real ACME email, a specific preview domain, and a
literal `__PREVIEW_TOKEN__`, while its own header told you to `scp` it to `/etc/caddy`.
Following that gave you a preview host whose entire invite gate was a string published in
a public repo, issuing certificates under someone else's Let's Encrypt account. Set
`PREVIEW_TOKEN`, `ACME_EMAIL` and `PREVIEW_DOMAIN` in `/etc/caddy/preview.env` and
re-install with `deploy:caddy`, which now substitutes all three, **refuses to install if
any placeholder survives**, and validates into a temp file before replacing the live
config rather than after.

- **Every ePub volume shipped an identical `dc:identifier`, `dc:title` and cover.**
  `unique-identifier` is EPUB3's primary key, so importing two volumes into Apple Books
  or Calibre made the second replace the first — you would silently lose a book. Volumes
  now derive their own identity; `epub.volumeTitles` names them properly.
- **An ePub volume matching no documents shipped as a success** — a typo'd book name
  produced a valid ~10KB book with a cover, a nav and no chapters, at exit 0. It now fails
  and lists the volumes that do exist. Relatedly, a corpus where _every_ doc names a
  volume no longer errors on its empty default bucket.
- **An invite link was unredeemable** on a proxied dev server with no `preview.tunnel`
  block: the lock armed off a config section that did not exist while the `?t=` reader,
  correctly gated on it, refused to look. One predicate now, exported and tested.
- **`tosijs-tunnel` ignored `PORT` and `--port`**, targeting one port while the server
  listened on another — the drift #39 was meant to end, reintroduced by the commit that
  claimed to end it.
- **The deploy and tunnel bins printed ✅ over failed remote steps.** `tosijs-deploy` now
  distinguishes _deployed_ from _routed_ and exits non-zero when the files landed but
  nothing serves them; `tosijs-tunnel` marks a public URL that is not routed.
- **Preview roots other than `/srv/preview` registered successfully and routed nothing** —
  the write side was de-hardcoded while the Caddyfile glob and index script still named
  the default. One place decides now, and a mismatch says so. Override with
  `preview.caddySitesDir`.
- **`release-check` cleared an empty range** at exactly the release boundary, because
  `git describe` returns the nearest tag including prereleases. It now baselines on the
  last stable release _this commit descends from_, so a hotfix tagged out of order or a
  maintenance branch cannot mislead it.
- **Publishing now gates on `release-check`.** It was added mid-cycle, and the first thing
  it caught was nine unrecorded annotations including a `[break]` — the `deploy/Caddyfile`
  change above, which would otherwise have shipped with no migration note at all.
- **The release gate hardened against itself.** `release-check` is now baselined on the
  last stable release this commit _descends from_ (`git describe --exclude`), so a hotfix
  tagged after a later release, or a maintenance branch, cannot mislead it — a repo-wide
  tag scan got both wrong. Its coverage matcher also stopped crying wolf: it filtered
  short words out of the annotation but not out of the changelog, so entries that were
  plainly written up got reported as missing.
- **`isLockedDown()` and `hasTunnel()` are exported** from the auth module. The predicate
  existed in three places, one of them recomputed inside its own regression test — so
  reverting the fix left the entire suite green. The test imports the real function now.
- **The browser-test lane keys on haltija's `ready` signal** (haltija ≥ 1.6.1, added in
  response to us filing it): a server that is up but has no connected tab is no longer
  adopted, because `hj navigate` then fails with "no browser reachable". That cost three
  lane runs in a day, each needing a manual `pkill`. The floor also named 1.5.5, a version
  that was never published.
- **New "Host bootstrap" docs**, because the preview host needs setting up once before
  any project can register. Both bins write fragments that `import preview_site` /
  `import tunnel_site` — snippets that only exist once the host has a Caddyfile — so an
  un-bootstrapped box failed `caddy validate` forever, for every project, silently. That
  failure now names the missing prerequisite.
- `epub.volumeTitles` is now accepted by `defineSiteConfig` — it was documented and
  type-rejected. `extractDocs`'s hidden-filtering gained tests at the tier that matters:
  real files, real entry point, verified by mutation.

### For maintainers of adopting projects

`bun run test-consumer` packs the tarball, installs it into a scratch project, and builds
from that project's cwd. Every other lane runs _in this repo, from this repo, with one dev
server_ — and four regressions shipped anyway, each living outside exactly that envelope.
More unit tests would have caught none of them; the gap was context, not depth.

### 1.9.0-rc.2 (prerelease detail)

Fixes everything both consumers hit within minutes of `rc.1`. Three were regressions
introduced by rc.1 itself.

- **The `tosijs-tunnel` / `tosijs-deploy` bins had no shebang** (#35, #36 — reported
  independently by both consumers), so `node_modules/.bin` shims handed them to the
  shell, which ran TypeScript as a shell script. Added to every shipped bin.
- **`/version.json`'s `generator` reported the CONSUMER's version** (#37). rc.1 replaced
  an import of the generated `src/version.ts` (which caused a `bun --watch` rebuild loop)
  with a read of `package.json` — but that resolves against the **cwd**, i.e. the
  adopter's repo. So the one field you consult to answer "which tosijs-ui built this?"
  answered with the wrong package entirely. Now resolved relative to the module.
- **Two dev servers could not coexist.** The tunnel listener bound a fixed port
  regardless of `PORT`, so a second server — or this repo's own Playwright lane beside a
  `bun start` — died with `EADDRINUSE`. It now defaults to `PORT + 1`, and a busy tunnel
  port warns instead of taking the whole server down: it is an optional extra, and
  refusing to start over it turns "another instance is running" into "my dev server is
  broken".
- **New lane: `bun run test-consumer`** — packs the tarball, installs it into a scratch
  project, runs every bin through the `node_modules/.bin` shims, and builds a site from
  that project's cwd. Every other lane runs _in this repo, from this repo, with one dev
  server_, and all four rc.1-era regressions lived outside that envelope. It found a real
  bug on its first run.
- **A failed "save to source" now says why** (#34). The endpoint answers 501 with an
  actionable sentence ("editableSources is not enabled…") and the client threw it away
  for a generic "Save failed.", so an unconfigured server was indistinguishable from a
  broken one and the edit appeared to vanish. The server's reason is surfaced, and every
  failure path now states that the edit is still in the editor.

### 1.9.0-rc.1 (prerelease detail)

Release candidate. Everything the nine-lens review and the first adopters raised is
addressed; no features are pending. Install with `tosijs-ui@rc`.

**Edit and view are now separate hostnames**, which makes the security posture legible
without reading config:

| host                             | what it is                           | gate            |
| -------------------------------- | ------------------------------------ | --------------- |
| `<project>.dev.example.com`      | read-only static preview — shareable | invite cookie   |
| `<project>.edit.dev.example.com` | live editable workspace — yours      | session, always |

- **`tunnel.requireToken` now defaults to `true`.** The old default (read-open) was
  justified by "an expired link should degrade to a readable page when you open a second
  window" — but a second window **shares the session cookie**, so a holder is never
  walled. The wall only appears for someone genuinely unauthenticated, which is the right
  answer for a workspace mirroring an uncommitted tree. Note the hostname is not a
  secret: Let's Encrypt publishes every certificate to public CT logs. Set `false`
  deliberately if you want a live read-only audience.
- **An edit host with no tunnel behind it explains itself** — a 503 saying the workspace
  is offline, with a link to the static preview, instead of a bare 502. It deliberately
  does _not_ fall back to serving the snapshot: you would think you were looking at live
  work when you were not.
- **A spent invite link says so.** The likely culprit is a chat-app link-preview bot,
  whose GET _is_ the first use; previously you got a silent redirect and discovered it
  when a save failed.

**Security fixes** (all found by review or adopters, none reported in the wild):

- `POST /report` was unauthenticated and reachable through the tunnel — a stranger could
  fabricate `{passed:N, failed:0}` and make the test lane **exit green on a suite that
  never ran**. Now local-only.
- Build-error text (absolute paths from your machine) was injected into every served page
  with no auth check. The label stays public; the detail needs a session.
- `rsync --delete` accepted any absolute path two segments deep — including `/usr/lib`
  and `/etc/caddy`, which it would have _mirrored_, i.e. emptied. Now an allowlist of
  preview roots, extracted and tested.

**Adopter-reported fixes** (all from tosijs-3d, all consumer-facing):

- **`tosijs-make-icons` stripped `fill-rule`** (#30), so a compound path using `evenodd`
  for holes — a keyboard drawn as one path — rendered the holes solid. `fill-rule` is
  fill _topology_, not colour, and takes no part in the tinting the strip exists for.
  It turned out to be stripped in **three** places, not one; the `stroked/` branch is the
  one that bit. This ships as a bin, so it affects anyone generating their own icon data.
- **The doc-site hydrate bundle was written into the library `dist/`** (#31) and shipped
  to every consumer, though nothing references it — one adopter's package went from
  0.62 MB / 398 files to **10.2 MB / 2888 files**, caught only by reading `npm pack`
  output. It now builds in a temp dir; tosijs-ui's own package drops 16 files and 5.2 MB
  of uncompressed payload.
- **New: `iconSvg(name)` and `iconNames()`** (#33), from `tosijs-ui/icon-svg` — raw SVG
  markup with **no DOM required**. `defineIcons()` could write to the icon map but
  nothing could read it, and `icons.foo()` returns an `SVGElement`, so a build script,
  ePub pass or server-rendered template had no way to get markup except parsing the
  package's source. Deliberately a separate module: `icons.ts` imports tosijs and throws
  on `HTMLElement` outside a browser, which is the exact context that wanted it.

**Correctness:**

- **A 403 could destroy uncommitted work.** An unauthorized read fell through to GitHub
  `main`, so you edited the _published_ file, and the save then handed you a download of
  it — applying which silently reverts your working copy. 401/403 is now its own case in
  both editors.
- **The last-good build protection moved to where the wipe is.** It lived in the dev
  server's watch branch, so `bun run build`, CI, adopters, and `bun start`'s own initial
  build had none of it. Now in `buildSite`, in a `finally` covering both a throw and a
  `return false`, with exit codes checked (the old restore could fail while still logging
  that it had succeeded).
- "Build failed" is no longer overwritten by test results seconds later.
- `?t=` is only intercepted when a tunnel is configured, and only on GET — `t` is the
  classic cache-buster name, and a 302'd POST loses its body.

### 1.9.0-beta.3 (prerelease detail)

Fixes three blockers found by the nine-lens review of beta.2, all in the remote-editing
feature, plus a rebuild loop the review did not catch because nothing automated runs
`bun start`.

- **`bun run tunnel --link` never worked.** It ran `pgrep -f 'bun bin/dev.ts'`, which does
  not match the documented start command (`bun --watch bin/dev.ts`) — so the headline flow
  was unreachable. Worse, it _did_ match `bun bin/dev.ts --build-only`, whose process exits
  before the signal handler is registered, so the default SIGUSR2 disposition **killed
  in-flight builds**. Now it asks the dev server over a loopback-only `/__devlink` endpoint:
  no process guessing, no signals, and it cannot touch a sibling project. (`/__devlink` is
  refused over the tunnel — a read-only visitor minting a write session would be privilege
  escalation.)
- **Write authorization failed OPEN.** It inferred "this is local" from the _absence_ of
  `X-Forwarded-*`, so any forwarder that doesn't set them — `ssh -R` with `GatewayPorts yes`,
  `ngrok tcp`, `socat`, iptables DNAT, nginx without `forwardfor` — delivered an off-machine
  request as `{peer: 127.0.0.1, no headers}` and got an **arbitrary repo write**, which the
  watcher rebuilds and runs. The dev server now binds a **separate loopback-only port** for
  tunnel traffic; arriving there always requires a session. Which socket you connected to is
  not something a client can forge.
- **The decision guarding that RCE had no tests**, while its own comment claimed a
  regression test that did not exist. It is now `mayWriteSource()` — pure, exported, and
  covered including a named regression case for the fail-open above.
- **The remote-access tooling now ships** as `tosijs-tunnel` and `tosijs-deploy` bins
  (tosijs-ui#27, and independently the review's ecosystem lens). Previously `SiteConfig`
  exposed `preview`/`tunnel` as typed public API while the ~390 lines of
  security-sensitive glue that implement them lived only in this repo — so every adopter
  hand-copied them and owned the drift. They resolve the consumer's site config
  (`--config=`, `TOSIJS_SITE_CONFIG`, then `./tosijs-site.config.ts` / `./site.config.ts`)
  and deliberately do **not** walk up the tree: these commands deploy and expose a
  workspace, so silently adopting a neighbour's config would publish the wrong project.
- **Fix: the tunnel probed the wrong port**, so forwarding to a dead listener failed
  silently with a 502 and a healthy-looking dev server (tosijs-ui#28). `ssh -R` can't
  catch it — the _remote_ bind succeeds, so `ExitOnForwardFailure` stays quiet; it's the
  local end that connects to nothing. It now probes the port it actually forwards to and
  **refuses to open**, naming the likely cause (including "your tosijs-ui predates the
  tunnel listener").
- **Tunnelled workspaces self-register too** (tosijs-ui#29). Static previews have
  self-registered since beta.1, but a tunnelled host still needed a block hand-added to
  the _shared_ Caddyfile — so the one remaining manual edit was the **riskier** one: a
  typo there breaks routing for every project on the box, which is exactly what the
  fragment design set out to abolish. `tosijs-tunnel` now writes its own fragment with
  the same validate-before-reload discipline, and the shared file names no hostnames at
  all.
- **`tunnel.remotePort` is allocated per project** instead of defaulting to 9787 for
  everyone (tosijs-ui#29). Two projects taking the default collided, quietly and in the
  direction that matters. The default is now derived from the project name — stable, and
  nobody has to allocate it — while an explicit `remotePort` still wins.
- **Fix: `chokidar` is no longer a hard runtime dependency of the build** (tosijs-ui#32).
  `dev-server.js` had a top-level `import { watch } from 'chokidar'`, and a consumer's
  `bin/site.ts` loads that module even in `--build` mode — so adopters hit
  `Cannot find package 'chokidar'` on a plain build. It is now imported lazily inside the
  watcher path and declared an **optional peer**, so building needs nothing extra and the
  watching dev server explains itself if it is missing.
- **Fix: `bun start` was in a rebuild loop** (measured at 899 restarts in ~40s), which made
  the documented dev command unusable. The build stamp imported the _generated_
  `src/version.ts`, putting it in `bun --watch`'s module graph while prebuild rewrote it every
  build. The import is gone, and generated files are now written **only when the content
  changes**, which makes the whole class of self-write loops impossible.

### 1.9.0-beta.2 (prerelease detail)

> Prerelease — install with `tosijs-ui@beta`. `latest` stays on 1.8.0.

**Remote editing, authenticated.** `bun run tunnel` exposes this machine's dev server at
a public URL; a single-use invite link is exchanged for a durable session cookie, and
editing through it is indistinguishable from sitting at the keyboard — same dev server,
same watcher, same files.

- **`bun run tunnel`** (`--status` / `--close` / `--link`). An SSH reverse tunnel to your
  preview box. **The box does no compute** — it terminates TLS and checks a credential —
  which is what lets one small VPS serve many projects. The work stays where the data is.
- **Magic-link auth.** Two tokens on purpose: the **link** rides in a URL so it is the
  one that leaks (history, `Referer`, proxy logs, chat-app link previews) and is
  therefore _single-use and 15 minutes_; the **session** never appears in a URL, arrives
  as an `HttpOnly` cookie, and is durable. A durable token pasted into a URL would be
  strictly worse than basic auth. `SameSite=Lax` gives the write endpoint free CSRF
  protection.
- **`tunnel.requireToken`** — default `false`: the workspace renders for anyone with the
  URL but **writes always need a session**, so a stranger gets a read-only page. Set
  `true` to require a session even to view. The default means an expired link degrades
  to a readable page rather than a wall — which is what you hit when you already hold a
  session and open a second window.
- **Security fix: source endpoints are no longer exposed to your LAN.** `Bun.serve` binds
  every interface, so on any shared network an unauthenticated `POST /__docstore/source`
  was **remote code execution** — write a repo file, the watcher rebuilds, the build runs
  it. Now: loopback, or a valid session.
- **Multi-project preview host.** Projects **self-register** — each deploy writes its own
  Caddy fragment, so adding one touches no shared config and needs no DNS change. The
  deploy validates before reloading and refuses on invalid config, so one bad fragment
  can't break routing for everyone. The root serves a **generated index** of what's
  deployed and which commit each is serving.
- **Invite links replace basic-auth dialogs** on the static preview hosts too. Knowingly
  weaker than the workspace's auth (a shared bearer secret in a cookie, not a per-client
  session) — Caddy serving static files has no session store — but equivalent to the
  basicauth it replaces, with no dialog.

### 1.9.0-beta.1 (prerelease detail)

> **Prerelease.** The preview-host system works end to end and is in daily use, but the
> plan it belongs to (`REMOTE-ACCESS-PLAN.md`) has two more phases. Published under the
> `beta` dist-tag — **install with `tosijs-ui@beta`**; `latest` stays on 1.8.0.
>
> It's a **minor**, not a patch, despite being unfinished: it adds public API
> (`SiteConfig.preview`), emits a new artifact into every adopter's output
> (`/version.json`), and changes what happens when a build fails. Semver tracks the
> shape of a change, not how done it feels.

- **New: preview hosting.** `bun run deploy` rsyncs the built site to a box you control,
  so a phone, a client, or a reviewer can see it without your dev server — or your
  laptop — being up. The whole artifact is a few MB, so this is a copy, not a pipeline.
  - **`preview: { host, path?, url? }`** in the site config. Only `host` is required;
    `path` defaults to `/srv/preview/<name>`. Flag > env > config, so a one-off push
    elsewhere doesn't need a config edit.
  - **Dry run by default.** It runs `rsync --delete` (the remote must mirror the build,
    stale pages must not linger), so the destructive form has to be typed: `--go`. It
    also refuses any target that isn't an absolute path at least two levels deep.
  - **Projects self-register.** Each deploy writes its own Caddy fragment; the server
    glob-imports them. Adding a project touches no shared config and needs no DNS
    change. The deploy **validates before reloading** and refuses to reload on invalid
    config, so one bad fragment can't take down every project on the box.
  - `deploy/Caddyfile` and `deploy/build-index.sh` are committed — server config in git
    beats server config in someone's shell history.
- **New: `/version.json`** in every build — `{ generator, site, commit, commitTime }`.
  Nothing exposed which commit produced a given site before. It matters wherever a
  deploy is a snapshot: a reviewer can otherwise report a bug you fixed this morning
  with no way to tell from the page which of you is stale.
  **Deliberately deterministic** — no build timestamp, no dirty flag — so a committed
  output dir doesn't churn on every build. Git fields are omitted (never blank) when
  git isn't available.
- **Fix: a failed rebuild no longer destroys the site.** `buildSite()` opens with
  `rm -rf <outputDir>`, so a build failing after that left nothing to serve — save a
  typo, and the next refresh was a dead site. The dev server now moves the working
  build aside first and restores it on failure, **and says so on the page**: the
  existing floating widget (which already means "the far end has something to tell
  you") shows the failure until a good build clears it, with detail in the console.
  Note it survives the "tests disabled" preference — otherwise the one person who
  turned test indicators off is the one who never learns their build is broken.
- **New: `DEV_NO_WATCH=1`** serves the built site without watching, for automated
  suites that want a server rather than a rebuilder.

## 1.8.0

> **Read this first if you use `tosijs-ui/site`.** This release adds a **dependency-audit
> gate that is ON BY DEFAULT and can fail your build** — on advisories in _your_ dependency
> tree, not ours. That is deliberate, and there are three ways out (below). Minor, not patch,
> precisely because `bun run build` can now exit 1 where it previously didn't.

- **New: dependency-audit gate** (`src/doc-system/site/audit-guard.ts`, exported from
  `tosijs-ui/site`). `bun audit` knows the registry advisory database; nothing in a normal
  build ever asked it, so a high-severity advisory in a transitive dep stayed invisible until
  someone ran it by hand. Now it's asked at the point a human is looking.
  - **Runs synchronously in every mode** — inside `buildSite` for `bun run build` / `--test`,
    and in `devServer` **just before it binds the port** (it throws, so the server never comes
    up). Sub-second in practice; a gate you wait for can't be raced. **Watch rebuilds never
    audit** — that would put a network call in your edit loop and break offline dev.
  - **What blocks:** any advisory at or above `level` (default `high`) that isn't gated.
    Consumer-facing and developer-facing advisories block **alike** — a dev-only dependency
    still runs on your machine, and the escape hatch makes over-blocking cheap.
  - **Three ways out:** `audit: { allow: [{ advisory, reason, expires }] }` to accept a risk
    **on a deadline** (an expired or malformed gate stops suppressing, so it comes back for
    review instead of living in an allowlist nobody re-reads); `audit: { mode: 'warn' }` to
    report without failing; `audit: false` or `TOSIJS_AUDIT=off` to disable.
  - **Fails open, never closed, when it can't check** — offline, registry down, `bun` too old,
    or past its 20s timeout: it warns and proceeds. It fails **closed** only on a real finding.
  - **Not downgraded in CI.** Unlike the machine-health preflight (a heuristic about someone's
    local box), an advisory is deterministic and environment-independent.
  - **Findings are grouped, sorted worst-first, and annotated** with the _nature_ of the risk
    (`LEAK/ALTER` / `DoS-only` / `DoS?+ESCALATABLE` / `UNCLASSIFIED`), parsed from the CVSS
    vector (3.x and 4.0) plus CWEs. Annotation only — it never changes what blocks, and it
    fails **closed**: ~20% of real advisories carry no CVSS vector at all, and those skew
    severe. Sub-threshold advisories are listed compactly (previously collected and never
    shown), plus an **advisories-per-package tally** — a dependency that keeps producing them
    is a code smell worth replacing rather than patching.
- **Fix (issue #24, reported by a consumer): nav `order` now sorts numerically.** Section nav
  and the generated `<!-- toc -->` built a zero-padded _string_ key and compared it lexically,
  so a fractional order silently sorted to the **end** of its section — `order: 1.5` landed
  after `2`, `3`, … because `"01.5" > "0002"` as text. "Insert a page at N.5" reads like a
  supported idiom and did the opposite with no warning. The same padding capped `order` at 4
  digits (10000+ overflowed the width) and mis-sorted negatives. `pinnedSort` is now a
  multi-key comparator (pin bucket → numeric order → title → filename), which fixes all three.
  **Removed `navSortKey`** (the string-key builder) — it was never in a public index, but was
  reachable via the `./*` subpath, so: technically breaking. A string key cannot express this
  ordering, so there is no corrected version of it.
- **`src/doc-system/nav-tree.ts` was a binary file.** The old sort key joined title and
  filename with a raw NUL byte, which makes macOS `grep` silently match **nothing** in that
  file (exit 1, no message) — so searching it for any symbol came back empty and the sort bug
  above was effectively unfindable. The comparator needs no delimiter; the NUL is gone.
  - **Adopting the gate — budget for one upgrade, not one pin.** From the first real
    adoption (`tosijs`, which went from 12 blocking advisories to zero): if the gate flags
    **`brace-expansion`**, note that `GHSA-mh99-v99m-4gvg` marks _every_ version below
    **5.0.8** affected, so the override is mandatory — and `brace-expansion@5` breaks
    **eslint 8**'s bundled `minimatch@3` with `expand is not a function`. In practice that
    means **an eslint 8 → 10 migration** (flat config + typescript-eslint 8), not a one-line
    pin. The upside is that it clears the whole `minimatch`/`js-yaml`/`flatted` cluster at
    once — which is exactly what the advisories-per-package tally is telling you when it
    fingers a stale toolchain. Expect "replace, don't patch" to be the right answer more
    often than a targeted override.
- **New: `openBrowser`** site-config option — opens the doc site on dev-server start, reusing
  an existing tab on macOS rather than piling up new ones.
- **New: `DEV_NO_WATCH=1`** serves the built site without watching. The E2E lane used to run
  `bun start`, so a file watcher stayed live for the whole suite and could `rm -rf docs/` and
  regenerate underneath a test mid-navigation — the lane flaked against its own build system,
  which presents as a product bug.
- **New public API on `tosijs-ui/site`:** `auditDependencies`, `reportAudit`,
  `resolveAuditMode`, `classifyRisk`, `groupAdvisories`, `openBrowser`, plus their types.
  `buildSite` takes an optional second argument (`{ skipAudit }`).
- **Fix: `bun run test-browser` no longer reports success on a failed build.** It computed the
  build result and launched the test run regardless, so a failing build (or a blocking
  advisory) still went green. One-shot modes now exit 1.
- **Fix: the E2E lane can register ServiceWorkers over the dev cert.** `ignoreHTTPSErrors` is a
  browser-_context_ option and never covered a ServiceWorker's own script fetch, so the
  import-resolver worker failed to register and `hydration.pw.ts` caught it as a console error.
  Chromium now launches with `--ignore-certificate-errors`.
- **Dependency pins:** `overrides` for `flatted` (`>=3.4.2`) and `brace-expansion` (`>=5.0.8`)
  clear live high-severity advisories. Added `.github/dependabot.yml` so advisories published
  later against an unchanged lockfile still surface.
- Includes the unpublished 1.7.5 (header-logo sizing) — npm goes 1.7.4 → 1.8.0.

## 1.7.5

- **Header logo size/spacing are now CSS-tunable** (follow-up to 1.7.4). The brand mark no
  longer hard-wires a pixel height inline; it carries a `.logo-mark` class driven by two
  variables — `--tosi-logo-mark-size` (default **32px**, was a fixed 40px) and
  `--tosi-logo-mark-gap` (default 10px) — for icon, `<img>`, and inline-`<svg>` logos alike.
  (Icon `<svg>`s take their height from `--tosi-icon-size`, set inline by `makeIcon()`, so the
  class points that knob at the same variable rather than fighting the inline height.) Retune a
  logo from CSS instead of editing the build.

## 1.7.4

- **Configurable header logo.** A new `logo` option lets a doc site set the brand mark shown
  left of the site title, instead of always inheriting the tosijs-ui logo. Accepts the name of a
  known icon (from `tosijs-ui`'s `icons`, e.g. `'tosiUi'`), an image URL / path / `data:` URI
  (rendered as an `<img>`), or a raw inline `<svg>…</svg>` string. Threaded through
  `SiteConfig.logo` (`tosijs-ui/site`), `createDocBrowser({ logo })`, and an embedded
  `<tosi-doc-system config='{"logo":"…"}'>`. Omit it to keep the prior behavior: the tosijs-ui
  logo when `projectLinks.tosijs` is set, otherwise no mark. Previously the header logo was
  hard-wired to the tosijs-ui mark — the one branding element `SiteConfig` couldn't override.

## 1.7.3

- **New `<tosi-pocket-bar>` component.** A "pocket buttonbar": a single translucent icon at
  rest that expands into a bar of slotted icon-button controls on hover/tap, then collapses
  again. `direction` (the full `FloatPosition` vocabulary, `auto` by default) determines both
  layout and growth direction; the handle stays put and toggles the bar; touch counts as hover.
  Built as a thin wrapper around `positionFloat`.
- **The live-example toolbar is now a pocket bar**, pinned to the top-right of the preview (so
  it never sits over the code editors in side-by-side mode). The collapsed handle carries the
  example's pass/fail color, "run tests" is a checkbox that greys when off, and the bar stays
  visible when the example is maximized.
- **Build system is fragility-tolerant.** An _unsupported import_ in a live example (a bare
  specifier the runtime can't resolve) now downgrades that block to display-only with a warning
  instead of failing the whole build; genuine syntax errors still fail the build.
- **Removed a render-skip anti-pattern** from `<tosi-segmented>`, `<tosi-color>`, and
  `<tosi-form>`. They previously depended on `render()` being _skipped_ after interaction via a
  `valueChanged` flag — fragile timing that broke under tosijs's parts changes. Renders are now
  idempotent: they skip only _provably-redundant_ writes by comparing values (colors compared
  **parsed**, so a `#rrggbb ↔ rgba()` round-trip no longer clobbers the caret/selection in the
  field being edited).
- **Code editor background is now a neutral off-white** (off-black in dark mode) instead of a
  brand-tinted color — both the default `<tosi-code>` and live examples.
- **Fix: nav flickered on refresh / reflowed on hydration.** The static no-JS nav relied on the
  browser's native `<details>` collapse (`::details-content { content-visibility: hidden }`),
  which Chromium applies lazily and non-deterministically on first paint — a closed section
  could render at full height for a frame before snapping shut. Closed sections now collapse
  synchronously via `display: none` (still opening on a no-JS `<summary>` toggle). Enforced by
  `tests/hydration.pw.ts`.
- **Moved the doc-browser test-counter widget to the bottom-left** so it no longer sits under
  the haltija dev overlay.
- **tosijs bumped to `^1.7.8`**, which fixes the `this.parts` proxy crossing into nested
  component instances (tosijs#20 — surfaced by the self-hosting doc demo, where clicking "edit"
  on one example opened the editor in the wrong one) and change-handler value staleness
  (tosijs#21).

## 1.7.2

- **Fix: doc sites are now mount-point-agnostic** (issue #25). The build baked `basePath` into
  every functional URL (nav/content links, `scriptUrl`, `stylesUrl`, favicon, `docsUrl`), so a
  build was locked to one mount — adding a custom domain to a GitHub project page flipped the
  live site to the root while `docs/` still pointed at `/repo`, 404ing every asset into a bare,
  unstyled shell with no warning. Functional URLs are now emitted **relative to each page**, so
  one build works at `/repo`, a custom-domain root, or a moved mount with no rebuild. _Metadata_
  URLs (`canonical`, `og:url`, `og:image`, `sitemap.xml`) stay absolute via `baseUrl`+`basePath`
  — so `basePath` now only affects metadata, never the page's assets. (The hydrated SPA's own
  nav/`pushState` and `__TJS_LOCAL_BASE` remain root-relative — tracked in #16.)
- **Fix: the haltija dev channel loaded once per background-test iframe.** The doc-browser's
  background test runner loads every page-with-tests in a hidden iframe, each served the injected
  dev-channel loader — so `dev.js` was imported N times into throwaway frames. The loader is now
  gated to the top window (`self===top`).
- **Live examples get a rounded, padded card.** `.preview` gains `padding` (breathing room around
  rendered content) and its first child's top margin is dropped; the inset border moves to the
  example's `:host` with a `border-radius`, so the whole example (preview + editors) reads as one
  bordered card. CodeMirror tooltips render fixed-positioned so autocomplete isn't clipped by the
  card's `overflow: hidden`.
- **Removed the "blueprint loading" doc page.** It documented tosijs's own
  `<xin-loader>`/`<xin-blueprint>` elements (no tosijs-ui component), so it belongs in tosijs's
  docs. The generic `blueprint` icon stays.

## 1.7.1

- **Fix: components looked wrong in the doc site's dark mode** (e.g. `<tosi-table>` stayed
  white). The doc-system theme drives the legacy `--background`/`--text-color` family and never
  set the `--tosi-*` component palette, so components fell back to their baked-in light defaults
  (`var(--tosi-bg, #fff)`). The doc-system now bridges `--tosi-accent`/`--tosi-bg`/`--tosi-text`/
  `--tosi-bg-inset` onto its own colors **as references**, so every `--tosi-*` component follows
  the site theme automatically — including dark mode (the referenced vars flip via
  `invertLuminance`). The data-table pinned-row example now uses `var(--tosi-table-bg)` instead of
  a hardwired `#eee`.
- **Data-table `type` example** demonstrating the new column types (currency, number, percent,
  fixed, bytes, boolean) added to the docs.

- **Fix: "edit page source" showed HTML instead of source in a dev server without
  `editableSources`.** The `/__docstore/source` endpoint was only routed when
  `editableSources` was on; otherwise the request fell through to the SPA `index.html`
  fallback and returned the rendered page at status 200 — so the client loaded HTML _as_ the
  source (and save read HTML). The endpoint is now handled unconditionally and answers a clean
  `501` when editing is disabled, so `loadSource` falls back to the GitHub raw source (read-only
  editing, matching the deployed site). Belt-and-suspenders: the client also rejects any
  `text/html` response from that endpoint (guards SPA-rewrite hosts too). Documented the option
  more fully in `doc-site-system.md`.
- **Local example save now gives feedback.** "Save changes (local)" writes a per-browser
  scratchpad (not the file) — it used to do so silently, reading as a no-op. It now posts a
  toast (`postNotification`): _"Saved to this browser only — use 'Save to source' to write the
  file."_

- **`valueRenderer(type)` + declarative `<tosi-table>` column `type`.** A new exported helper
  turns a compact type string into a reusable, locale-aware renderer with a sensible default
  alignment: `number`, `currency`/`currency(USD)`, `fixed`/`fixed(n)` (default 2),
  `percent`/`percent(n)` (of a fraction; default 0), `sci`, `eng`, `bytes`/`bytes(iec)` (SI ÷1000
  or IEC ÷1024 units, right-aligned), and `boolean`/`boolean(t)`/`boolean(t,f)` (icons via the
  `icons` proxy, centered; default `checkSquare`/`square`). Numeric cells also get a
  `-negative` / `-zero` **state class** by value sign, so you get red negatives (etc.) from one
  CSS rule — no custom cell renderer. Data-table columns take a `type`
  (`ValueRendererType`) and format + align automatically — no hand-rolled `dataCell`; an explicit
  `align`/`dataCell` still wins. Formatting follows `setLocale()`. Verified end-to-end (unit +
  browser).

- **Deprecation cleanup: `on<Event>` component callbacks → `handle<Event>`.** tosijs reserves the
  `on<Event>` prefix for elements-factory event-handler sugar and now warns when a component
  _defines_ such a property. Renamed the internal ones: `onResize` → `handleResize` (`size-break`,
  `side-nav`, `code-editor`, `babylon-3d`, `tab-selector`) and `onScrollEnd` → `handleScrollEnd`
  (`data-table`). Verified: those pages now load with zero `on<Event>` deprecation warnings.
  (`<tosi-tabs>`'s public `onCloseTab` still uses the old name — a breaking rename deferred to a
  deprecation-alias pass; and the `tosiValue()`/`tosiPath()` accessor deprecation is a separate,
  larger cleanup — both tracked.)

- **CSS canary** — an inline doc-test on the `live-theme` page that smoke-tests the whole
  styling chain in a real browser (`StyleSheet()` → the `vars` proxy → scaled-var `calc()` →
  `createTheme`/`applyTheme` → cascade → `getComputedStyle`), plus a theme color-change
  propagating to computed style, dark-mode luminance inversion, and `Color.inverseLuminance`.
  Each link is silent when it breaks (happy-dom can't resolve `var()`/the cascade), so a red
  now means a system-level CSS break, not a component quirk. Runs in the `doc-tests.pw.ts` CI
  gate.

## 1.7.0

> ### ⚠️ BREAKING CHANGE — `<tosi-code>` moved from ACE to CodeMirror 6
>
> This is a **breaking change shipping under a minor version**, deliberately. `<tosi-code>`
> (and the `<tosi-code>`-backed code panels in live examples / the doc browser) is now a
> CodeMirror 6 wrapper instead of ACE. The public contract that **survives** is unchanged:
> `value`, `mode`, the `change` event, `disabled`, and `undo`/`redo`. What is **removed**:
> the ACE-era **`theme`** and **`options`** props — there is no compatibility shim for them
> (CodeMirror's theming model is different in kind, so a shim would silently no-op). If you set
> either, migrate: dark mode is now automatic (driven by `body.darkmode`), and editor styling
> comes from the `--code-bg` / `--text-color` CSS variables. The `editor` property now exposes
> a CodeMirror `EditorView` rather than an ACE editor.
>
> **Why a minor, not 2.0.0:** the `2.0` name is reserved for the tjs-native tosijs port that
> follows tosijs 2.0; spending it on an editor swap now would missignal that larger change.
> The deviation from strict semver is intentional and called out here + in the README. **To
> defer the change, pin `tosijs-ui@1.6`.** Everything else in 1.7.0 is additive.

The headline: the editor became first-class for **tjs** and **WebAssembly**, and doc pages got
dramatically lighter — a reader loads a page with **neither the tjs transpiler nor CodeMirror on
first paint**, both arriving only when a code panel is opened to edit.

### CodeMirror 6 editor (replaces ACE)

`<tosi-code>` is a CodeMirror 6 wrapper. The heavy CM code (`code-editor-cm.ts`) is a **lazy
chunk** loaded on first edit — for ESM/bundler consumers a page with no editor bundles none of
it. (The IIFE build cannot code-split, so the CDN `<script>` path still inlines it.) Modes:
`javascript`, `typescript`, `tjs`, `ajs`, `css`, `html`, `markdown`. New: `undo()`/`redo()`/
`canUndo()`/`canRedo()`, `showDiff(on)` (diffs against a captured baseline via `tosi-diff`), and
automatic dark mode via a `highlight` compartment + a `body.darkmode` observer.

### First-class tjs + inline WebAssembly in live examples

`tjs`/`ajs` example blocks get tjs-lang's CodeMirror language + **runtime-value autocomplete**
(completion resolves the live values of an example's locals). Inline `wasm{}` examples run — the
WASM kernel actually compiles, guarded in CI so a silent fall-back to JS is caught. tjs-lang
bumped **0.9.0 → 0.12.0** across the release (memory-storm fix in 0.10.1; import-resolver in
0.11.0; VM security-review fixes in 0.12.0).

### Self-contained, transpiler-free example pages

Each `tjs` example is transpiled **at build time** and its JS baked into the page as a hidden,
non-executing `<script type="application/tosi-transpiled">`; the example runs from that bake, so
no transpiler loads for a reader. The bakes ship per-doc in `docs.json` too, so client-side
navigation gets them at zero extra first-paint cost. Editing an example drops the bake and loads
the real transpiler + editor on demand; saving keeps the transpiled JS.

### Live examples can import real npm packages (experimental)

With `importResolver` enabled, a live example can `import` any npm package — resolved through a
same-origin service worker (tjs-lang's import-resolver). Three execution modes are flaggable on
the fence: `inline` (default), `iframe` (DOM/CSS isolation), `ide` (recognized; iframe path for
now). Fence grammar is order-free: ` ```ts:ide#demo ` and ` ```ts#demo:ide ` both parse.

### Doc-system & build

Pre-rendered, hydrating doc pages (the chrome renders server-side and hydrates in place — no
opacity gate on first paint). The hydration bundle ESM-splits CodeMirror off the critical path.
Machine-safety guards for the long-lived dev server: an RSS ceiling, an 8-hour idle exit, and a
preflight that refuses to build on an already-dying machine (Bun's `Bun.build()` native leak,
oven-sh/bun#34053, is worked around by shelling out to the `bun build` CLI). A live
`<tosi-css-var-editor>` on component pages. Nested `<tosi-doc-system>` instances no longer share
state (each gets its own observable registry key).

### Peers

`tosijs` peer floor `^1.7.0` — the 1.7 line is a co-released, lockstep stack, and this is the
version tosijs-ui is built, tested, and (in the iife) bundled against; we don't verify against
older tosijs, so the declared floor matches what's actually tested. `tjs-lang` `^0.12.0` — a lazy, optional
peer (a plain component consumer never pulls it in). `@codemirror/*` are the only hard runtime
dependencies (a deliberate divergence from the zero-runtime-dep rule; they must share one
`@codemirror/state` instance).

---

_The beta changelogs below are retained for detail; 1.7.0 consolidates betas 1–5 + rc.1. Built on tosijs 1.7.0._

## 1.7.0-beta.3

**Self-contained examples, and CodeMirror + the tjs transpiler are now edit-time only.** A
reader loading a doc page — directly or via client-side navigation — runs every example with
**neither the tjs transpiler nor CodeMirror on first paint**. Both load only when you open a
code panel to edit.

Still a beta under the `beta` tag (`latest` stays 1.6.x): `npm i tosijs-ui@beta`.

### Examples run without the transpiler

Each `tjs` example is transpiled at build time and its JavaScript is baked into the page as a
hidden, non-executing `<script type="application/tosi-transpiled">`; the example runs from that
bake. Plain `js` examples need no transpiler at all (`loadTransform('js')` is now identity). The
bakes also ship per-doc in `docs.json`, so client-side navigation gets them too — at **zero
added bytes for prose/book sites** (only docs with code examples carry bakes). Editing an
example drops the stale bake and transpiles on demand; a saved local edit keeps its own
transpiled code so it reloads without the transpiler.

### CodeMirror panels build lazily

`<tosi-example>` no longer constructs its `<tosi-code>` panels up front (this delivers the
beta.2 "Remaining" note above): the panel — and the CodeMirror chunk — is built on first
`showCode`. A page with examples now ships zero CodeMirror until a reader opens a panel.

### tjs-lang 0.9.1 → 0.10.1

Bumped the transpiler, **skipping 0.10.0** (it triggered a memory storm rooted in a bun bug;
0.10.1 carries the fix). 0.10.x closed four upstream issues, letting this release **delete ~272
lines** of hand-rolled scope-scanning and a hand-declared autocomplete-config type in favor of
tjs-lang's own exports. The inline-WASM guard was updated for 0.10.x's renamed compiled export.

- `tjs-lang` peer: `^0.9.1` → `^0.10.1` (and the `TJS_VERSION` CDN-fallback pin, in lockstep).

## 1.7.0-beta.2

The code editor moved from **ACE to [CodeMirror 6](https://codemirror.net/)**, `tjs`
became a first-class editing mode with runtime-value autocomplete, the doc-site builder
gained the hooks that unblock the tosijs 2.0 TJS port — and generated doc pages are now
**readable before any JavaScript runs**.

**A beta, published under npm's `beta` tag** — `latest` stays on 1.6.x. A prerelease is not
matched by `^1.6.x` (or by `^1.7.0`), so nobody is auto-upgraded into the editor swap; you get
this only by asking for it:

```
npm i tosijs-ui@beta        # or tosijs-ui@1.7.0-beta.2
```

### Doc pages no longer wait for the bundle

Generated pages used to hide the body (`opacity: 0`, with a 4s safety-net timeout) until
hydration, because hydration injected the whole page chrome and the reflow would have been
ugly. **That gate is gone.** The chrome is pre-rendered, so the page is styled, readable and
navigable — real `<a>` links, real headings — before a byte of JS executes, and hydration is
purely additive: nothing moves.

Measured on the built site, gzipped, CPU-throttled — blank-screen duration:

| device                     | before                           | after      |
| -------------------------- | -------------------------------- | ---------- |
| cheap phone / Pi4, slow 4G | 4532ms                           | **1635ms** |
| cheap phone / Pi4, 3G      | 4837ms — _hit the 4s safety net_ | **1635ms** |

The bundle now gates **editing**, not **reading** — which was the point.

Two regressions against that promise were caught in review and fixed here: the nav was styled
by two hand-copied rule sets that had drifted (so it reflowed ~4px per row on hydration), and
the page `<title>` was derived twice (so the home page flipped from its real title to
`tosijs-ui — tosijs-ui`, [#6](https://github.com/tonioloewald/tosijs-ui/issues/6)). Both are
now single, shared rules, and `tests/hydration.pw.ts` asserts that a no-JS page and a hydrated
page agree — geometry, styling and title.

### Doc pages no longer ship CodeMirror to readers who don't open an editor

`dist/iife.js` can't code-split (bun's IIFE format), so `<tosi-code>`'s lazy `import()` is
flattened in — CodeMirror + lezer + acorn on every page whether or not it has an editor
(121KB → 388KB gzip). The generated **doc pages now load an ESM `--splitting` hydration bundle**
(`<script type="module">`) instead: the always-loaded entry is **~123KB gzip** and CodeMirror
rides a lazy chunk pulled only when an editor mounts. The tjs CM extension stays bundled, and
splitting preserves the shared single `@codemirror/state`. **`dist/iife.js` is unchanged and
still shipped for the CDN `<script>` path.** So a **pure-docs / book site with no code
examples now ships zero CodeMirror** — the case that was hurt worst.

Remaining (tracked in `TODO.md`): a page that DOES have live examples still eager-loads the
editor chunk, because `<tosi-example>` builds its code panels up front even while hidden. The
next step defers that construction until the reader opens a panel; the example still runs (the
preview and inline tests don't need the editors) — only the code view waits. **(Delivered in
1.7.0-beta.3 — see below.)**

### Dev-server safety (also in 1.6.23)

A leaking dev server took a machine down twice. The guards, all in `tosijs-ui/site`:

- **Machine-health preflight** before every build and dev-server launch: refuses to add load to
  a machine that is already dying, and names the offending PIDs, sizes, ages and project dirs.
  `preflight: 'fail' | 'warn' | false` in the site config; `DEV_SKIP_PREFLIGHT=1` to skip. Warns
  rather than refuses in CI.
- **`idleTimeoutHours` (default 8)** — ⚠️ **a behavior change**: your dev server now exits after
  8 idle hours (no request, no rebuild). A forgotten dev server is not inert — it is a days-old
  process still running the code it loaded at launch. Set `idleTimeoutHours: 0` to disable.
- **RSS ceiling** (`memoryLimitMb`, default 4096) sampled every 60s, not just after a rebuild.
- **Rebuild-loop detector** — a build that writes a file it also watches now stops, and names
  the file, instead of spawning a bundler forever.
- The example check, the ePub build, and the bundle gzip all moved into **child processes**;
  `Bun.build()` strands ~30MB of native arena per call ([oven-sh/bun#34053](https://github.com/oven-sh/bun/issues/34053), still open).
- **`killStrayServer` no longer `kill -9`s every process _connected to_ the dev port** — it used
  `lsof -ti:PORT`, which matches clients as well as the listener, so it could kill your browser.
- haltija is spawned as a **pinned range** (`haltija@^1.4.0`, override with `HALTIJA_VERSION`),
  not a floating `@latest`, and its teardown kills only its own process tree. The 1.4.0 floor
  is deliberate: it is the first haltija that routes `hj` by working directory, never overwrites
  a newer machine-wide `hj`, and exits non-zero on a failed command — so a project's dev server
  drives its own browser and can't silently downgrade another project's CLI.

### Breaking

`<tosi-code>` (ACE → CodeMirror 6). Each removed member now **warns once and no-ops**
rather than failing silently, but they are gone:

| removed                           | replacement                                        |
| --------------------------------- | -------------------------------------------------- |
| `theme` attribute                 | style the editor with `--code-bg` / `--text-color` |
| `options` property (ACE-shaped)   | configure via `editor` (a CodeMirror `EditorView`) |
| `ace` getter                      | there is no ACE global; use `editor`               |
| `editor.session.getUndoManager()` | `undo()` / `redo()` / `canUndo()` / `canRedo()`    |

**`editor` changed type in place** — it was an ACE `Editor`, it is now a CodeMirror
[`EditorView`](https://codemirror.net/docs/ref/#view.EditorView). A grep for the removed
names will not catch this, and the warn-once shims above **cannot** catch it either: the
property still exists and still returns an object; it is simply a different object. So
**`editor` now warns once on first access**, naming what moved — one line in the console
beats a `TypeError` from inside a library you have never opened. TypeScript users get a
compile error instead (1.6 typed it `any`; 1.7 types it `EditorView | undefined`).

**`change` now means the _user_ changed it.** The event is new in 1.7, and it fires only
on user edits — a programmatic `el.value = doc` (loading a document) does not fire it, and
neither does writing into a `disabled` editor. Without that, every app that populates an
editor would record a spurious save/dirty-flag on open. Programmatic sets are also not
undoable: loading a document is not an edit to Ctrl+Z back out of.

Unchanged: `value`, `original` / `showDiff()`, `mode`, `disabled`.

**tosijs floor is now `^1.6.9`.** 1.6.9 fixes the `parts` proxy so a pre-hydration access no
longer poisons it, and adds public `hydrated` / `whenHydrated` seams
([tosijs#13](https://github.com/tonioloewald/tosijs/issues/13)). Two internal hand-rolled
hydration guards (`<tosi-code>`, `<tosi-example>`) were deleted in favor of the official
`this.hydrated` — so any component that reaches into `parts` from a getter is safe against the
old "read it once and it's bricked forever" trap.

**Semver stance (deliberate, not an oversight).** This library breaks in minors before 2.0 —
**`2.0` is reserved for the tjs-native rewrite**, not for this. So `^1.6.x` resolves `1.7.0` and
existing consumers pick this up on their next install.

**If you use `<tosi-code>`, pin `~1.6` and upgrade deliberately.** Everything else in the library
is untouched, so a consumer of (say) `<tosi-rating>` can take 1.7 without changes — but note it
now installs 12 `@codemirror/*` runtime dependencies where the library previously had none.

## 1.6.23

**Dev-server safety.** Everything under "Dev-server safety" above, backported — this is the
release every `tosijs-ui/site` consumer wants, and it needs no code changes.

The one to know about: **`killStrayServer` was `kill -9`ing every process _connected to_ the
dev port, not just the listener.** `lsof -i:PORT` matches sockets whose local _or remote_ port
is `PORT`, so `bun start` could SIGKILL the browser reading your page, Playwright's browsers,
or an editor's language server. Now it signals only the listening process, only if it is a JS
runtime, SIGTERM first.

⚠️ **Behavior change:** the dev server now exits after **8 idle hours** (`idleTimeoutHours: 0`
to disable), and refuses to start on a machine that is already out of memory
(`DEV_SKIP_PREFLIGHT=1`, or `preflight: 'warn' | false`).

The trap to know about: **`editor` changed type in place** and no shim can catch it. The removed
`theme` / `options` / `ace` members warn once and no-op, but `el.editor` still exists — it is
simply a CodeMirror `EditorView` now, so `el.editor.session.*` is a runtime `TypeError`. A grep
for the removed names will not find it.

### Added

- `<tosi-code>` gained a `change` event (`event.detail.value`), `undo()` / `redo()` /
  `canUndo()` / `canRedo()`, and `tjs` / `ajs` modes.
- **Runtime-value autocomplete** in `tjs` mode: set `tjsAutocomplete` and completion
  suggests the _real members of live values_ — including tosijs proxy members that no
  static analysis can see. Live examples wire their own executed scope into it, so
  `const { app } = tosi(…)` gives real `app.` / `app.items.` completions.
- Inline **WebAssembly** live examples (tjs `wasm {}` blocks).
- `buildSite`: `libraryBuild` and `generateCssPreload` hooks, for projects whose
  library sources are native `.tjs` (this is what the tosijs 2.0 TJS port needs).
  **These actually shipped in 1.6.21** — they are listed here only because they landed on this
  branch first. Nothing needs 1.7 to use them.

### Changed

- **`dist/iife.js` is ~384KB gzipped, up from ~120KB.** Bun's IIFE format cannot
  code-split, so CodeMirror is inlined there. (Under a bundler/ESM it stays a lazy
  chunk — a page with no `<tosi-code>` doesn't load it.) This is deliberate: the
  in-page editor and its save-to-source flow are the point of the doc-system, so the
  IIFE carries the editor.
- The library now has runtime `dependencies` (12 `@codemirror/*` packages) where it
  previously had none. They can't be optional peers: the tjs CodeMirror extension must
  share this exact `@codemirror/state` instance or it silently no-ops.
- `tjs-lang` peer: `^0.8.7` → `^0.9.1`.

### Fixed

- `showDiff()` rendered nothing. Giving `<tosi-code>` its own `content` displaced the
  default `<slot>` that used to project the light-DOM diff overlay, so the overlay was
  invisible (0×0) while `showingDiff` still reported `true`. It now renders inside the
  shadow root. This is the review step of the doc-system's edit-and-save-to-source
  flow, so a blank diff meant saving changes you never saw.

## 1.6.22

### Fixed

- **The dev server no longer leaks memory on every rebuild — update as a priority if
  you use `tosijs-ui/site`.** `buildSite()` called `Bun.build()` in-process, and Bun's
  bundler never returns its native arena: RSS grows monotonically per call with no
  plateau (40 sequential builds of one entry = **+367MB**, still climbing ~5MB/build at
  the end), while the JS heap stays flat — so it is invisible to `Bun.gc()` and to any
  heap profiler. `devServer()` calls it once per rebuild in a process that runs for
  days, so it compounds: a ~2-day watch session reached **136GB RSS** and took the
  machine down with it. Filed upstream as
  [oven-sh/bun#34053](https://github.com/oven-sh/bun/issues/34053).

  The bundle now builds in a **child process** (`bun build`), whose memory the OS
  reclaims on exit — the same 15 bundles leave the parent **+0.5MB** instead of +192MB.
  `buildEpub()` moved to a child too (happy-dom + `@resvg/resvg-js` are both native and
  retaining, and it runs on every rebuild), with a 120s timeout kill so a hung or failed
  ePub warns instead of wedging the rebuild. Measured on this repo's own dev server:
  **baseline RSS 503MB → 150MB, and per-rebuild growth 26–59MB → ~2.7MB.**

  Bundle output is byte-for-byte identical; this is purely where the work runs.

### Added

- **Dev-server memory watchdog.** `devServer()` now samples RSS after each rebuild and,
  past a ceiling (`memoryLimitMb` in the site config, or `DEV_MEMORY_LIMIT_MB`; default
  4096), prints the growth-per-rebuild and exits rather than let a leak thrash the
  machine. It distinguishes real growth (a leak — report it) from a build whose baseline
  simply exceeds the ceiling (raise the ceiling).

## 1.6.21

### Fixed

- Doc-system nav-toggle in `routing: 'memory'` mode drove the _outer_ doc-browser
  instance instead of its own (now scoped per-instance).

### Added

- `buildSite` `libraryBuild` + `generateCssPreload` hooks (also in 1.7.0 above), so a
  project whose library source is native `.tjs` can build through the doc-site pipeline.

### Changed

- `tjs-lang` peer: `^0.8.7` → `^0.9.0`.

## 1.6.20

### Fixed

- **`import 'tosijs-ui'` no longer resolves to the IIFE under browser bundlers.**
  The `"."` export had a `"browser"` condition pointing at `dist/iife.js` — the
  self-contained CDN/doc-site bundle that inlines tosijs + marked. Browser-targeted
  bundlers (Vite, webpack, esbuild) picked it, so consumers **double-bundled tosijs**
  instead of externalizing the peer, and named imports broke (the IIFE isn't an ESM
  module). Removed the condition: every `import` now resolves to `dist/index.js`
  (ESM, peers externalized). The IIFE stays for CDN `<script>` use and the naive
  doc site — referenced by file path, never via `import 'tosijs-ui'`.

### Changed

- **tosijs peer + dev dependency bumped to `^1.6.6`** — picks up a subtle
  component-lifecycle fix.

## 1.6.19

Doc-system tooling; no component API changes.

### Added

- **Build-time example transpile check.** Every executable live-example block
  (`js` / `tjs` / `ts` / `test`) across the whole corpus is run through the front
  half of the runtime pipeline — `rewriteImports` → transform → `new
AsyncFunction` — at build time, **without executing it**, so a block that can't
  build (a syntax/import error, or illustrative code mistakenly tagged with an
  executable language instead of the display-only `typescript`) **fails the build**
  with the offending doc/block named, on every page — including fences hidden in
  blockquotes and lists. TypeScript is transpiled with bun's own transpiler
  (network-free). Opt out with `checkExamples: false`.
- **"One Source, Every Artifact"** doc page — how one corpus of doc-comments +
  markdown projects into a static SEO site, a self-testing live playground with
  in-browser TypeScript, an ePub/PDF, and an agent-debuggable page. Embeds a live
  `<tosi-doc-system>` (the whole system running inside its own page).

### Fixed

- **A live example's build/exec failure is now a test failure whether or not it
  defines `test` blocks.** So on any page the browser test runner loads, _all_ of
  its examples are checked for explosions — a no-test example that throws is
  reported as a failed test — not just blocks with explicit assertions.

## 1.6.18

Doc-site tooling; no component API changes.

### Added

- **`haltijaDev` — give a coding agent eyes + hands on your running dev page.**
  Set `haltijaDev: true` (or `HALTIJA_DEV=1`) and `bun start` injects a tiny
  localhost-gated loader into served HTML — a runtime `import()` of a local
  [haltija](https://github.com/tonioloewald/haltija) channel's `dev.js` — and
  spins up (or reuses) a server-only channel (no Electron app) in `--both` mode:
  HTTP 8700 for the `hj` CLI, HTTPS 8701 for the injected widget (so an HTTPS page
  has no mixed-content). An agent can then read the live DOM, click/type, run JS,
  and **screen-capture** the rendered page (`hj screenshot`, via `getDisplayMedia`
  — click the 🖥 widget button once to grant the share). Because the loader is
  pulled from the local server at runtime it is **never bundled** (zero build
  bytes) and self-disables off-localhost, and because it's injected at serve time
  it never touches the built output. Certs are mkcert-signed (already required for
  the dev server's HTTPS), so no browser warning. Local dev only; off by default.
  The channel tracks haltija's `@beta` dist-tag (where the WebRTC capture lives).

## 1.6.17

### Fixed

- **`@resvg/resvg-js` is now an optional peer dependency** (it was only a dev
  dependency). It rasterizes the generated ePub cover and belongs to the same
  doc-site pipeline as `happy-dom`, which was already an optional peer — so an
  adopter building ePubs via `tosijs-ui/site` got `happy-dom` surfaced by their
  package manager but not `resvg`, and their book's cover silently failed to
  generate. Both are still lazy-loaded with a graceful warning when absent.

## 1.6.16

Doc-system: prose/book adoption. Component APIs are unchanged; everything here is
doc-site tooling (the `tosijs-ui/site` build system + live examples).

### Added

- **Book manifest — curate/reorder the book without touching site nav.** A new
  `book` field on the site config (`BookManifest`) selects and sequences the ePub
  (and, later, print) as a subset of the corpus while the live site still shows
  everything — one source, two outputs. `include`/`exclude` globs pick docs,
  `order: [...]` names the lead sequence (front/back matter are just docs you
  name), and `sort: 'filename'` gives a folder of chapters natural order with no
  metadata. It adds no new ordering mechanism — it overlays each doc's `order` so
  the existing nav sort sequences the book (pins/parents still apply). Zero-config
  is unchanged: the book is the whole visible corpus. Identity (title/author/
  cover) still comes from `epub`.
- **Prose Markdown (Batch A), activated only by its own syntax** so code docs are
  byte-identical: **YAML frontmatter** (`title`/`order`/`author`/`date`/`draft`,
  frontmatter wins over JSON-comment metadata), **wikilinks** `[[slug]]` /
  `[[slug|label]]` → `/slug/`, and **footnotes** `[^id]` → numbered refs + an
  endnotes section (ePub/web).
- **Book/prose quick wins:** auto-serve `/iife.js` when `bundleEntry` is omitted;
  skip `_`-prefixed scaffolding files; empty metadata `title` falls back to the
  H1; warn when no ePub cover is generated.
- **`epub.coverIcon`** — embed a custom flat SVG glyph into the generated ePub
  cover (in place of the favicon); the source viewBox is preserved so any
  square-ish icon scales correctly. tosijs-ui ships a `tosi-book` cover glyph.
- **`<tosi-3d>` declarative attributes** — assemble a scene purely in HTML (no
  `sceneCreated` callback): `src` (a `.glb` URL, auto-loads like `<img>`),
  `hero-light` (a directional key light over a soft hemispheric fill), `fov` (a
  field-of-view multiplier — `1` unchanged, `<1` a longer lens), and `clear-color`
  (a hex color, or `transparent` to composite the 3D over the page). Aimed at
  declarative narrative pages.

### Fixed

- **Multiple `<tosi-3d>` on one page.** Their scenes are created asynchronously and
  interleave, so objects created without an explicit scene landed on Babylon's
  `Engine.LastCreatedScene` (another instance's scene) — the built-in default
  camera did this, leaving a scene with no camera and a blank canvas. The default
  camera (and the doc examples' lights) now pass their scene explicitly.

### Changed

- **PDF is now the in-browser Print button, not a batch job.** The doc-browser's
  Print action renders the shared book HTML (`book-html.ts`) and the browser
  prints it to PDF. This is the single, supported PDF path going forward; a future
  paginated/footnote-anchored PDF will build on it. (ePub is still generated at
  build time and unaffected.)
- **Live examples pinned to tjs-lang 0.8.7** (upstream subtle-bug fix).

### Removed

- **The headless PDF batch builder** (`buildPdf` / `BuildPdfOptions`, exported
  from `tosijs-ui/site`, and the `book:pdf` / `--pdf` script) — a secondary code
  path superseded by the Print button above. If you generated PDFs in a build,
  print the book page to PDF (via the doc-browser) instead.

## 1.6.15

Live-example fixes from tosijs-3d adoption. All fixes to the doc-system's
editing surface; no API changes.

### Fixed

- **Save to source works when the doc comment is indented.** A `/*# … */` doc
  comment is often indented in the source; the extractor dedents its fenced blocks
  (so examples render with correct ordinals), but the raw scan required the closing
  ` ``` ` right after a newline, so an indented file matched **zero** blocks and
  every save failed with "no matching block". The scan is now indentation-aware,
  and edits compare dedented / write re-indented so a block keeps its place. The
  failure alert is also split into a precise message (page↔source ordinal mismatch
  vs. a genuine no-op).
- **The pop-out editor window ("view/edit code in a new window") now has the full
  menu.** It previously opened as a bare instance, so View changes, Save changes
  (local), and Save to source were hidden; the source↔doc key and the pristine
  snapshot are now passed through, so the pop-out offers — and can execute — the
  same actions as the main window.
- **Opening the editor in a new window closes the inline code view** in the main
  window if it was open (the pop-out owns editing).
- **The doc extractor now only treats a `/*#` block as a doc when it starts a
  line** (whitespace-only before the slash), so a `/*#` inside a `//` comment, a
  string, or mid-line can't be scraped as a spurious doc page.

## 1.6.14

Lighter live-example transpilation. Additive; affects only how the live-example
runner loads its (optional, lazy) transpiler.

### Changed

- **Live examples now use tjs-lang 0.8.5's self-contained browser bundles**
  (`tjs-lang/browser` + `tjs-lang/browser/from-ts`). The transpiler is a single
  self-contained chunk, and the TypeScript compiler is **lazy-loaded from a CDN at
  runtime** only when a `ts` example actually transpiles — so `typescript` (~MB)
  and the transpiler's own deps are never in a consumer's dependency graph. This
  also fixes the `ts` example path, which previously tried to load the TypeScript
  compiler through a CDN transform that timed out on its size.
- **Robust transpiler loading.** The doc-site build now ships the tjs-lang browser
  bundles **same-origin** (copied next to the iife under `/tjs/`, with a global
  pointing the loader at them), so live examples never depend on a third-party
  CDN's propagation timing or uptime. The loader prefers the same-origin copy,
  then falls back through a multi-CDN chain (jsdelivr → unpkg → esm.sh). Installed
  ESM consumers resolve the peer locally as before.
- `tjs-lang` optional peer bumped to `^0.8.6`.

### Fixed

- A garbled character in the first `example` doc snippet that made it throw.
- Live examples could all break for the propagation window after a tjs-lang
  release, when the single pinned CDN 404'd the just-published version (now
  served same-origin + multi-CDN).

## 1.6.13

The documentation system becomes a real publishing pipeline: every doc site can
now emit an **ePub** of the whole corpus, **print to PDF** from the browser, and
**deep-link every example from the book back to the live site**. All changes are
**additive — no breaking changes** — and concern the doc-site tooling
(`tosijs-ui/site`) and doc browser, not the components.

### Added

- **ePub of the whole doc site** (`tosijs-ui/site`). Set `epub: true` (or an
  options object) in the site config and the build emits a valid EPUB 3 of the
  corpus alongside the static pages, regenerated on every build:
  - one chapter per doc in nav-tree order, a readable **Contents page** in the
    reading flow plus the reader's Contents drawer (EPUB3 `nav.xhtml` + EPUB2
    `toc.ncx`), and a customizable stylesheet (`epub.css` / default force-wraps
    code listings)
  - an **auto-generated cover** from the title + favicon (`epub.cover` /
    `epub.coverColor` to override); needs the optional `@resvg/resvg-js`, and is
    omitted gracefully if it's absent
- **Print to PDF** from the doc browser — a "Print as PDF" item in the settings
  menu assembles the whole corpus into a print-styled window and opens the print
  dialog. No server, no headless browser. (A headless `buildPdf` is also available
  for CI via `bun run book:pdf`, not deployed.)
- **"Download ePub"** item in the settings menu (alongside Print), linking the
  built book.
- **Example anchors + book deep links.** Every live example gets a stable anchor
  (`/{slug}/#example-1`, or a custom `id` via a ` ```js#my-id ` fence on any block
  of the group). Arriving at such a URL scrolls the example into view with a brief
  highlight. Each example in the ePub/PDF links back to its anchor on the live
  site — a reader is one tap from the real, interactive, editable version.

### Removed

- The broken **bundlejs** size badge (bundlejs errors computing the bundle).

### Notes

- The ePub needs `happy-dom` (dev dep) + the `zip` CLI; the generated cover needs
  the optional `@resvg/resvg-js`.

## 1.6.12

Packaging improvements for independent, tree-shakeable consumption. All changes
are **additive — no breaking changes**; one minor behavioral edge case is noted
below.

### Added

- **Subpath exports**, so an app can import only what it needs without the barrel
  dragging in dev tools:

  - curated: `tosijs-ui/icons`, `tosijs-ui/code-editor`, `tosijs-ui/live-example`,
    `tosijs-ui/doc-browser`, `tosijs-ui/diff`, `tosijs-ui/theme`
  - per-component wildcard: `import { tosiRating } from 'tosijs-ui/rating'`
    (resolves `dist/rating.js`, registering just that element)

  The full barrel `import 'tosijs-ui'` is unchanged and still registers every
  `<tosi-*>` element.

### Changed

- **`menu`, `tooltip`, and `float` now inject their stylesheet and register their
  global listeners on first use, not at import.** Importing these modules (or the
  barrel) no longer has import-time side effects. This also fixes a latent bug
  where the menu `keydown` handler attached to `document.body` at import time,
  which may not exist yet.

### Possible edge case

If you imported `menu` / `tooltip` / `float` **purely for the side effect** — i.e.
you hand-author markup with the internal classes (`.tosi-menu`, `.tosi-tooltip`,
…) and relied on a bare `import` injecting the stylesheet, **without** ever
calling the API (`popMenu`, `popDropMenu`, `initTooltips`, `showTooltip`) or using
the `<tosi-*>` element — those styles now apply only once the API/element is first
used. Normal usage (the element creators, the `<tosi-*>` elements, and the
`popMenu`/`initTooltips` entry points) is unaffected.
