tosijs-ui 1.12.4 → 1.12.6

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 (47) hide show
  1. package/CHANGELOG.md +490 -0
  2. package/bin/tunnel.ts +26 -0
  3. package/dist/code-editor.d.ts +3 -0
  4. package/dist/code-editor.js +59 -0
  5. package/dist/data-table.d.ts +1 -0
  6. package/dist/data-table.js +41 -1
  7. package/dist/diff.d.ts +84 -0
  8. package/dist/diff.js +307 -12
  9. package/dist/doc-browser.js +50 -10
  10. package/dist/doc-system/release-notes.d.ts +0 -1
  11. package/dist/doc-system/release-notes.js +19 -1
  12. package/dist/doc-system/render.d.ts +1 -1
  13. package/dist/doc-system/render.js +28 -1
  14. package/dist/doc-system/site/build-lock.d.ts +16 -0
  15. package/dist/doc-system/site/build-lock.js +20 -0
  16. package/dist/doc-system/site/debug-sink.d.ts +39 -0
  17. package/dist/doc-system/site/debug-sink.js +72 -0
  18. package/dist/doc-system/site/dev-auth.d.ts +47 -2
  19. package/dist/doc-system/site/dev-auth.js +70 -3
  20. package/dist/doc-system/site/dev-server.d.ts +20 -0
  21. package/dist/doc-system/site/dev-server.js +219 -19
  22. package/dist/doc-system/site/docs.d.ts +22 -0
  23. package/dist/doc-system/site/docs.js +43 -2
  24. package/dist/doc-system/site/index.d.ts +2 -0
  25. package/dist/doc-system/site/index.js +13 -0
  26. package/dist/doc-system/site/invite-page.d.ts +20 -0
  27. package/dist/doc-system/site/invite-page.js +59 -0
  28. package/dist/doc-system/site/orchestrator.js +4 -0
  29. package/dist/doc-system/site/site-config.d.ts +28 -0
  30. package/dist/doc-system/site/sourcemap-check.d.ts +19 -0
  31. package/dist/doc-system/site/sourcemap-check.js +39 -0
  32. package/dist/iife.js +81 -80
  33. package/dist/iife.js.map +14 -14
  34. package/dist/live-example/code-transform.d.ts +29 -4
  35. package/dist/live-example/code-transform.js +62 -6
  36. package/dist/live-example/component.js +22 -2
  37. package/dist/live-example/execution.js +3 -3
  38. package/dist/live-example/test-harness.js +9 -2
  39. package/dist/notifications.js +13 -3
  40. package/dist/side-nav.d.ts +9 -0
  41. package/dist/side-nav.js +36 -4
  42. package/dist/tsconfig.tsbuildinfo +1 -1
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/llms.txt +1 -1
  46. package/package.json +3 -2
  47. package/tls/create-dev-certs.sh +39 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,495 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.12.6
