tosijs-ui 1.9.8 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +668 -6
  2. package/bin/caddy-install.ts +202 -0
  3. package/bin/deploy-preview.ts +13 -4
  4. package/bin/release-notes.ts +43 -0
  5. package/bin/resolve-site-config.ts +84 -1
  6. package/bin/tunnel.ts +43 -6
  7. package/dist/code-editor-cm.d.ts +17 -8
  8. package/dist/crud.d.ts +155 -0
  9. package/dist/crud.js +822 -0
  10. package/dist/data-table.d.ts +64 -1
  11. package/dist/data-table.js +747 -32
  12. package/dist/doc-system/doc-system.d.ts +3 -0
  13. package/dist/doc-system/doc-system.js +68 -2
  14. package/dist/doc-system/release-notes.d.ts +34 -0
  15. package/dist/doc-system/release-notes.js +82 -0
  16. package/dist/doc-system/site/build-lock.d.ts +50 -0
  17. package/dist/doc-system/site/build-lock.js +140 -0
  18. package/dist/doc-system/site/check-examples.js +12 -1
  19. package/dist/doc-system/site/dev-auth.d.ts +144 -7
  20. package/dist/doc-system/site/dev-auth.js +267 -16
  21. package/dist/doc-system/site/dev-server.d.ts +17 -0
  22. package/dist/doc-system/site/dev-server.js +212 -18
  23. package/dist/doc-system/site/docs.js +14 -1
  24. package/dist/doc-system/site/make-llms-txt.d.ts +7 -0
  25. package/dist/doc-system/site/make-llms-txt.js +17 -0
  26. package/dist/doc-system/site/orchestrator.d.ts +1 -0
  27. package/dist/doc-system/site/orchestrator.js +710 -654
  28. package/dist/doc-system/site/output-guard.d.ts +15 -0
  29. package/dist/doc-system/site/output-guard.js +15 -0
  30. package/dist/doc-system/site/site-config.d.ts +75 -5
  31. package/dist/doc-system/site/truncated-doc.d.ts +27 -0
  32. package/dist/doc-system/site/truncated-doc.js +55 -0
  33. package/dist/hash-state.d.ts +29 -0
  34. package/dist/hash-state.js +210 -0
  35. package/dist/icons.js +31 -11
  36. package/dist/iife.js +153 -150
  37. package/dist/iife.js.map +22 -14
  38. package/dist/index-iife.js +21 -0
  39. package/dist/index.d.ts +3 -0
  40. package/dist/index.js +3 -0
  41. package/dist/live-example/code-transform.js +1 -1
  42. package/dist/live-example/component.js +34 -0
  43. package/dist/localize.d.ts +19 -1
  44. package/dist/localize.js +150 -1
  45. package/dist/router.js +9 -1
  46. package/dist/schema-form/fields.d.ts +187 -0
  47. package/dist/schema-form/fields.js +593 -0
  48. package/dist/schema-form/json-schema.d.ts +41 -0
  49. package/dist/schema-form/json-schema.js +25 -0
  50. package/dist/schema-form/plugins.d.ts +45 -0
  51. package/dist/schema-form/plugins.js +55 -0
  52. package/dist/schema-form/unenforced.d.ts +11 -0
  53. package/dist/schema-form/unenforced.js +104 -0
  54. package/dist/schema-form/validator.d.ts +58 -0
  55. package/dist/schema-form/validator.js +111 -0
  56. package/dist/schema-form.d.ts +148 -0
  57. package/dist/schema-form.js +1472 -0
  58. package/dist/tsconfig.tsbuildinfo +1 -1
  59. package/dist/version.d.ts +1 -1
  60. package/dist/version.js +1 -1
  61. package/llms.txt +15 -2
  62. package/package.json +20 -7