4
+
5
+ ### `<tosi-diff resolvable>` — "do I want to accept this?"
6
+
7
+ The diff viewer can now be operated, not just read. Set `resolvable` and every change gets a
8
+ pair of buttons; `value` is the text those choices imply and `change` fires when one moves. It
9
+ is a review surface for a **proposed** edit — an AI suggestion, a collaborator's revision, a
10
+ file that moved under you while you were editing it.
11
+
12
+ The labels are yours: `originalLabel`/`modifiedLabel`. "Mine"/"Theirs" is git's framing,
13
+ "Current"/"Proposed" suits reviewing a suggestion, and this component's own vocabulary is
14
+ original/modified — none of which is right for everyone, so none of it is hardcoded. Also
15
+ `acceptAll()`, `rejectAll()`, `changeCount`, and a `resolutions` array you can read and write.
16
+ `diffBlocks()` and `resolveDiff()` are exported too, if you want the model without the DOM.
17
+
18
+ The unit of decision is a change **block**, not a line. The LCS walk can emit
19
+ remove/add/remove/add for a single multi-line edit, and offering four independent choices there
20
+ lets a reviewer accept half an edit — producing text neither side wrote.
21
+
22
+ The read-only path is deliberately unchanged: grouping reorders interleaved edits, which is
23
+ better to resolve and a **different rendering** for the two components already shipping this
24
+ one. Additive means additive.
25
+
26
+ Two notes on how this was tested, because **both** bugs that nearly shipped were invisible to
27
+ the unit lane, and for the same underlying reason.
28
+
29
+ The first version rendered **nothing**: `dataset: {…}` in an element creator assigns
30
+ `el.dataset`, a read-only accessor, so `render()` threw and left an empty shadow root.
31
+ Twenty-six green tests sat behind it, every one asserting `value` — which is computed from the
32
+ model and is correct whether or not a single element reaches the DOM. tosijs renders on an
33
+ animation frame and happy-dom runs none, so a unit test that merely _mounts_ an element asserts
34
+ against an empty shadow root; appending hydrates, and `render()` must be called explicitly.
35
+
36
+ The second was worse, because the render-level tests added for the first one _also_ passed. The
37
+ buttons did nothing when clicked, in every browser. `<tosi-diff>` is a shadow-DOM component and
38
+ tosijs delegates `on*` handlers from above the shadow boundary, so a real click is
39
+ **retargeted**: `event.target` is the host, not the button, and `event.target.closest(…)` — the
40
+ idiom this project documents, and which is correct for a light-DOM component — is `null` every
41
+ time. The fix is `composedPath()`, the only one of the two that survives the boundary.
42
+
43
+ The happy-dom test could not see it: it called `.click()` on a node it had already looked up and
44
+ then asserted the value moved. Measured against a deliberately broken build, **30 unit tests
45
+ pass and all 3 browser tests fail** — so `tests/diff-resolve.pw.ts` now clicks what a person
46
+ clicks and reads what a host reads. The rule this leaves behind: **shadow-DOM interaction needs
47
+ a browser test**, not a DOM-shaped unit test.
48
+
49
+ ### Selective revert in live examples
50
+
51
+ The diff resolver is wired into `<tosi-example>`'s **View changes**, and into `<tosi-code>`
52
+ generally via a new `diffResolvable` (plus `diffOriginalLabel`/`diffModifiedLabel`).
53
+
54
+ Revert was all-or-nothing, which is the wrong shape for what actually happens: you tried four
55
+ things and three worked. Each change in the diff now carries keep/revert buttons — labelled
56
+ **Source** and **Yours** in an example — defaulting to _keep_, so the feature costs nothing to
57
+ ignore and doing nothing leaves your edit exactly as it was.
58
+
59
+ Choices are applied when the diff **closes**, not as you click, and a `change` event fires if
60
+ the text moved. Writing each choice straight back into `value` would feed the new text into the
61
+ overlay's `modified`, and `<tosi-diff>` resets its decisions when either input changes — so live
62
+ application would wipe the choices making it and renumber the hunks under the pointer.
63
+
64
+ ### Fixed: `<tosi-diff>` and notifications ignored dark mode
65
+
66
+ Both hardcoded a light palette — `#fff`/`#222` for the diff, `#fafafa`/`#444` for
67
+ notifications — so on a dark page you got a white block, and in the notification's case the one
68
+ element in the library that appears unbidden over whatever you are looking at was also the one
69
+ that ignored your theme.
70
+
71
+ Both now fall back to the theme (`--background`/`--text-color`) with the old literals as the
72
+ final fallback, so an unthemed page is unchanged and every `--tosi-diff-*` /
73
+ `--notification-*` override still wins exactly as before.
74
+
75
+ The diff needed both halves at once: darkening the surface alone would have been **worse** than
76
+ leaving it, because light text on a solid `#e6ffed` row is unreadable. The add/remove tints are
77
+ now translucent (`color-mix` with `transparent`), so one value reads correctly as a wash over
78
+ either surface.
79
+
80
+ ### 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))
81
+
82
+ 1.12.5 added a build lock that records `pid`, `role`, `root` and `port` per project, so a
83
+ `stop` command can identify what is running instead of `pkill`-ing sibling checkouts. It worked
84
+ — and none of it was reachable from outside the package. `import('tosijs-ui/site')` gave
85
+ `currentHolder === undefined`, and the deep path is blocked by the exports map.
86
+
87
+ `currentHolder`, `describeHolder`, `lockPathFor`, `lockDecision`, `isProcessAlive` and the
88
+ `LockHolder`/`LockDecision` types are now re-exported from `tosijs-ui/site`.
89
+
90
+ The reporter deliberately did **not** re-derive the lock path locally, and was right not to:
91
+ that means copying the FNV-1a hash of the resolved root, and if the hash ever changed the
92
+ consumer would report "nothing running" _while a server runs_. Silently-wrong is the exact
93
+ failure this whole area exists to eliminate; duplicating the algorithm reintroduces it one
94
+ layer out.
95
+
96
+ ### haltija floor raised to `^1.12.6`
97
+
98
+ The floor encodes fixes, not a date. 1.12.6 is where a `--private` instance finally gained a
99
+ **lifetime bound** (haltija#39, filed from here after one was found 12 days old at 5.7 GB and
100
+ ~150% CPU on a machine at load average 212); where **LAN and Bonjour access started working at
101
+ all** (served scripts handed the browser `localhost:<port>`, and `localhost` in a served script
102
+ means the _browser's_ machine, so a phone was told to connect to itself); where the widget sends
103
+ `X-Haltija-Token`, so a `--token` server and a page that can talk back stopped being mutually
104
+ exclusive; where results carry `paintAgeMs`, making a rAF-starved tab detectable rather than
105
+ silently wrong; and where Electron went 40.6.1 → 43.4.1, clearing two context-isolation
106
+ bypasses in the thing we spawn.
107
+
108
+ (Internal, but worth recording: our own `CLAUDE.md` documented this pin as `^1.6.1` long after
109
+ the code had moved to `^1.11.2` — a stale number in the one place a reader would trust for it.
110
+ Corrected.)
111
+
112
+ ### Docs: our haltija gate covers the bridge, not the port
113
+
114
+ The `haltijaDev: 'tunnel'` documentation read as a complete security story — session cookie,
115
+ 404-on-unauthorized, relay rather than hole — and a reader could reasonably conclude the whole
116
+ surface was gated. It is not. Enabling `haltijaDev` at all means haltija's **own** port is
117
+ running, and a default instance binds beyond loopback with `Access-Control-Allow-Origin: *` and
118
+ no token, where `POST /terminal/command` reaches a shell. Set `HALTIJA_TOKEN` on a network you
119
+ do not control; it only became genuinely usable in haltija 1.12.6, which is why older advice
120
+ said to skip it.
121
+
122
+ ## 1.12.5
123
+
124
+ ### Fixed: 49 controls a screen reader announced as "button" and "edit text"
125
+
126
+ Lighthouse scored the doc site 91 on accessibility, and the cause was entirely mechanical.
127
+ `<tosi-table>` builds a column-options button that contains only an icon, and an editable cell
128
+ that is an empty `<input>` — neither has any text to derive an accessible name from. On the
129
+ data-table page alone that is 37 unnamed buttons and 12 unnamed fields. The doc-browser's search
130
+ box made it worse in a quieter way: it had a `placeholder` and nothing else, on **every page of
131
+ every site built with `tosijs-ui/site`**. A placeholder is not a label — it is an unreliable
132
+ accessible name and it vanishes the moment anything is typed.
133
+
134
+ The controls now carry a name: `title` on the column-options button, where the tooltip is a
135
+ genuine win for sighted mouse users too, and `aria-label` on the cells and the search field,
136
+ where a tooltip on every hover would just repeat the column header as noise.
137
+
138
+ To be precise about what `title` buys, because its two halves land on different people: the
139
+ **accessible name** is real and works — `title` feeds the accessible name computation, so
140
+ VoiceOver on iOS/iPadOS announces these controls, as do NVDA and JAWS. The **visible tooltip** is
141
+ mouse-only, and a screen-reader user was never relying on it.
142
+
143
+ No visible label is added, deliberately. The vertical-more glyph is the standard overflow
144
+ affordance and reads as one; spelling it out on every column header would be clutter that makes
145
+ the header worse for everyone in order to restate what the icon already says. The defect was that
146
+ the control had no name in the accessibility tree, and that is what is fixed.
147
+
148
+ Localized tables get this through the existing `data-tosi-localized` directive rather than a
149
+ `<tosi-localized>` child, because an `<input>` cannot contain a child element. Add
150
+ **`Column Options`** to your localized strings; the editable cells reuse the column names you
151
+ already localize, so an editable table wants those present even if you are happy with
152
+ untranslated headers.
153
+
154
+ The regression test is in `tests/a11y-names.pw.ts`, and its first version **passed with both
155
+ fixes reverted** — it waited for `buttons.length > 2`, which the nav chrome satisfies on its own,
156
+ and then asserted an empty list against a page that had not built the controls yet. `toEqual([])`
157
+ is trivially true when there is nothing to check. Every assertion there now has a positive
158
+ precondition that the things under test exist, and the mutation that removes the fixes fails it
159
+ by name.
160
+
161
+ Not fixed, and not ours: the two images without `alt` on the 3D page come from Babylon's own
162
+ default loading screen.
163
+
164
+ ### Fixed: on a narrow screen, tapping a nav link left you looking at the nav
165
+
166
+ A regression from 1.12.3's full-screen work, reported from a phone-width window. The sidebar
167
+ fills the screen, you tap a link, the URL changes — and the nav stays up. The article you asked
168
+ for never appears.
169
+
170
+ Leaving full-screen had been written as the tidy mirror of entering it: `navVisible = true`. But
171
+ that setter's show-the-nav branch writes `contentVisible = false`, and it ran on **every**
172
+ navigation to a non-full-screen page — immediately after the nav click handler had set
173
+ `contentVisible = true` to do precisely the thing the reader asked for. The layout code was
174
+ overwriting a decision that belongs to the person reading.
175
+
176
+ The two directions are asymmetric now, and that is the point rather than an oversight: entering
177
+ full-screen states what it needs, and stepping out of it releases only what it claimed
178
+ (`alwaysCompact`). Which pane is showing at a narrow width is not the layout's business.
179
+
180
+ **Nothing in the test suite could have caught this.** Every layout test ran at 1400px, where
181
+ normal mode shows the nav and the content together and `contentVisible` has no visible effect —
182
+ the bug was invisible at every width the tests used. There are now narrow-viewport tests for
183
+ both directions, and the mutation that restores the old line fails them.
184
+
185
+ ### `<tosi-sidenav>` no longer races a timer to hide its own load-time animation
186
+
187
+ Its defaults are the compact arrangement — 50/50 columns, margin -100% — so the first layout pass
188
+ moves it to wherever it actually belongs. With a transition live, that move _animates_: the
189
+ sidebar visibly slides into place on load.
190
+
191
+ The suppression was `setTimeout(…, 250)`, which is a race rather than a rule — it assumes the
192
+ first successful layout pass lands within 250ms of the element upgrading. Measured here it lands
193
+ ~70ms after (chromium 236→297ms, webkit 213→291, firefox 229→304), leaving ~175ms spare, which
194
+ is exactly why this never reproduced locally. But `handleResize` returns early while
195
+ `offsetParent` is null, so on a bigger corpus or a slower device the first pass can be deferred
196
+ past the window, and then the animation is visible.
197
+
198
+ It now releases on the first pass that actually computes something, one frame later so the
199
+ corrected values paint before transitions come back. No duration is correct for every page; a
200
+ rule needs no number.
201
+
202
+ **This does not eliminate the hydration flash, and it is worth being honest about the limit.** It
203
+ removes one race. What remains is the interval between the element upgrading — at which point its
204
+ shadow defaults apply — and the first layout pass correcting them: ~70ms here, invisible, but a
205
+ real window in which the element is painted wrong rather than not painted. The principled next
206
+ step is for the defaults to _be_ the common case, and it is written up in `TODO.md` rather than
207
+ attempted blind, since the flash does not reproduce on this machine and changing paint defaults
208
+ you cannot observe is how you trade a flash you cannot see for one you can.
209
+
210
+ ### Decided: a nav-toggle override does not survive Back
211
+
212
+ Left open in 1.12.3 and now settled — **metadata wins on arrival**. A page declaring
213
+ `layout: "full-screen"` is full-screen however you reach it: a link, Back, or a fresh load. The
214
+ override is a transient viewing choice rather than a property of the document, and since the nav
215
+ button is always offered on such a page, restoring it is one tap.
216
+
217
+ Worth recording that the alternative was real. Browsers restore scroll position on Back, so "put
218
+ the view back how I left it" is an established expectation, not an exotic one. It was rejected
219
+ because the state would have to live in `history.state`, which makes the same URL behave
220
+ differently arrived-at-by-Back than clicked — trading the current surprise for a new one — and
221
+ adds state to a path that has none.
222
+
223
+ No behaviour change; the tests that pinned it now say it is settled rather than pending.
224
+
225
+ ### 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))
226
+
227
+ The screen offered only a command to run _on the machine hosting the server_. The devices a
228
+ tunnel exists for have no such keyboard — and an installed PWA has no address bar either, because
229
+ iOS gives a Home Screen app its own cookie jar and its own launch URL. An unauthenticated launch
230
+ was a black rectangle with no way out of it.
231
+
232
+ It now offers the 7-character code box directly: the same affordance as the `dev.tosijs.net`
233
+ landing page, moved to where it is actually needed. A plain GET form produces `/?t=CODE`, which is
234
+ the path the link already takes, so this needed no new server code. The input disables
235
+ autocapitalize, autocorrect and spellcheck, because the code is case-insensitive and a phone
236
+ keyboard's help is noise; there is a viewport meta, because a code box you cannot hit is not much
237
+ better than none.
238
+
239
+ **What this deliberately does not attempt** is carrying a session across the install. The
240
+ separate cookie jar is a privacy feature rather than a bug, and the issue's suggested "don't
241
+ redirect" fix would bake a live credential into a home-screen icon — working once, then failing
242
+ forever with no address bar to correct it. Seven characters is the better trade.
243
+
244
+ ### A leading metadata comment is no longer the page title ([#100](https://github.com/tonioloewald/tosijs-ui/issues/100))
245
+
246
+ The title came from line one, literally. A file opening with its metadata block — the documented
247
+ way to set `order` or `parent` — published under the title `<!--{ "order": 2 }-->`, while `order`
248
+ itself parsed correctly. So the metadata visibly worked and the title visibly did not, which is a
249
+ confusing pair to be handed. Hit while adding `CHANGELOG.md` and `Migration.md` to a doc site,
250
+ which is precisely where a leading metadata comment is most natural.
251
+
252
+ Leading HTML comments are skipped now, including ones that wrap across lines, and an
253
+ unterminated comment yields no title rather than lifting text out of it — publishing something
254
+ the author had deliberately hidden would be worse than an empty string. One rule, used by both
255
+ call sites that previously had their own copy of the line.
256
+
257
+ No URLs move: slugs derive from the filename, not the title, so an affected page gets a correct
258
+ title at the address it already had.
259
+
260
+ **The other half of that issue was already fixed**: `docPaths` entries have been in the dev
261
+ server's watch set since #49, so editing a root `Migration.md` does trigger a rebuild.
262
+
263
+ ### `debugSink` — telemetry back from a page that is not on this machine ([#99](https://github.com/tonioloewald/tosijs-ui/issues/99))
264
+
265
+ `haltijaDev` is localhost-gated for good reason: an injected drive-my-browser channel reachable
266
+ from the network is RCE-adjacent. But that leaves a page served to a headset or a phone with no
267
+ way to say **anything** — and in a WebXR session there is no console and `requestAnimationFrame`
268
+ is suspended, so every usual readback path is gone at once. The remaining channel is a human
269
+ reading an in-scene panel aloud.
270
+
271
+ `debugSink: true` accepts `POST /__debug-sink` and appends one line per event to a JSONL file
272
+ whose path it prints at startup, so an agent can `tail -f` it:
273
+
274
+ ```javascript
275
+ navigator.sendBeacon('/__debug-sink', JSON.stringify({ t: Date.now(), tag, ...event }))
276
+ ```
277
+
278
+ `sendBeacon` is the point — it needs no headers, reads no response, and survives the page going
279
+ away.
280
+
281
+ **Why this is reachable off-loopback when `/__docstore/source` is not.** It appends opaque bytes
282
+ to a scratch file, outside the repo, that the build never reads and nothing serves back. No code
283
+ runs; nothing is reconfigured. The source endpoint is remote code execution by design and stays
284
+ gated. This is a log.
285
+
286
+ Still **off by default**: an unauthenticated write endpoint on a LAN-reachable server should be a
287
+ decision you made rather than one you inherited. Bounded so a looping page cannot fill a disk —
288
+ bodies over 64 KB refused (413), the file stops at 32 MB (507). Newlines in a payload are
289
+ escaped, because one event must be one line or `tail -f` shows a split event and the reader
290
+ cannot tell.
291
+
292
+ ### The dev cert covers the machine's LAN IP ([#92](https://github.com/tonioloewald/tosijs-ui/issues/92))
293
+
294
+ Every name the cert carried was useful only _on_ this machine, except `<host>.local` — which is
295
+ precisely the one another device is most likely to fail to resolve. Reported from a Quest:
296
+ `tosi.local` resolved to **IPv6 only**, including a link-local `fe80::…` a headset gets and
297
+ cannot route, while the LAN IP worked. The host never sees it, because it resolves mDNS
298
+ natively, so it fails only on the devices the name was added for.
299
+
300
+ Private ranges only (10/8, 172.16/12, 192.168/16) — a public address on a dev cert would be a
301
+ name this machine has no business claiming. A DHCP lease change moves the address and the cert
302
+ does not follow; the script says so, and prints the URLs another device can use.
303
+
304
+ Note this fixes the **name mismatch**, not the untrusted CA: mkcert's root still is not on the
305
+ headset, so you either install it there or use the tunnel, which has a real certificate.
306
+
307
+ ### A dropped session says the server restarted, instead of blaming your cookies ([#114](https://github.com/tonioloewald/tosijs-ui/issues/114))
308
+
309
+ Sessions live in memory and die with the process. That is the design, not an oversight: a
310
+ credential should not outlive the thing that granted it, and nothing about a session — or the
311
+ token exchanged for it — is ever written to disk. **That has not changed and will not.**
312
+
313
+ What was wrong is that a cookie from a previous run and a cookie the server had never seen
314
+ produced the _identical_ screen. So a reader whose dev server had restarted was told "invite
315
+ links expire", and reasonably concluded their cookie was expiring — the one explanation the
316
+ evidence ruled out. The reporter's own words: the misleading failure "is the part I would most
317
+ want fixed".
318
+
319
+ The cookie now carries a per-process boot id (`<bootId>.<token>`), so the next process can tell a
320
+ cookie it _issued_ from one it has never seen. Both are still refused, identically; only the
321
+ sentence differs:
322
+
323
+ > **The dev server restarted, so your session ended with it.** Sessions are held in memory on
324
+ > purpose — they never outlive the process that issued them, and they are never written to disk.
325
+ > Your browser still holds the cookie; there is simply nothing on this side to match it to.
326
+
327
+ The prefix is not a credential and admits nobody: a cookie with the right run id and a wrong
328
+ token is refused exactly as before, and there is a test asserting that. An absent or malformed
329
+ cookie is _not_ reported as a restart — claiming one we cannot evidence would be its own lie.
330
+
331
+ ### A bundle that references an unserved sourcemap now says so at build time ([#103](https://github.com/tonioloewald/tosijs-ui/issues/103))
332
+
333
+ `--sourcemap=linked` appends a `sourceMappingURL` comment, so devtools fetches that file on every
334
+ load. When the map is not in the served root the fetch fails — in exactly the session where
335
+ someone is reading the console carefully for something else. It cost the reporter a wrong
336
+ hypothesis while chasing a slow load.
337
+
338
+ The uglier half was already fixed by #116 in this same release: the SPA fallback used to answer
339
+ `.map` requests with the **HTML shell**, so devtools received a web page where a JSON map should
340
+ be. Missing assets 404 now.
341
+
342
+ The remaining half is a build-time warning naming the file and where it was expected. It checks
343
+ what is **served**, not what was built — those differ whenever `bundleOutDir` is set, and the
344
+ copy across is best-effort by design, so trusting the step is what let this ship. It warns rather
345
+ than fails: a map is a debugging convenience and no site should fail to build without one.
346
+
347
+ The check is a pure, tested function rather than an inline block, because the branch it lives in
348
+ is one **this repo's own build never takes** — our bundle comes from `bin/dev.ts`, not from
349
+ `buildSite`. That is exactly why the bug reached an adopter instead of us.
350
+
351
+ ### `resolveUnder` asserts path containment instead of inheriting it ([#96](https://github.com/tonioloewald/tosijs-ui/issues/96))
352
+
353
+ Defence in depth: there is no live traversal, and the reporter checked the classes carefully —
354
+ `/../../../etc/passwd`, percent-encoded and double-encoded forms all stay inside the served root
355
+ today. But they stay inside for two reasons the static handler does not own: the WHATWG URL
356
+ parser collapses `../` before `.pathname` is read, and `.pathname` is never the raw request line.
357
+ Both are properties of the **caller**, so a future call with a path from a config value, a
358
+ manifest entry or a header inherits nothing. The asserted version already lived ten lines away
359
+ in the same file.
360
+
361
+ Now an exported, tested predicate — a test that went through the server would prove nothing,
362
+ since the URL parser makes a violation unreachable from outside.
363
+
364
+ ### `bun run stop` — stop THIS project's dev server, not every one on the machine ([#117](https://github.com/tonioloewald/tosijs-ui/issues/117))
365
+
366
+ There was no way to stop a dev server, so what everyone reached for was
367
+ `pkill -f 'bun bin/dev.ts'` — which matches **every** dev server on the machine, because every
368
+ project on this pipeline runs an identical command line. A sibling checkout, a worktree or
369
+ another agent's session died to a command that reads as "restart mine". Observed five times in
370
+ one working day, twice while a tunnel link was in use by a remote reviewer.
371
+
372
+ The record needed for this already existed: the build lock stores `pid`, `port` and `root`,
373
+ per-project, with staleness decided by liveness rather than age. It needed a reader and a
374
+ command. `bun run stop` (or `bun bin/dev.ts --stop`) signals that pid and nothing else, and says
375
+ plainly when nothing is running.
376
+
377
+ ### The dev server can no longer die silently ([#91](https://github.com/tonioloewald/tosijs-ui/issues/91))
378
+
379
+ Reported after a ~9.4h run: `pgrep` said running, `curl` got nothing, the last log line was a
380
+ **successful** build, and there was no error, signal or exit anywhere. Every diagnostic a person
381
+ would reach for answered "fine"; it was found the next day because a human said the site was
382
+ down.
383
+
384
+ Worth correcting the report's own diagnosis, because it does not survive the evidence: the idle
385
+ path **announces itself** before exiting, and no such line was logged — so the idle timeout is
386
+ not what stopped that listener. What did is still unknown, which is exactly why the fix targets
387
+ the symptom rather than a cause.
388
+
389
+ The health tick now probes the server's own listener (`GET /__alive`, a bare 204 that
390
+ deliberately does **not** count as activity — a self-check that did would hold the idle timer
391
+ open forever). Two consecutive failures and it exits non-zero with an explanation. A process that
392
+ cannot serve has no value, and the one useful thing left to it is to stop claiming to be alive.
393
+
394
+ ### `tosijs-tunnel --link` stops printing links that cannot work ([#94](https://github.com/tonioloewald/tosijs-ui/issues/94))
395
+
396
+ `--link` asks the dev server for a token, which succeeds whether or not a tunnel exists. The URL
397
+ is well-formed and the token is real — it just answers 503, because nothing is listening
398
+ upstream. Printed with exit 0 and no comment, that is indistinguishable from the far side being
399
+ down.
400
+
401
+ It still prints the link, since you may be about to start the tunnel, but it now says so and
402
+ exits non-zero, so a script treating 0 as "a usable link was produced" is telling the truth.
403
+
404
+ ### 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))
405
+
406
+ Context keys become **function parameter names** — the runner builds each block as
407
+ `new Fn(...contextKeys, body)`. `test-harness.ts` carried its own copy of the rule that turns a
408
+ key into an identifier, `key.replace(/-/g, '')`, which strips hyphens and leaves slashes and
409
+ `@`. So an ordinary specifier like `'tosijs-3d/demo-utils'` became the parameter name
410
+ `tosijs3d/demoutils`, and **every test in that file died before one of them ran** with V8's
411
+ `Arg string terminates parameters early`. The examples on the same page rendered fine, because
412
+ they went through the shared sanitiser — so nothing looked wrong except a red badge.
413
+
414
+ One implementation now, in `code-transform.ts`, used by all five call sites. It also handles
415
+ what the shared version had never needed to: a leading digit (`'3d-tools'`), a reserved word
416
+ (`'class'`), and a key that reduces to nothing.
417
+
418
+ **Two keys that reduce to the same identifier now throw, by name.** Quietly suffixing the second
419
+ would leave one module bound to a name no rewritten import references — undefined at runtime with
420
+ nothing to read. The message says which two keys and what to do:
421
+
422
+ ```
423
+ example context keys "tosijs-3d" and "tosijs/3d" both reduce to the identifier "tosijs3d",
424
+ so an import from one of them could not be told from the other. Rename one of the context keys.
425
+ ```
426
+
427
+ There is also a structural test asserting **nothing outside `code-transform.ts` sanitises a
428
+ context key**. That is the one that would have caught this: every unit test of the shared helper
429
+ passes with the divergent copy restored, because they exercise the helper rather than the call
430
+ site — which is exactly how the bug survived.
431
+
432
+ ### A missing asset 404s instead of impersonating a page ([#116](https://github.com/tonioloewald/tosijs-ui/issues/116))
433
+
434
+ The dev server fell back to the SPA shell for **any** unknown path, so a missing
435
+ `waterbump.png` answered `200 text/html`. Babylon's `Texture` fetched it, failed to decode a web
436
+ page as an image, and substituted its checkerboard — a missing asset presented as a _styling
437
+ choice_, and was complimented before it was diagnosed.
438
+
439
+ Unknown **routes** still get the shell, because client-side routing depends on it. The
440
+ distinguishing signal is a dot in the last path segment: an extension was asked for, so a file
441
+ was meant. `/v1.2/guide` is still a route; `/thing.png` is not. The 404 names the path, because
442
+ a bare "File not found" sends people to the wrong layer — the reporter went through their
443
+ texture pipeline before suspecting the server.
444
+
445
+ ### `staticDirs` are watched, so a replaced asset is not served stale ([#110](https://github.com/tonioloewald/tosijs-ui/issues/110))
446
+
447
+ `buildSite` copies `staticDirs` into the output on every build, but nothing watched them — so
448
+ re-exporting a GLB over `static/model.glb` left the previous copy served indefinitely, with no
449
+ error and no hint. Both files exist and only their contents differ, which is the hardest version
450
+ of stale to see, and the workaround people find is touching a source file to provoke a rebuild.
451
+
452
+ Same shape as #49, where `docPaths` was the omission. Verified end to end: a replaced static file
453
+ is now picked up in about half a second.
454
+
455
+ ### A lone custom element is no longer wrapped in a paragraph ([#115](https://github.com/tonioloewald/tosijs-ui/issues/115))
456
+
457
+ Reported by tosijs-3d: a doc page whose entire content is one custom element — the case
458
+ `layout: "full-screen"` exists for — got an element that would not fill. marked classifies raw
459
+ HTML by tag name and cannot know whether an unknown tag is block or inline, so
460
+ `<my-editor></my-editor>` alone on a line came out as `<p><my-editor></my-editor></p>`. That
461
+ paragraph is auto-height, so `height: 100%` resolved against a **33px** box inside an 842px
462
+ content area.
463
+
464
+ Measured before and after on the same page: 33px, then **842 of 842** on all three engines. The
465
+ comment promising "a definite height, so a child asking for `height: 100%` gets one" is finally
466
+ true rather than aspirational.
467
+
468
+ The unwrap is deliberately narrow, since a heuristic here earns its own bugs: it fires only when
469
+ a paragraph's entire content is a single element whose tag name contains a hyphen — a custom
470
+ element, the one case marked provably cannot classify. `<p><img></p>`, `<p><em>text</em></p>`
471
+ and `<p>see <my-thing></my-thing> here</p>` are all left exactly as they are, and mutation
472
+ testing covers both directions — unwrapping everything fails three tests, unwrapping nothing
473
+ fails one.
474
+
475
+ ### The inline doc tier runs at two widths now
476
+
477
+ The same gap, one level down: the doc-test runner's iframe was hardcoded to 800x600, so **every
478
+ inline `test` block had only ever run wide**. Its size is now overridable, and the Playwright
479
+ doc-test spec runs the whole tier a second time at 390x844.
480
+
481
+ An iframe is a real viewport, so this is not a simulation — verified that
482
+ `matchMedia('(max-width: 600px)')` matches inside a 390px frame and not a 1200px one, and that
483
+ the override reaches the runner (390px frame, 386 inner; 800/796 without it).
484
+
485
+ **Widths only, deliberately.** Touch cannot be emulated from inside a page: an iframe inherits
486
+ the host's input characteristics, so `(pointer: coarse)` stays false and `maxTouchPoints` stays 0
487
+ however small the frame is. `navigator.maxTouchPoints` _can_ be redefined, and doing so would be
488
+ worse than skipping it — the property would lie while the media queries told the truth, so a test
489
+ could pass against a page behaving as desktop. Touch belongs to a Playwright context
490
+ (`hasTouch`, `devices[…]`); noted in `TODO.md` as worth its own spec, since everything
491
+ touch-shaped here is currently tested only with a mouse.
492
+
3
493
  ## 1.12.4
4
494
 
5
495
  ### `.tjs` source files are documented again ([#108](https://github.com/tonioloewald/tosijs-ui/issues/108))
package/bin/tunnel.ts CHANGED
@@ -191,6 +191,32 @@ if (has('link')) {
191
191
  `\n🔗 Edit link:\n ${url}\n` +
192
192
  (code ? `\n code: ${code} (case-insensitive)\n` : '')
193
193
  )
194
+
195
+ /*
196
+ A link nobody can reach is worse than no link.
197
+
198
+ `--link` asks the dev server for a token, which succeeds whether or not a tunnel exists — the
199
+ URL is well-formed and the token is real, it just resolves to a 503 from the reverse proxy
200
+ because nothing is listening upstream. Printed with exit 0 and no comment, that is
201
+ indistinguishable from the far side being down, and the reporter lost a chunk of an afternoon
202
+ to the difference (#94).
203
+
204
+ Checked AFTER printing, deliberately: the link is still the thing you asked for, and you may
205
+ be about to start the tunnel. What changes is that the output says so and the exit code agrees
206
+ — a script that treats 0 as "a usable link was produced" is now telling the truth.
207
+
208
+ `running()` is the same predicate `--status` uses, matching this project's own forward by its
209
+ exact ports and host rather than by an argv substring.
210
+ */
211
+ const forwards = await running()
212
+ if (forwards.length === 0) {
213
+ console.error(
214
+ `⚠️ No tunnel is running, so that link will answer 503.\n` +
215
+ ` The token is real — nothing is listening on the far side yet.\n` +
216
+ ` Start one with \`tosijs-tunnel\` (or \`bun run tunnel\`), then use the link.\n`
217
+ )
218
+ process.exit(1)
219
+ }
194
220
  process.exit(0)