package/CHANGELOG.md CHANGED
@@ -1,5 +1,658 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.11.0
4
+
5
+ **Schema-driven editing.** Three new pieces that compose: `<tosi-schema-form>` renders a form
6
+ from a JSON Schema, `<tosi-crud>` puts search, list and edit over a store you supply, and
7
+ `hashState` keeps filter and selection in the URL so a filtered list with a record open is a
8
+ link you can send.
9
+
10
+ **The dev bridge is usable on a headset.** The edit-link token is now **seven characters** you
11
+ can read off one screen and type on another, instead of twenty-two of mixed-case base64url.
12
+ That change came from watching the feature fail in practice: people gave up and typed LAN IP
13
+ addresses instead, and a credential too painful to use is not protecting anything.
14
+
15
+ > **If you are on 1.10.0, this is a three-version jump.** 1.10.1 and 1.10.2 were tagged in git
16
+ > but **never published to npm** — `latest` has been 1.10.0 throughout. Nothing is lost: every
17
+ > change in those releases is in this one, and their notes are below where they always were.
18
+ > They are simply not installable, so `npm i tosijs-ui@1.10.2` will fail.
19
+
20
+ ### Breaking
21
+
22
+ - **`tosijs-schema` floor is `^1.8.0`** (optional peer). 1.8.0 enforces `oneOf`,
23
+ `exclusiveMinimum` and `exclusiveMaximum` — which is what
24
+ [tosijs-schema#8](https://github.com/tonioloewald/tosijs-schema/issues/8), filed from this
25
+ work, asked for — and exports `unenforcedKeywords`. The form now asks the **registered
26
+ validator** which keywords it does not check, falling back to a local list only when a
27
+ validator cannot answer. Against 1.7.0 a `oneOf` field carried "oneOf is not validated";
28
+ against 1.8.0 it carries nothing and the value is genuinely checked. Verified with
29
+ `bun bin/verify-schema-dep.ts --version=1.8.0` (17/17).
30
+
31
+ - **Validation is supplied, not imported.** `setSchemaValidator({ validate, inferSchema, unenforcedKeywords })` —
32
+ one line, anywhere, before or after render. The CDN `<script>` build and any `tosijs-ui/site`
33
+ doc site register it themselves, so only an ESM consumer writes it.
34
+
35
+ The reason is worth knowing, because it is not a preference. A bare `import('tosijs-schema')`
36
+ in shipped code is either **resolved** by your bundler — which fails the build for anyone who
37
+ did not install it, including people using only `<tosi-table>` — or left **external**, which
38
+ cannot resolve in a browser and kills validation for everyone. Both were measured. There is
39
+ no third option, so the component asks for two functions instead of a package. The upside is
40
+ that they are just functions: an Ajv wrapper, a house validator or a test stub all work.
41
+
42
+ - **`tjs-lang` moves to `^0.13.1`** (from `^0.12.0`), and `TJS_VERSION` in `code-transform.ts`
43
+ moves in lockstep. 0.12.0 is **deprecated on npm**, and the deprecation names this release's
44
+ own combination — _"tosijs-schema >=1.5.0 breaks the battery atoms' output validation in these
45
+ versions. Upgrade to 0.13.1."_ Worse for adopters, `^0.12.0` does not admit 0.13.x, so anyone
46
+ on current tjs-lang got a hard `ERESOLVE`. The tjs CodeMirror extension is still bundled
47
+ rather than externalized — the constraint that silently no-ops highlighting if it breaks.
48
+
49
+ - **The edit-link token is 7 Crockford base32 characters**, and `linkTtlMinutes` defaults to
50
+ **5** (was 15). A link minted by an older server is not redeemable by a newer one; both are
51
+ in-memory and per-process, so this only matters across a restart mid-session.
52
+
53
+ ### New
54
+
55
+ **`<tosi-schema-form>`** — a form from a JSON Schema. `value` is the state and `change` fires on
56
+ edit, the same contract as every other component here.
57
+
58
+ - Scalars, enums, `const`, and `format`-driven input types. Anything it cannot render is a
59
+ labelled placeholder saying so, never a silently omitted field — a field that vanishes is
60
+ indistinguishable from a schema that never mentioned it, which is how an editor loses data.
61
+ - **Nested objects** become `<details>` sections to any depth, with fully-qualified leaf paths
62
+ (`address.geo.lat`), so value sync and error keying work at depth with no special cases.
63
+ `required` is scoped to the object that declares it, which is what JSON Schema means: a
64
+ required `city` inside an optional `address` says _if you give an address, it needs a city_.
65
+ - **Arrays** with add, remove and reorder. Each element expands against `items`; scalar items
66
+ are a single field at the index (`tags.0`), object items expand to their properties
67
+ (`items.0.sku`).
68
+ - **Unions** (`anyOf`/`oneOf`), each rendered as what it actually is: `[X, null]` is just an
69
+ optional X, an all-`const` union is a `<select>`, an all-object union is a variant picker plus
70
+ the matching branch's fields. Anything else names the shapes it saw ("a union of string |
71
+ object"). Discriminators are derived from the property every branch pins to a different
72
+ `const`, or declared as `x-discriminator`.
73
+ - **Format plugins** — `registerFieldPlugin(format, plugin)` claims a schema `format`. The
74
+ plugin owns the control; the form keeps the label and the error slot. Plugins are dispatched
75
+ first and for every node kind, so `unsupported` is the fallback, not the ceiling. Register
76
+ whenever you like: styles are injected on registration and live forms rebuild.
77
+ - **Validation is optional.** Without a validator the form still renders and edits and simply
78
+ reports no errors, which is a smaller failure than refusing to render.
79
+ - **No schema? It infers one** from the value, via `inferSchema`. Read it back from `.schema`,
80
+ edit it, set it again — that round trip is the point.
81
+
82
+ **`<tosi-crud>`** — search, list, edit over a `CrudStore` adapter (`list` / `save?` / `delete?`,
83
+ promise-returning). There is **no transport in the component**: REST, a DocStore, IndexedDB, an
84
+ array in a closure and a mock all satisfy the same three methods. Omit `save` and the form is
85
+ read-only; omit `delete` and its button is not shown. `columnsFromSchema()` lets one description
86
+ of the shape drive both surfaces — strictly better than the table's own inference, which reads
87
+ `Object.keys(array[0])` and silently loses the column for a property the first row lacks. The
88
+ search term and selected id live in the URL hash, so typing **replaces** the history entry while
89
+ selecting a record **pushes** one: back leaves the record rather than un-typing your search a
90
+ letter at a time.
91
+
92
+ **`hashState({ namespace, mode })`** — key-value state in the page hash. Every key is namespaced
93
+ in the URL and bare in the API, and a write never touches a key it did not put there, so two
94
+ instances (or a hashState beside a hash router) share one URL without deleting each other. That
95
+ is the failure that made `createDocBrowser` grow a `'memory'` routing mode, so `mode: 'memory'`
96
+ is here from the start.
97
+
98
+ **Editable `<tosi-table>`** — set `editable` and cells become inputs, per-column overridable
99
+ either way; a column with its own `dataCell` is never made editable. A `schema` drives the
100
+ control type and validates edits, using the same model the form uses. A `change` event carries
101
+ `{ item, field, oldValue, newValue, error }` and commits on `change`, not `input`: an event per
102
+ keystroke would make `3` a legitimate intermediate state of typing `35`.
103
+
104
+ **Localization** — `localize(pattern, values)` fills `{name}` placeholders **after** translating,
105
+ so the key is the whole sentence (`'Add {item}'`, never `'Add ' + item`). A concatenated
106
+ sentence is untranslatable in principle: the translator sees a dangling fragment and cannot move
107
+ the placeholder to where their language puts it. An unknown placeholder stays visible as
108
+ `{name}` rather than blanked. `<tosi-schema-form>` localizes its own chrome and rebuilds on
109
+ locale change.
110
+
111
+ **The dev bridge.** `mintLinkToken()` / `normalizeLinkToken()`: Crockford base32 excludes `I`,
112
+ `L`, `O` and `U`, so the `0`/`O` and `1`/`l` mistypes that hurt most on a floating keyboard are
113
+ impossible rather than merely unlikely. `randomInt`, not `randomBytes % 32`. Redemption folds
114
+ case, accepts `I`/`L` as `1` and `O` as `0`, and ignores hyphens — normalising on **redemption**
115
+ rather than only when minting, because case-insensitivity that exists in the alphabet but not in
116
+ the comparison is a claim rather than a behaviour, and the failure it produces is a correct human
117
+ being told they typed it wrong. `--link` prints the code on its own line as well as in the URL.
118
+
119
+ The TTL is 5 minutes because the link is a **bearer token** for its lifetime — which the
120
+ `dev-auth` header comment now says plainly instead of describing the single-use rule it stopped
121
+ following in 1.10.0. About 35 bits is ample for what this actually is: an online-only guess
122
+ against a `Map` lookup, for a token redeemed seconds after minting, that mints only a write
123
+ session `mayWriteSource` still gates.
124
+
125
+ Guess-rate control: redemption runs **one at a time** and every attempt occupies at least 100ms,
126
+ so ten attempts a second against 32⁷ is ~111 years to exhaust and about 1 in 11 million inside a
127
+ five-minute window (measured — 20 concurrent attempts complete at 9.8/sec). After ten
128
+ consecutive failures the slot widens to a second. Not a lockout: the door never closes on a
129
+ human, it only gets slower to knock on. An earlier draft of this used escalating delays plus a
130
+ lockout, and replacing it was the right call — that version had two tunable constants, a counter
131
+ needing reset, and a lockout an attacker could trigger, which is a denial of service against the
132
+ developer on the one credential they need in order to work.
133
+
134
+ `tosijs-tunnel` prints the host **and where the address came from** before it acts —
135
+ `tosijs-deploy` is dry-run by default and shows its target, this one is not.
136
+
137
+ **Tooling.** `bin/verify-schema-dep.ts` runs the twelve requirements from tosijs-schema#6 plus
138
+ both directions of #7 against a candidate release and exits non-zero on any failure.
139
+ `PREVIEW_HOST` now also resolves from `~/local-secrets/tosijs-preview.env`, because the previous
140
+ advice — export it from your shell profile — is present for a human and **absent for every
141
+ tool**: non-interactive shells inherit no interactive profile, so an agent or CI step sees
142
+ nothing and reports a missing credential rather than a missing `export`. `readLocalSecret` parses
143
+ `KEY=value` only and never evaluates; a credentials file that needs a shell to read is one that
144
+ can run code. `bin/preview-host.test.ts` includes a guard that greps the committed config for a `user@host`
145
+ literal — and asserts the pattern would have caught the real one, so it cannot pass by being
146
+ wrong. `bin/smoke-consumer.ts` gained four things it could not previously see: resolution of the
147
+ new subpaths from an installed tarball, a `tsc --noEmit` over an installed consumer with
148
+ `skipLibCheck: false` and no optional peers, a bundle entry that actually imports the library,
149
+ and both vendored types pinned by assignability tests in both directions.
150
+
151
+ - **`<tosi-table>` gained `full-width-header`.** A table whose columns add up to less than its
152
+ container left a strip of blank space on the right for no reason; with the attribute the
153
+ leftover width goes to the last **unpinned** column. Right-pinned columns are skipped —
154
+ they sit against the right edge by definition, so stretching one would push the space back
155
+ into the middle. When the columns overflow, nothing changes: every column keeps its width
156
+ and the table scrolls, because the row width is `max(sum, 100%)` and `1fr` then resolves to
157
+ exactly the width it was given. Header and body share one `grid-template-columns`, so they
158
+ stretch together.
159
+
160
+ ### Process
161
+
162
+ - **`bun run release-check` must run AFTER the release commit, and it is the last thing before
163
+ `git tag`.** The range is `<last tag>..HEAD`, so a bullet written _in_ the release commit is
164
+ not in the range you checked a moment earlier — 1.11.0 shipped a commit whose body claimed
165
+ the gate was green while it exited 1 on the state that commit created. The range is also
166
+ exclusive of the since-commit, so tagging turns the gate green without the bullet ever being
167
+ written: the annotation escapes in both directions. Now documented in `CLAUDE.md`.
168
+ - The `unenforced.ts` parity test is a **subset** check rather than equality. Upstream growing
169
+ the set of keywords it enforces is good news, and an equality assertion turned that into a
170
+ red build. Upstream _shrinking_ below our fallback is the dangerous direction — the fallback
171
+ would claim a keyword is checked when it is not — and that is still caught.
172
+
173
+ ### Fixed after the pre-release review
174
+
175
+ The nine-lens review ran **three times** against this release. Everything in this section was
176
+ found by it and fixed before tagging.
177
+
178
+ - **Editable numeric cells silently truncated what you typed.** Typing `19.95` into a
179
+ `<tosi-table editable>` number cell committed **95**; `-4` committed **4** — and every
180
+ commit fired `change` with `error: null`, so nothing reported that a digit had gone. Two-way
181
+ `bindValue` wrote the model on every `input`, and `<input type=number>` sanitizes an
182
+ intermediate `"19."` to `""` under the HTML value-sanitization algorithm, so the binding
183
+ wrote `""` straight back into the focused input. The binding is one-way now and
184
+ `handleCellChange` is the sole model write — which also makes "commits on `change`, not on
185
+ `input`" true of the _model_ and not only of the event.
186
+
187
+ It was invisible to every lane because every test assigned `.value` programmatically or
188
+ typed with no delay, and neither ever lets a frame run between keystrokes. There are three
189
+ tests that genuinely **type**, with a per-character delay, across all three engines.
190
+
191
+ The nine-lens review ran twice against this release. Everything below was found by it and
192
+ fixed before tagging.
193
+
194
+ - **`<tosi-crud>` destroyed unsaved edits** — one frame after any keystroke, with no user
195
+ action, and `save()` then posted the unedited record.
196
+ - **`<tosi-crud>`'s table selection never followed the form**, so a `#?people.id=2` deep link
197
+ opened the record with nothing highlighted — the feature these notes headline, producing a
198
+ list that did not show where you were. **Back** also left the opened record on screen while
199
+ the URL said otherwise, so back-then-reload showed you two different things; and a
200
+ hash-driven selection change fired no `change` event. Saving a new record now writes its id
201
+ to the hash, which is both correct and what makes "absent id means deselect" safe.
202
+ - **`<tosi-crud>` rebuilt the entire table on every keystroke.** Identity-guarded; measured at
203
+ zero table renders per keystroke afterwards.
204
+ - **`<tosi-schema-form>` emitted duplicate `change` events** — a checkbox click produced
205
+ three. `<tosi-table>` fixed the identical hazard in this release; the sibling shipped
206
+ without it.
207
+ - **The "not validated" note skipped containers**, so `uniqueItems` on an array and `allOf` on
208
+ an object — exactly the keywords a validator is most likely to skip — carried nothing.
209
+ - **`columnsFromSchema` re-derived property types** and got `{anyOf:[{type:'boolean'},
210
+ {type:'null'}]}` wrong, and emitted only `title` as the column name — so a title-less schema
211
+ gave headers reading `firstName` beside a form showing "first name".
212
+ - **`<tosi-table>`'s column menu still concatenated localized words**, which the localize docs
213
+ in this same release condemn by name. All five languages checked rendered wrong:
214
+ `Sortieren Aufsteigend`, `숨기기 열`, `非表示 カラム`, `隐藏 列`, `Cacher Colonne`.
215
+ `localizePhrase(key, fragments)` asks for the whole sentence and joins the fragments only
216
+ when nobody has translated it — so an existing translation table keeps its behaviour exactly
217
+ and adding one row upgrades it. Nothing is orphaned.
218
+ - **Six `on<Event>` members were shadowed by the element factory**, which warns about it in
219
+ the console. Renamed to `handle<Event>` per the framework's own guidance.
220
+ - **The no-validator path had no coverage in any tier** — the default for every ESM adopter,
221
+ while every lane ran with a validator registered. It has its own test file now. Two real
222
+ defects were behind that gap: removing a validator left the warnings permanently
223
+ suppressed, and a single once-flag meant whichever component spoke first silenced the rest.
224
+ - **A form with no schema and no validator rendered an empty box**, warning about validation
225
+ when the problem was that it had nothing to render. It says so on screen now, and the
226
+ warning names the right problem.
227
+ - **`change.detail.oldValue` was `undefined` for every cell edit after the first.** The
228
+ baseline was deleted on each commit rather than rolled forward, and the delete ran _before_
229
+ the equality guard — so a no-op commit wiped it, and clearing a numeric cell as the second
230
+ edit fired no `change` at all and skipped the coercion write, leaving a schema-typed integer
231
+ holding a raw string.
232
+ - **The documented `setSchemaValidator` recipe was wrong**, and only for the people it was
233
+ written for. `{ validate, inferSchema }` omits `unenforcedKeywords`, so the form fell back
234
+ to a keyword list frozen at tosijs-schema 1.7.0 and labelled every `oneOf` and
235
+ `exclusiveMinimum` field "not validated" while 1.8.0 was checking it. Our own doc site was
236
+ correct because the iife passed all three — so the failure was visible _only_ to the ESM
237
+ adopters the docs instruct. Pass all three.
238
+ - **`crud.table` and `crud.form` threw before hydration** — the only two accessors on the
239
+ class without the guard the rest have — so the documented way to reach the composed parts
240
+ threw on the line the docs told you to write. They return `null` now. The snippet was also
241
+ in a display-only fence, so no lane ran it; it is executable and tested. And its second line
242
+ was wrong too: `crud.form.readOnly = true` was silently reverted on the next render.
243
+ - **`<tosi-crud>` wrote `history.replaceState` on every search keystroke.** WebKit throws past
244
+ ~100 calls in 10s — a held key clearing a long term gets there — and the throw preceded the
245
+ debounce, so search silently stopped working with an uncaught error. The URL write moved
246
+ inside the debounce, where the store query already was.
247
+ - **An empty `PREVIEW_HOST` swallowed the whole resolution chain.** `??` only skips
248
+ null/undefined, so `PREVIEW_HOST=''` — what a CI `env:` renders for a missing secret —
249
+ counted as "set" and hid both the site config and `~/local-secrets`, the exact symptom that
250
+ rung was added to abolish.
251
+ - **Adding or moving an array item dropped focus.** Every row is rebuilt, so the button you
252
+ just clicked stopped existing: you could not press ↓ twice to move an item two places, or
253
+ add two rows from the keyboard. Focus is restored by role and index, skipping controls that
254
+ are disabled at the destination.
255
+ - **`<tosi-schema-form>` was quadratic in the keystroke path** — one root-scoped
256
+ `querySelector` per field, twice, per keystroke. Measured on the built component: 800 fields
257
+ cost **240 ms** per keystroke before and **0.64 ms** after. An index built with the DOM
258
+ replaces the scans.
259
+ - **The redemption gate had 21 tests and its wiring had none.** Reverting the dev server to a
260
+ bare `redeemLink()` call restored both the unbounded queue and the unthrottled guess rate
261
+ while leaving every lane green — a security control held in place by nothing but the diff.
262
+ The decision is an exported function now, like the others in that file, and is tested.
263
+ - **Notes read `root.uniqueItems is not validated`** on a field called Tags — upstream's
264
+ `unenforcedKeywords` answers with paths. The leading `root.` is stripped and deeper segments
265
+ kept, since for a container the keyword may be several levels down.
266
+ - **These notes themselves published the old preview-host precedence**, inverted against the
267
+ code and against another paragraph thirty lines earlier. Corrected, with a test asserting
268
+ all four places that state it agree — that sentence had been written three different ways.
269
+ - **A duplicate mid-file import** in `dev-auth.test.ts`, found only because nothing had ever
270
+ type-checked the test suite. (~150 accumulated type errors remain; that is tracked, not
271
+ fixed here.)
272
+ - **The two vendored-type anti-drift guards were never type-checked** — `tsconfig.json`
273
+ excludes `*.test.ts`, so both were inert and one was **red**: `json-schema.test.ts` asserted
274
+ our `JSONSchema` is assignable to theirs, which TypeScript cannot decide because both types
275
+ recurse through `additionalProperties`. The guard now asserts the direction it CAN decide —
276
+ theirs → ours, the assignment the code actually performs when storing an inferred schema —
277
+ and the reverse is exercised at runtime by validating an ours-typed schema through their
278
+ validator. `bun run typecheck-guards` (`tsconfig.guards.json`) runs in CI beside the
279
+ existing typecheck; mutation-verified that drifting the vendored type turns it red.
280
+ - **`<tosi-table>`'s four column-menu captions** now use `localizePhrase`, so the docs and the
281
+ docs' own worked example agree.
282
+
283
+ ### Also fixed in the review passes
284
+
285
+ - `<tosi-schema-form>` let the control's native `change` escape to consumers, so a form edit
286
+ fired a duplicate event — the `input`-only handler's `stopPropagation()` never saw a
287
+ `change`. Three events per checkbox click became one.
288
+ - `localizePhrase` asked "did the output differ from the input?" rather than "does a row
289
+ exist", so a locale that deliberately maps a phrase to itself fell back to joined fragments
290
+ — the behaviour the row was added to replace. It also re-implemented annotation stripping
291
+ with a weaker regex than the module's own, which already disagreed about `tag\#42`.
292
+ - `--tosi-spacing-50` is not a variable — the scale is `-xs/-sm/-lg/-xl` — and the `var()`
293
+ fallback made the mistake invisible. The three genuinely-new customization points
294
+ (`--tosi-error`, `--tosi-border`, `--tosi-border-radius`) are documented on the schema-form
295
+ page rather than left accidental.
296
+ - `<tosi-table>`'s `fieldFor` was documented as "cached per render pass" and cached nothing,
297
+ while allocating a schema and running the validator's keyword walker per editable cell per
298
+ render.
299
+ - `SiteConfig.tunnel.linkTtlMinutes` said "Default 15" two lines above "Defaults to 5"; the
300
+ summary line is what an editor tooltip shows.
301
+ - `tosijs-tunnel --link` now prints the 7-character code as well as the URL, which the docs
302
+ already described and only the dev server's own banner did.
303
+ - The "No preview host" instructions promised a `700` credentials directory and gave a command
304
+ that neither created nor secured it — and failed outright on a machine that did not have it.
305
+ - `src/docs/utilities.md` was a five-line stub duplicating a section `helper-libraries.md`
306
+ already provides. Removed.
307
+ - `src/docs/migrating.md` has a **1.11.0 section** — the validator seam, the `tjs-lang` floor,
308
+ and the edit-token change. It exists for "releases you may be upgrading past" and had no
309
+ entry despite this release having a Breaking section.
310
+
311
+ - `<tosi-table>`'s `change` event reported `oldValue: undefined` whenever no `focus` event had
312
+ fired on the cell — an unfocused browser window, or any consumer who sets `.value` and
313
+ dispatches `change`, which is what an adopter's test does. The old value came from a
314
+ `WeakMap` filled on focus, which existed because two-way `bindValue` used to overwrite the
315
+ model as the user typed. The one-way binding shipped earlier in this release ended that, so
316
+ the old value is now read from the model immediately before the write — no focus event
317
+ required, and one mechanism instead of two. Caught by the haltija lane, which the other
318
+ three lanes do not substitute for.
319
+
320
+ - `crud.save()` threw away the record it sent when the store returned nothing. A 204-style
321
+ adapter satisfies `Promise<any>` by resolving `undefined`, which is a perfectly ordinary
322
+ thing for a REST store to do, and the row went blank. It now keeps what it sent when the
323
+ store has nothing to say.
324
+ - The `doc-system.pw.ts` nested-doc-system flake — the **fourth** instance this release of a
325
+ spec racing the page's own inline doc tests. The runner is now disabled for that whole
326
+ spec file rather than per test, because after four the fix plainly belonged at the file
327
+ level. 6/6 clean since.
328
+
329
+ - `<tosi-doc-system>` gave up on the first failed `docs.json` fetch, so a doc site rendered
330
+ nothing at all when one request lost — a real-user failure, not only the test flake that
331
+ surfaced it. It now retries three times with full-jitter backoff (250ms base, 4s cap) and
332
+ honours `Retry-After`. It retries **only 429, 5xx and network errors**: a 404 or a 403 is an
333
+ answer, and re-asking cannot change it — which is what stops a retry turning an overloaded
334
+ server into a self-inflicted DoS.
335
+
336
+ ### Known issues
337
+
338
+ - **firefox intermittently reports `no WebAssembly compiler available`** under the full
339
+ parallel test lane, which stops the inline-WASM example loading at all. Filed as
340
+ [tjs-lang#36](https://github.com/tonioloewald/tjs-lang/issues/36): a `wasm {} fallback {}`
341
+ block should take its fallback branch when the engine declines to compile, which is exactly
342
+ what that branch is for. Affects the test lane, not shipped behaviour — the example
343
+ degrades to not rendering rather than misreporting, and the doc-test guard against _silent_
344
+ fallback is unaffected.
345
+
346
+ - **[#102](https://github.com/tonioloewald/tosijs-ui/issues/102)** — `<tosi-table>`'s header
347
+ and body columns can disagree when a programmatic `columns` assignment lands while a render
348
+ is already in flight. The reporting app changes column visibility on a page-size change,
349
+ which is what produces the timing. The host app arguably should not reassign columns
350
+ mid-render, but a torn header/body is ours either way. Targeted at **1.11.1**; the path
351
+ predates 1.11.0.
352
+
353
+ ### Fixed
354
+
355
+ **Packaging — this one affects existing `<tosi-table>` users.** `<tosi-table>` no longer reaches
356
+ for a schema library at all: it is the component people use _without_ one, and it must never make
357
+ anyone install something in order to build.
358
+
359
+ `tosijs-schema` was imported by
360
+ shipped `dist/` and declared in **no dependency field**, so a consumer's build failed on a
361
+ package they were never told they needed. It is now a declared optional peer at `^1.7.0` — which
362
+ is what the design note said from the start — and nothing we ship imports a type from an optional
363
+ peer any more: `import type` is erased from emitted JS but **not** from emitted `.d.ts`, and
364
+ declaring a peer optional does not help because TypeScript has no notion of an optional peer.
365
+ A second instance had been shipping for a while: `dist/code-editor-cm.d.ts` imported a type from
366
+ `tjs-lang/editors/codemirror`, a declared optional peer that TS2307s anyway (reported upstream as
367
+ tjs-lang#28). Verified from a packed tarball in a clean project: `tsc` and `vite build` both exit
368
+ 0 with the peers absent.
369
+
370
+ - The `./*` subpath wildcard mapped `tosijs-ui/schema-form/fields.js` to
371
+ `dist/schema-form/fields.js.js`, so the extension-ful spelling — what an adopter copying this
372
+ repo's own ESM import style would write — did not resolve at all. A second `./*.js` wildcard
373
+ fixes it, strictly additively.
374
+
375
+ **Localization was wrong in shipped data, and the keys were why.** `<tosi-table>`'s column-header
376
+ menu asked for bare words, and our own translation table shows the cost: `Right` came back in the
377
+ _correct_ sense rather than the direction in four of nine languages (sv `Rätt`, zh `正确的`, es
378
+ `Bien`, it `Giusto`), `Column` as `柱子` (a pillar), `Sort` as `种类` / `종류` (a kind of thing),
379
+ `Show` as `Espectáculo` / `Spettacolo` (the kind with a stage). Every one is a competent
380
+ translation of the word it was given — which is the point. The keys now carry sense annotations
381
+ (`Right#direction`, `Column#table`, `Sort#order`, `Show#reveal`, `Hide#conceal`, `Pin#fasten`,
382
+ `Ascending#sort-order`), and `demo/src/localized-strings.ts` has matching rows with the
383
+ wrong-sense entries corrected, verified in a browser across sv/es/zh. Annotating a key is
384
+ backward compatible, so this cannot orphan an existing table.
385
+
386
+ **`<tosi-crud>` destroyed unsaved edits.** Four specs now cover delete, its rejection path and
387
+ `createNew`→`save`; mutation testing confirms that restoring the defects turns two of them red.
388
+
389
+ `showSelected()` compared `form.value !== _selected`,
390
+ and `setByPath` is immutable, so after the first keystroke that was permanently true and every
391
+ render wrote the record as loaded back over the edit — one frame later, with no user action, and
392
+ `save()` then posted the unedited record. The fix keeps the load guard
393
+ (`_loaded !== _selected`) — one invariant, one place — and drops a form-change listener that set
394
+ `_selected = form.value`, which nothing needed: `value` and `save()` read `form.value` directly,
395
+ and `remove()` is better with the record as opened than with a half-edited copy of it. Mutation
396
+ testing settled that: the two were independently sufficient, so neither was pinned and either
397
+ could have been removed without a single test failing.
398
+
399
+ - `<tosi-crud>`'s destructive paths ran in no lane, and the gap hid two defects: `createNew()`
400
+ followed by `remove()` sent `store.delete({})`, and the Delete button raised an unhandled
401
+ rejection on a rejecting store.
402
+ - Grid navigation swallowed arrows, Home and End inside editable cells, so the caret could not be
403
+ moved and focus jumped away mid-edit; on an enum `<select>` the cell could not be changed by
404
+ keyboard at all. Escape and Tab stay with the grid.
405
+ - The cell's native `change` no longer escapes the cell. It bubbles, so a listener on the table
406
+ received both — theirs with a `detail`, the input's with none.
407
+ - The "this keyword is not validated" note rendered **only for unions**, so the honesty it exists
408
+ for covered exactly one keyword while the docs promised otherwise.
409
+ - `validate` is called with `strict: true`. Without it the validator samples: `maxProperties` is
410
+ skipped entirely and a bad element deep in a long array is stepped over.
411
+ - The optional-peer degrade was silent. It now warns once naming the package and the seam, and
412
+ `validationAvailable` lets a Save handler tell _"this conforms"_ from _"nobody checked"_.
413
+ - We read `x-discriminator` first. `discriminator` is an OpenAPI keyword that tosijs-schema's own
414
+ `agentContract` refuses, so a schema written to pass their check spells it `x-`.
415
+ - The schema→control layer existed twice and had drifted: a `const` property was readonly in the
416
+ form and a freely editable text box in the table.
417
+ - Variant detection scores branches on how many required keys are present, rather than demanding
418
+ every key — a half-filled variant used to match nothing and show an empty box.
419
+ - `readOnly` hides the add/remove/reorder controls instead of disabling them.
420
+ - The array controls carry stable classes (`.schema-move-up`, `.schema-move-down`,
421
+ `.schema-remove`) alongside their localized `title`/`aria-label`: a localized tooltip cannot
422
+ also be a selector.
423
+ - The hash router treats `?…` in the hash as a query, not part of the path. `#/invoices/42?q=x`
424
+ previously matched no route at all.
425
+
426
+ The intermittent Playwright failure was chased rather than re-run until green: `hash-state.pw.ts`
427
+ on WebKit, about one run in four, because the page's own inline doc test writes into the hash
428
+ asynchronously and could land after the spec cleared it. A doc page under test is also a page
429
+ running tests.
430
+
431
+ **The dev bridge's own guard was a denial of service.** Serialized redemption on an
432
+ unauthenticated path with no depth cap meant 50 junk requests delayed a legitimate link by 42
433
+ seconds, and a trickle outgrew the drain so the denial outlasted the attack. Depth is capped;
434
+ overflow is refused instantly with 503 and `Retry-After`. The clock is also read on **arrival**,
435
+ so a token valid when you clicked can no longer expire while queued.
436
+
437
+ **Preview host handling.** A project's own `preview.host` outranks
438
+ `~/local-secrets/tosijs-preview.env`; the machine-global file holds one host, and when it won,
439
+ running `tosijs-tunnel` in someone else's checkout pointed it at your box — and that bin does not
440
+ merely print a target. One `resolvePreviewHost()` now serves all three bins. The tunnel's link
441
+ line no longer claims "Single-use edit link (valid 15 min)", and `doc-site-system.md` no longer
442
+ shows `host:` in its example config. `UPSTREAM.md` claimed 0.12.0 was the latest tjs-lang; stale
443
+ by two minors.
444
+
445
+ ### Housekeeping: we published a preview address, and rotated it
446
+
447
+ Our own `tosijs-site.config.ts` committed the preview host as an ssh target
448
+ (`user@address`) — an **address, not a credential**. Nothing an adopter installs is
449
+ affected, and this is not a security advisory for anyone but us. Recording it because the
450
+ practice it produced is worth adopting.
451
+
452
+ Verified rather than assumed, since "we're sure there were no keys in there" is exactly the
453
+ sort of claim that deserves checking:
454
+
455
+ - **no private key material anywhere in the repo's history** — no `BEGIN … PRIVATE KEY`
456
+ block in any commit on any ref, and no `.pem`, `.key`, `id_rsa` or `id_ed25519` file ever
457
+ added. `*.pem` is gitignored; the only tracked files under `tls/` are a README and the
458
+ cert-generation script.
459
+ - access to that box always required a key that was never published, so the exposure was an
460
+ address someone could try to connect to — not a way in.
461
+
462
+ It has been **rotated regardless**, because a value that has ever been committed to a public
463
+ repo is public: `git log -S` finds it in seconds, and rewriting history does not un-publish
464
+ anything.
465
+
466
+ The practice that came out of it, now in `tosijs-coding-practices` → `deployment.md`:
467
+ machine-local credentials live in `~/local-secrets/` — a `700` directory beside the repos,
468
+ never inside one, so committing them is structurally impossible rather than merely against
469
+ the rules. `tosijs-ui`'s bins resolve `--host=` > `PREVIEW_HOST` > `PREVIEW_SSH` > site config >
470
+ `~/local-secrets/tosijs-preview.env` — the machine-global file is **last**, so a project that
471
+ names its own host is never silently redirected at someone else's box — and a regression test
472
+ now greps our own
473
+ config for a `user@host` literal. The rule had been documented in `site-config.ts` the whole
474
+ time and violated in the same repo — which is why the guarantee is now a directory.
475
+
476
+ ## 1.10.2
477
+
478
+ > **Never released.** This version was tagged but never published, and the tag has since been
479
+ > deleted. Everything below shipped in **1.11.0** instead — including the `killStrayServer`
480
+ > fix, which is why the withdrawal is safe rather than a dropped security patch. npm went
481
+ > straight from 1.10.0 to 1.11.0. Kept here because these changes are real and a reader
482
+ > tracing when something landed needs to find them. ([#101](https://github.com/tonioloewald/tosijs-ui/issues/101))
483
+
484
+ **The dev server no longer serves stale content after a rebuild.** This is the one that
485
+ presented as random flakiness across several sessions: a served `/docs.json` disagreeing with
486
+ the file on disk made the doc browser's route match fail, so live-example insertion silently
487
+ skipped — the page rendered, hydration reported success, custom elements were defined, and
488
+ **zero examples appeared, with no error anywhere**.
489
+
490
+ The cause was a cache key of `path + lastModified`, with a comment asserting that a rebuild
491
+ invalidates it naturally. It does not: `lastModified` is millisecond-granularity, and six
492
+ rapid writes to one file report the same value. A rebuild that rewrote a file inside one
493
+ millisecond kept serving the first compressed body until the process restarted — which is why
494
+ a restart "fixed" it. The cache is now emptied on every completed rebuild, and the key carries
495
+ the file size. (#50)
496
+
497
+ **`killStrayServer` no longer shoots an unrelated process.** Reclaiming the port checked that
498
+ the holder was a JS runtime (`bun`/`node`/`deno`) — but "a JS runtime" is not "our dev
499
+ server", and a colleague's dev server, a language server or a test runner would all pass. It
500
+ now requires the process to be working in **this project**, and skips with a message naming
501
+ the directory when it is not. If the working directory cannot be read it skips too: refusing
502
+ to start costs you ten seconds, shooting the wrong process costs you an afternoon. (#77)
503
+
504
+ **`bun run release-check` now checks the version against what actually changed** — it blocks a
505
+ patch that carries a `[break]` annotation or touches a security-relevant path, and warns on a
506
+ `[change]`. The nine-lens review triggers on the version _letter_, so it only ever fired when
507
+ the letter was already right; 1.9.9 was prepared, gated, tagged and pushed as a patch while
508
+ relocating a build artifact and loosening a security default, and was caught only because a
509
+ human asked for a review anyway. (#78, #79)
510
+
511
+ **Docs:** `bundleOutDir` is in the Bundle config table it belongs in, and `checkExamples` —
512
+ including `contextKeys`, which **extends** the default context rather than replacing it — is
513
+ documented where adopters actually look. (#80, #81)
514
+
515
+ ## 1.10.1
516
+
517
+ > **Never released.** This version was tagged but never published, and the tag has since been
518
+ > deleted. Everything below shipped in **1.11.0** instead — including the `killStrayServer`
519
+ > fix, which is why the withdrawal is safe rather than a dropped security patch. npm went
520
+ > straight from 1.10.0 to 1.11.0. Kept here because these changes are real and a reader
521
+ > tracing when something landed needs to find them. ([#101](https://github.com/tonioloewald/tosijs-ui/issues/101))
522
+
523
+ Two fixes to 1.10.0's row grouping and scroll preservation, both reported by snowfox against
524
+ a real wide, grouped, pinned table.
525
+
526
+ **A pinned column that repeats a group value is no longer a hole.** A column that is both
527
+ `pinned` and listed in `nonRepeatingGroupedRowCells` used `visibility: hidden` on its repeated
528
+ cells — which suppresses _everything the cell paints_, including the opaque background
529
+ `.col-pinned` exists to provide. On every row but the first of its group, horizontally
530
+ scrolled cells showed through the sticky column **and were clickable there**: a visual hole
531
+ and a hit-testing hole from one rule. Repeated cells now hide their _content_ (transparent
532
+ text, `display: none` on element children, `user-select: none`) and keep painting their box.
533
+ (#83)
534
+
535
+ **`preserveScroll` now restores horizontal position too.** It restored `scrollTop` alone, and
536
+ the tables that most need it are the wide ones — normally read scrolled sideways, with pinned
537
+ columns as the identity and the interesting columns off to the right. Sorting or toggling a
538
+ group snapped back to column 0, so the row stayed put while appearing to say something
539
+ completely different. `scrollLeft` is captured independently of the row anchor, so it also
540
+ survives on a table that is scrolled sideways but still at the top — which used to return
541
+ early and lose the columns. (#86)
542
+
543
+ Group ids are memoized per render in a `WeakMap`; the map is replaced each render, so a
544
+ cached id can never outlive the values it came from.
545
+
546
+ ## 1.10.0
547
+
548
+ Mostly a **silent-failure** release: several things that were quietly not working, or quietly
549
+ not being checked, now say so. If you use `tosijs-ui/site`, three of these have probably cost
550
+ you time already.
551
+
552
+ ### `<tosi-table>`
553
+
554
+ **Scroll position survives a re-render.** Sorting, filtering, or toggling
555
+ `visibleGroupedRowIds` no longer throws you back to the top. The table anchors on the
556
+ **topmost visible row**, not on `scrollTop`, and restores that row to the same offset — the
557
+ distinction matters as soon as the row count changes, because the same pixel offset then
558
+ shows entirely different rows. Set `preserveScroll = false` for the case where a render means
559
+ "here is a different dataset" and starting at the top is right.
560
+
561
+ ### The tunnel: links now work on a second device
562
+
563
+ A magic link is redeemable **repeatedly for 15 minutes** instead of dying on first use. The
564
+ old behaviour collided with the feature's own purpose: glance at a link and close the tab and
565
+ you need a new one; open it on a laptop then reach for your phone and it is dead — in a
566
+ workspace built for reading your uncommitted tree _on a phone_. One project had already
567
+ replaced it with a never-expiring link of their own, which is the tell.
568
+
569
+ The bound moved from _uses_ to _time_, not away. Set the level per project:
570
+
571
+ ```typescript
572
+ tunnel: { linkPolicy: 'single-use', linkTtlMinutes: 2 } // ratchet up
573
+ ```
574
+
575
+ An expired link is refused under either policy — widening reuse never widens lifetime.
576
+
577
+ Every user-facing string — including the line the CLI prints at the moment you share a link
578
+ — is derived from the policy in force, rather than asserting "single-use" regardless.
579
+
580
+ ### Silent failures, now loud
581
+
582
+ - **`docPaths` are watched.** A root-level doc was served and rendered but never watched:
583
+ edit, save, refresh, stale page, no rebuild, no message — indistinguishable from a browser
584
+ cache. (#49)
585
+ - **Two builders on one output tree are refused,** naming the holder's pid and port. Both
586
+ wipe-then-repopulate `docs/`, so running a standalone build against a live dev server meant
587
+ each deleted what the other was writing; it killed a dev server with no error anywhere and
588
+ left `docs/` a fraction populated. A standalone `buildSite()` refuses; a second **dev
589
+ server** only warns and continues, because `bun playwright test` and `bun run test-browser`
590
+ each start their own and a hard failure there would break the lanes this protects. The lock
591
+ is stale-safe (a dead holder's lock is debris), re-entrant, released on Ctrl-C, and fails
592
+ open. (#51)
593
+ - **A truncated `/*#` doc block warns, by name.** Block comments cannot nest, so a `*/`
594
+ inside a doc demo ends the doc there — previously surfacing as parse errors pointing at
595
+ _prose_, never at the delimiter responsible. (#70)
596
+ - **Inline doc tests no longer under-report.** A page could report "done" before its examples
597
+ had run, so their tests were omitted rather than failed — silently, with the suite green.
598
+ **If you use `tosijs-ui/site` with async examples, your counts may have been wrong;** a
599
+ number that goes UP after upgrading is this fix.
600
+
601
+ ### Build output
602
+
603
+ **The hydration bundle now builds into your site output, not `dist/`.** It was written into
604
+ the library tree with only the `.js` copied out, stranding the sourcemap in a directory you
605
+ publish and commit but never serve — 65 MiB across 216 packed blobs in one adopter, ~35% of
606
+ that repo's packed blob store, for a file nothing could load. Set `bundleOutDir` if your
607
+ bundle is itself a published artifact. If you have that history:
608
+ `git rm --cached dist/iife.js dist/iife.js.map` and gitignore them. (#69)
609
+
610
+ ### Also in this release
611
+
612
+ - **`checkExamples` accepts `{ contextKeys: [...] }`** as well as a boolean, so a library
613
+ that documents ITSELF — whose examples import its own package name — keeps the guard on
614
+ instead of disabling it. The keys are **added to** the `tosijs` / `tosijs-ui` defaults, not
615
+ substituted for them. (#71)
616
+ - **`preview.tunnel.linkPolicy`** (`'window'` | `'single-use'`) and
617
+ **`preview.tunnel.linkTtlMinutes`** (default 15) set the link security level.
618
+ - **`SiteConfig.preview.host` is optional.** The documented practice supplies it from
619
+ `PREVIEW_HOST`, so a correct config used to fail typecheck. (#72)
620
+ - **`marked` peer is `^16.4.2 || ^17.0.0 || ^18.0.0`** — it was two majors behind `latest`, so
621
+ a consumer on a current marked got a peer violation on a fresh install. (#60)
622
+ - **The live-example docs now state that `test()` bodies run concurrently** within a fence,
623
+ and give the rule that follows: steps that depend on each other belong in one `test()`.
624
+ (#43)
625
+ - **`llms.txt` carries the browser-control affordance** when a project sets `haltijaDev`, so
626
+ an agent learns it can drive the running page instead of inferring behaviour from source.
627
+ (#18)
628
+ - **Host bootstrap in `doc-site-system.md` now gives the actual commands** rather than
629
+ describing the danger and leaving the dangerous step as an exercise.
630
+ - **`svg2DataUrl`: two real fixes, and one that is NOT done.** It wrote `strokeWidth` /
631
+ `strokeLinecap` / `strokeLinejoin` — names SVG ignores — on any element carrying inline
632
+ style, and explicit `fill` / `stroke` / `strokeWidth` arguments were clobbered by the style
633
+ pass. Both fixed.
634
+
635
+ **[#68](https://github.com/tonioloewald/tosijs-ui/issues/68) is NOT closed by this.** Most
636
+ icons carry their styling on the **root** `<svg>`, which this does not touch, so a
637
+ `stroked` icon — about 280 of ~287 — still serializes with no stroke at all and renders as
638
+ a solid black wedge. Verified after the fix: `svg2DataUrl(icons.chevronRight())` emits no
639
+ `stroke` and no `stroke-width`. Prefer inline `<svg>` over a data URL for stroked icons
640
+ until this lands.
641
+
642
+ - **`acquireBuildLock` / `lockDecision`** in `build-lock.ts` — the
643
+ output-tree lock behind the concurrent-build refusal. **Internal**: not exported from
644
+ `tosijs-ui/site`, so do not build on it yet. Say so if you want it public.
645
+ - New **`tosijs-caddy-install`** bin — installs the Caddy snippets the deploy/tunnel bins
646
+ need, **refuses** to install a template with placeholders left in it, keeps the outgoing
647
+ config as `Caddyfile.bak`, and is dry-run by default. That refusal was the reason this
648
+ existed as a private script; shipping the template without it was the wrong half to keep.
649
+ - The unit lane now covers `bin/` — `bunfig.toml`'s test root was `./src`, so tests anywhere
650
+ else were silently never collected.
651
+
652
+ > **Versioning note.** This is a patch that adds public API. Minors are for breaking changes
653
+ > and feature rollouts; additive, non-breaking extensions ship as patches. The contract you
654
+ > rely on is unchanged: **a patch never breaks you.**
655
+
3
656
  ## 1.9.8
4
657
 
5
658
  **Grouped tables can now say how much of a group is showing.** The piece missing from 1.9.7's
@@ -54,14 +707,23 @@ know anything about grouping. It is additive to the filter's own result, so a fi
54
707
  ranks as well as selects keeps its ranking.
55
708
 
56
709
  Two details worth knowing. **Clusters keep your sort**: groups appear in first-appearance
57
- order, so a group lands wherever its best-sorted row landed. Sorting the clusters by their
58
- id instead would have quietly thrown away the sort the user just clicked. And repeated
59
- cells are **hidden, not emptied** — `.tr:not(.table-cluster-first) .cluster-repeat
60
- { visibility: hidden }`. If you write your own rule for a custom `dataCell`, use
61
- `visibility`, never `display: none`: every cell is an item of the row's grid, so removing
62
- one pulls each later cell a column left and the row renders under the wrong headers.
710
+ order, so a group lands wherever its best-sorted row landed. Sorting the clusters by their id
711
+ instead would have quietly thrown away the sort the user just clicked.
712
+
713
+ And repeated cells are **hidden, not emptied** — transparent text plus `display: none` on
714
+ element children, via `.tr:not(.table-cluster-first) .cluster-repeat`. Deliberately not
715
+ `visibility: hidden`, which also stops the cell painting its **background**: a pinned
716
+ column's background is the only thing masking the columns scrolling underneath it, so
717
+ repeated cells there became windows onto the scrolled content. And never `display: none` on
718
+ the cell itself — every cell is an item of the row's grid, so removing one pulls each later
719
+ cell a column left and the row renders under the wrong headers.
63
720
  `table.isFirstInGroup(row)` answers the same question in JavaScript.
64
721
 
722
+ Group ids are **memoized per render** in a `WeakMap` — the id is computed several times for
723
+ one row in a single pass (clustering, parity, first-of-group, counts, then again per stamped
724
+ row), and the inferred form does a `JSON.stringify` each time. The map is replaced on every
725
+ render rather than kept, so a cached id can never outlive the values it came from.
726
+
65
727
  Ungrouped tables are unaffected — all three properties default to off.
66
728
 
67
729
  **The inline doc-test lane was under-reporting, and staying green about it.** If you use