195
221
  }
196
222
 
@@ -30,6 +30,9 @@ export declare class CodeEditor extends WebComponent<CodeEditorParts> {
30
30
  private diffOverlay;
31
31
  private _pendingDiff;
32
32
  get showingDiff(): boolean;
33
+ diffResolvable: boolean;
34
+ diffOriginalLabel: string;
35
+ diffModifiedLabel: string;
33
36
  showDiff(on: boolean): void;
34
37
  static initAttributes: {
35
38
  mode: string;
@@ -24,6 +24,8 @@ body {
24
24
  | `mode` | `javascript`, `typescript`, `tjs`, `ajs`, `css`, `html`, `markdown` |
25
25
  | `disabled` | makes the editor read-only |
26
26
  | `original` + `showDiff(on)` | diff the current `value` against a baseline, as an overlay |
27
+ | `diffResolvable` | make that overlay **resolvable**: each change gets keep/revert buttons, and the choices are applied to `value` when the diff closes |
28
+ | `diffOriginalLabel` / `diffModifiedLabel` | what the two buttons say (default `Original`/`Modified`) |
27
29
  | `editor` | the underlying CodeMirror [`EditorView`](https://codemirror.net/docs/ref/#view.EditorView) (`undefined` until loaded) |
28
30
  | `undo()` / `redo()` / `canUndo()` / `canRedo()` | history control |
29
31
  | `tjsAutocomplete` | runtime-value autocomplete hooks (tjs mode) — see below |
@@ -38,6 +40,29 @@ runtime values** — including proxy members no static analysis can see:
38
40
  codeEl.tjsAutocomplete = { getLiveBindings: () => ({ app, elements }) }
39
41
  ```
40
42
 
43
+ ## Selective revert
44
+
45
+ `showDiff(on)` shows a read-only diff. Set `diffResolvable` first and the overlay becomes a
46
+ review surface instead: every change carries keep/revert buttons, defaulting to **keep**, and
47
+ whatever you leave alone survives. This is what `<tosi-example>`'s **View changes** uses — you
48
+ tried four things and three worked, so revert the one you regret rather than all four.
49
+
50
+ The choices are applied when the diff **closes**, not as you click, and a `change` event fires
51
+ if the text moved. That is deliberate: writing each choice straight back into `value` would
52
+ feed the new text into the overlay's `modified`, and `<tosi-diff>` resets its decisions when
53
+ either input changes — so live application would wipe the choices making it and renumber the
54
+ hunks under the pointer.
55
+
56
+ ```typescript
57
+ codeEl.original = savedSource
58
+ codeEl.diffResolvable = true
59
+ codeEl.diffOriginalLabel = 'Source'
60
+ codeEl.diffModifiedLabel = 'Yours'
61
+ codeEl.showDiff(true)
62
+ // …user reverts a hunk or two…
63
+ codeEl.showDiff(false) // codeEl.value now reflects their choices
64
+ ```
65
+
41
66
  ## Bundling
42
67
 
43
68
  CodeMirror is a **lazy chunk**: with a bundler (ESM), a page that never uses
@@ -154,6 +179,17 @@ export class CodeEditor extends WebComponent {
154
179
  return this._pendingDiff ?? false;
155
180
  return !this.parts.diffHost.hidden;
156
181
  }
182
+ /*
183
+ Make the diff overlay RESOLVABLE, turning it from a review into selective revert.
184
+
185
+ Off by default, so `showDiff()` stays exactly what it was. With it on, every change gets
186
+ keep/revert buttons and the ones you leave alone are kept — which is the useful default when
187
+ the "modified" side is your own edit: you are reverting the two lines you regret, not
188
+ re-accepting the twenty you meant.
189
+ */
190
+ diffResolvable = false;
191
+ diffOriginalLabel = 'Original';
192
+ diffModifiedLabel = 'Modified';
157
193
  showDiff(on) {
158
194
  if (!this.hydrated) {
159
195
  this._pendingDiff = on;
@@ -165,9 +201,32 @@ export class CodeEditor extends WebComponent {
165
201
  this.diffOverlay = tosiDiff();
166
202
  diffHost.append(this.diffOverlay);
167
203
  }
204
+ this.diffOverlay.resolvable = this.diffResolvable;
205
+ this.diffOverlay.originalLabel = this.diffOriginalLabel;
206
+ this.diffOverlay.modifiedLabel = this.diffModifiedLabel;
168
207
  this.diffOverlay.original = this.original;
169
208
  this.diffOverlay.modified = this.value;
170
209
  }
210
+ else if (this.diffOverlay !== undefined && this.diffResolvable) {
211
+ /*
212
+ Resolutions land on the way OUT, not as you click.
213
+
214
+ Writing each choice straight back into `value` would feed the editor's new text into the
215
+ overlay's `modified`, and `<tosi-diff>` resets its decisions when either input changes —
216
+ by design, because choice #2 of the old diff is not choice #2 of the new one. Live
217
+ application would therefore wipe the very choices making it, and the hunks would
218
+ renumber under the pointer. Applying once, when the diff closes, sidesteps that
219
+ completely and matches how a reviewer thinks: decide, then commit.
220
+
221
+ The `change` event matters as much as the value: it is how a host recomputes "does this
222
+ still differ from the source" after a partial revert.
223
+ */
224
+ const resolved = this.diffOverlay.value;
225
+ if (resolved !== this.value) {
226
+ this.value = resolved;
227
+ this.dispatchEvent(new CustomEvent('change', { detail: { value: resolved } }));
228
+ }
229
+ }
171
230
  diffHost.hidden = !on;
172
231
  }
173
232
  static initAttributes = {
@@ -341,6 +341,7 @@ export declare class TosiTable extends WebComponent {
341
341
  sortByColumn: (columnOptions: ColumnOptions, direction?: "ascending" | "descending" | "auto") => void;
342
342
  popColumnMenu: (target: HTMLElement, options: ColumnOptions) => void;
343
343
  get captionSpan(): ElementCreator;
344
+ labelAttrs(key: string, attr?: string): Record<string, string>;
344
345
  get visibleRows(): any[];
345
346
  get visibleSelectedRows(): any[];
346
347
  get selectedRows(): any[];