tosijs-ui 1.12.0 → 1.12.2

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,225 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.12.2
4
+
5
+ ### `haltijaDev: 'tunnel'` — let an agent drive a page running in a headset (#104)
6
+
7
+ `haltijaDev: true` is localhost-gated, and that gate is exactly what stopped the feature
8
+ working where it is worth the most. Debugging in a VR headset is close to blind: no readable
9
+ console, no devtools, no way to inspect the DOM without taking it off — and taking it off is
10
+ the thing you cannot do, because the bug is usually _about_ being in it.
11
+
12
+ Set `haltijaDev: 'tunnel'` and the channel is also served **same-origin over the tunnel**.
13
+ Redeem an edit link on the device, load the page, and it shows up in `hj windows` like any
14
+ other — `hj eval`, `hj navigate`, `hj tree` all work against it, and `hj` never learns the
15
+ page is remote.
16
+
17
+ **Why it works at all, and why it needed no change from haltija.** The page **dials out**:
18
+ `component.js` reads `window.__haltija_config__.serverUrl` and falls back to localhost only
19
+ when that config is absent. So nothing has to reach _into_ a headset — the dev server sets
20
+ the config itself, serves the component from the page's own origin, and relays the WebSocket
21
+ to the local channel. The upstream `dev.js`/`inject.js` chain is skipped entirely; both files
22
+ carry their own localhost gates and all three of their hardcoded `https://localhost:8701`
23
+ URLs would resolve on the **headset** rather than on your machine.
24
+
25
+ **A separate opt-in, deliberately.** `true` means "an agent may drive the page on this
26
+ machine". Upgrading must never silently turn that into "wherever it is reachable", so
27
+ `'tunnel'` is its own value — and `HALTIJA_DEV=1` does **not** enable it, because that
28
+ variable is a convenience toggle and this is not a convenience.
29
+
30
+ **What authorizes it:** a live dev session cookie — the same credential that gates source
31
+ writes, via the same predicate (`mayDriveWithAgent` delegates to `mayWriteSource`, and a test
32
+ asserts they agree on every input). Driving a page with an agent is at least as powerful as
33
+ writing source, so it gets no weaker a gate. What authorizes is the **listener**, never the
34
+ peer address: a reverse tunnel counterfeits "local" by construction. Unauthorized requests
35
+ get **404**, not 403 — whether a project enabled the bridge is nobody's business but the
36
+ session holder's.
37
+
38
+ Verified end to end rather than by inspection: a real browser on the tunnel origin stores the
39
+ session, receives the loader, resolves `serverUrl` to its own origin (not `localhost:8701`),
40
+ attaches the widget, opens the relayed socket, appears in `hj windows`, and answers `hj eval`
41
+ and `hj navigate`.
42
+
43
+ ### Dragging works on Firefox now — every draggable, not just table columns ([#107](https://github.com/tonioloewald/tosijs-ui/issues/107))
44
+
45
+ **The first drag on a freshly-loaded page did not work in Firefox.** The thing you grabbed
46
+ stayed put and random text highlighted instead; it only started behaving once you had clicked
47
+ somewhere — anywhere — to settle Firefox's selection state. Land on a page and drag a column
48
+ edge, which is an entirely ordinary thing to do, and the first attempt failed.
49
+
50
+ This affected **every** `trackDrag` consumer — `<tosi-sizer>`, `<tosi-float>`,
51
+ `<tosi-editable-rect>` and `<tosi-table>` column resizing — and had done since the drag tracker
52
+ landed in **v0.5.1**. Measured with no prior click: `<tosi-sizer>` did not move at all, a table
53
+ column applied one 12px step and froze. All three engines now behave identically (150 → 210 →
54
+ 270 on the same gesture, and nothing selected).
55
+
56
+ Two parts, because either alone does nothing. `trackDrag` calls `preventDefault()` on the
57
+ initiating mousedown, which is narrow rather than blunt — it only ever runs once a component has
58
+ already decided a drag is starting, so ordinary clicks, focus and selection are untouched. And
59
+ `<tosi-sizer>`, `<tosi-float>` and `<tosi-editable-rect>` had to stop registering their
60
+ **mousedown** listeners as `passive`, because a passive listener makes `preventDefault` a silent
61
+ no-op — which is exactly why the table (whose listener was never passive) improved first while
62
+ the others stayed dead. Nothing is lost: `passive`'s practical value is silencing the console
63
+ warning about handlers that delay scrolling, and that warning only ever applied to touch and
64
+ wheel. `touchstart` stays passive.
65
+
66
+ It is Firefox behaving badly, and that is a diagnosis rather than an excuse — the workaround is
67
+ cheap and users do not care whose bug it is.
68
+
69
+ ### Page metadata: `layout: full-width`, so a route can leave the reading column ([#105](https://github.com/tonioloewald/tosijs-ui/issues/105))
70
+
71
+ Prose wants a measure — 44em is roughly what people read comfortably, and it stays the default.
72
+ But a demo, a dashboard, a wide table or a canvas is _worse_ squeezed into a column, and having
73
+ to pick one habit for the whole site is what makes people build a second site. Now a page can
74
+ opt out, the same way it sets `pin` or `order`:
75
+
76
+ ```markdown
77
+ <!--{ "layout": "full-width" }-->
78
+ ```
79
+
80
+ or `layout: full-width` in YAML frontmatter. **This project's own README uses it.**
81
+
82
+ Stamped into the served HTML rather than applied on load, so the first paint is already right —
83
+ a layout applied by script would show the reading column and snap wide, the flicker
84
+ pre-rendering exists to prevent. Client-side navigation keeps it in step **in both directions**;
85
+ that second direction is the one a naive implementation forgets, and it has its own test
86
+ (mutation-verified: dropping the clear-on-navigate fails it, dropping the static stamp fails
87
+ three more including first paint).
88
+
89
+ **`layout: "full-screen"` is not implemented and is rejected with a message rather than
90
+ half-honoured.** It needs a collapsed state on `<tosi-side-nav>`: after hydration the nav
91
+ carries an inline `display`, and side-nav sets its width as an inline property, so a stylesheet
92
+ cannot override either without `!important` — and hiding side-nav itself hides the content too,
93
+ since it wraps both. A value that dropped the measure while leaving the chrome exactly where it
94
+ was would be reported as a bug, correctly. Tracked in `TODO.md`.
95
+
96
+ ### `<tosi-table>`: a column drag no longer dies when `columns` is reassigned under it
97
+
98
+ The other half of the reporting app's "resize funkiness", and a different defect from #102's
99
+ torn grid — this one is not a mismatch at all. `resizeColumn` captured the `ColumnOptions`
100
+ object and mutated it for the whole drag, and that object stops being the table's the instant a
101
+ caller assigns a new `columns` array. Which the app does on a page-size change, and a page-size
102
+ change is a very likely thing to happen while someone is dragging a column edge. From then on
103
+ the drag wrote widths into an orphan: pointer moving, column not moving, nothing reported.
104
+
105
+ The drag now re-resolves the column by `prop` each step — ending cleanly if it is gone, and
106
+ rebasing on the new width if the object was merely replaced. Confirmed pre-existing rather than
107
+ introduced by 1.12.1: reverting that fix leaves the drag equally dead.
108
+
109
+ ### Known: column resize does not work on Firefox at all ([#107](https://github.com/tonioloewald/tosijs-ui/issues/107))
110
+
111
+ Found while investigating the above, and it is the bigger problem. On Firefox a **plain** column
112
+ drag — no reassignment anywhere near it — applies one step and freezes: +60px reaches 162 and
113
+ +120px still reads 162, against 210 then 270 on chromium and webkit. A document-level capture
114
+ listener shows Firefox stops delivering `mousemove` to the page entirely after the first event,
115
+ so no callback can run.
116
+
117
+ The cause looks to be `trackDrag` listening on a full-screen overlay while Firefox keeps
118
+ implicit mouse capture on the element that received the `mousedown`. The fix is Pointer Events
119
+ with explicit capture, and `trackDrag` is shared by every draggable affordance — so it wants
120
+ doing deliberately, with the others re-tested on Firefox at the same time, rather than as a
121
+ footnote to this release. Ruled out first: it is not the DOM being rebuilt under the drag
122
+ (deferring renders changes nothing) and not #102 (reverting that changes nothing).
123
+
124
+ ### `tosijs-release-notes`: the bump gate stops blocking on dev tooling
125
+
126
+ Two fixes, both hit while cutting this release — which is the only way anyone finds them.
127
+
128
+ **A dev-tooling path with a security surface now WARNS instead of blocking a patch.** Every
129
+ path on that list is development tooling — a dev server, a tunnel, a deploy script — and none
130
+ of it runs in an end user's browser as part of an adopter's app. Halting a release over it
131
+ spends the maintainer's attention on the release with the least at stake, and a guard that
132
+ cries wolf gets overridden or deleted, taking the case it was _right_ about with it (#79, a
133
+ loosened dev-server default shipped as a patch). The warning keeps that signal and stops
134
+ charging for it. A gate that genuinely should block would have to key on code an adopter
135
+ **ships**, which none of these are.
136
+
137
+ **The blocking branch offered advice it could not honour.** It said "explain in the notes why
138
+ this does not reach anyone" — but `bumpConcerns` never sees the notes, so nothing you wrote
139
+ could satisfy it, and the only way forward was to work that out by reading the tool's own
140
+ source. Same defect this project already fixed in the tunnel's 401 page. It now says "cut a
141
+ minor", which is the only thing that ever worked.
142
+
143
+ ### Security-relevant paths that changed, and why this is still a patch
144
+
145
+ `dev-auth.ts` — the module that decides who may do privileged things — changed in this
146
+ release, and a patch touching it deserves a heading rather than a footnote.
147
+
148
+ **The change is purely additive, and that is checkable rather than asserted.** The diff against
149
+ 1.12.1 removes **zero lines**, and `mayWriteSource` is **byte-identical** to 1.12.1. What was
150
+ added is one new exported function, `mayDriveWithAgent`, whose entire body delegates to
151
+ `mayWriteSource` — with a test asserting the two agree on every combination of `viaTunnel`,
152
+ `hasValidSession` and peer address, so they cannot drift apart later without that test going
153
+ red.
154
+
155
+ **Nothing reaches an existing adopter.** The new predicate is consulted only by the
156
+ `/__haltija/` routes, which exist only when `haltijaDev: 'tunnel'` is set. Every existing
157
+ authorization decision — source writes, link minting, session validation — is executed by the
158
+ same bytes as in 1.12.1. If you have not opted in, the reachable behaviour of this release is
159
+ identical to 1.12.1.
160
+
161
+ **What it would mean if you do opt in:** a live dev session cookie becomes sufficient to let an
162
+ agent drive your page over the tunnel, in addition to editing source. That is a real widening
163
+ of what a session can do, which is why it is a distinct config value and not implied by
164
+ `haltijaDev: true`.
165
+
166
+ **A patch, not a minor**, despite reading like a feature: it is a new optional _value_ on an
167
+ existing config field, plus routes that exist only under it. `haltijaDev: true` behaves exactly
168
+ as before and nothing is removed or renamed, so the contract a patch carries — it does not
169
+ break you — holds.
170
+
171
+ ## 1.12.1
172
+
173
+ ### The Playwright lane no longer fights itself
174
+
175
+ Local workers are pinned to **6** rather than Playwright's default (~half the cores, 9 here).
176
+ Every engine had taken a turn producing a `page.goto` timeout or a `docs.json` load failure —
177
+ roughly one full run in five, always passing in isolation — because 9 browsers were hitting one
178
+ dev server. 1.11.0 fixed the client half (`fetchCorpus` retries); this reduces the contention
179
+ instead of surviving it.
180
+
181
+ Measured, not guessed: 9 workers ran the suite in 82s and 6 in 81s, so the lane is not
182
+ CPU-bound at 9 and a third of the load is free; 4 workers costs 106s and buys nothing further.
183
+ 6/6 clean afterwards. Contributor-facing only — CI already runs at 1 worker.
184
+
185
+ ### `<tosi-schema-form>`: all three perf guards, and they cover different things
186
+
187
+ The keystroke perf guard counts DOM lookups, and provably could not see `syncValues` — which
188
+ runs on add/remove-item, not on keystrokes. A root-scoped per-field scan reintroduced there
189
+ passed the existing test. There is now a sibling test on that path; mutation-testing takes it
190
+ from 1 lookup per add to 41 → 161 as field count grows, while the keystroke test stays green
191
+ under the same mutation.
192
+
193
+ Wall-clock is back alongside them, too. It was removed in 1.11.0 for flaking; with the lane
194
+ quiet the isolated and loaded distributions now overlap (3.20-4.57 vs 2.64-4.14 across all
195
+ three engines, against 4.0 for linear and 16 for quadratic), so a threshold of 8 separates
196
+ them. It earns its place by catching what counting cannot: an O(N) scan per field that uses no
197
+ selector at all fails it at 9.63x while both count guards pass.
198
+
199
+ ### `<tosi-table>`: the header and body can no longer describe different columns (#102)
200
+
201
+ Reported from a host app that changes column visibility when the page size changes. `set
202
+ columns` updates the column list **synchronously** but defers the rebuild to the next frame,
203
+ so between the assignment and that frame anything recomputing widths — a resize drag, a scroll
204
+ handler, or the host's own page-size code — wrote a grid template for the NEW columns over the
205
+ cells the OLD ones had built.
206
+
207
+ Why that reads as _disagreement_ rather than a missing column, which is what made it confusing
208
+ to report: with fewer tracks than cells, CSS Grid auto-places the surplus into **implicit**
209
+ tracks, and implicit tracks size to content. Header text and body text differ, so the two rows
210
+ resolve them to different widths and the columns visibly step apart.
211
+
212
+ `setColumnWidths()` now describes the column set the DOM was actually built from, refreshed by
213
+ `render()` on the frame that installs those cells. A column change is therefore **deferred, not
214
+ dropped** — and because column objects are shared by reference, a resize drag still moves
215
+ widths immediately: it is the shape that is pinned to the DOM, not the widths. All three
216
+ properties have tests, and the fix is mutation-verified (reverting it fails with
217
+ `tracks: 3, headerCells: 4`).
218
+
219
+ The caller arguably should not reassign columns mid-render. It does not matter — a table that
220
+ can show a header and a body describing different columns is our defect whenever the
221
+ assignment lands.
222
+
3
223
  ## 1.12.0
4
224
 
5
225
  ### tosijs-ui is Apache-2.0 as of this release (MIT through 1.11.1)
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # tosijs-ui
2
2
 
3
- <!--{ "pin": "top", "headTitle": "tosijs-ui — robust, standards-based web components", "description": "A small, fast library of standards-based web components (data tables, dialogs, forms, rich text, carousels, and more) built on tosijs — smaller and faster than a React app, with no virtual DOM.", "keywords": "web components, custom elements, tosijs, data table, dialog, form, rich text, no virtual dom" }-->
3
+ <!--{ "pin": "top", "layout": "full-width", "headTitle": "tosijs-ui — robust, standards-based web components", "description": "A small, fast library of standards-based web components (data tables, dialogs, forms, rich text, carousels, and more) built on tosijs — smaller and faster than a React app, with no virtual DOM.", "keywords": "web components, custom elements, tosijs, data table, dialog, form, rich text, no virtual dom" }-->
4
4
 
5
5
  [ui.tosijs.net live demo](https://ui.tosijs.net) | [tosijs](https://tosijs.net) | [discord](https://discord.gg/ramJ9rgky5) | [github](https://github.com/tonioloewald/tosijs-ui#readme) | [npm](https://www.npmjs.com/package/tosijs-ui)
6
6
 
@@ -159,9 +159,16 @@ if (has('check')) {
159
159
  )
160
160
  for (const e of c.evidence) console.error(` ${e}`)
161
161
  console.error(
162
+ /*
163
+ Advice you can actually follow. The blocking branch used to offer "explain in the notes
164
+ why this does not reach anyone" — which `bumpConcerns` cannot honour, because it never
165
+ sees the notes. A gate whose stated remedy is unrunnable leaves you re-reading your own
166
+ tool to find out what it wants, which is the same defect this repo fixed in the tunnel's
167
+ 401 page.
168
+ */
162
169
  blocking
163
- ? `\n Cut a minor, or explain in the notes why this does not reach anyone.\n`
164
- : `\n Check it is genuinely additive.\n`
170
+ ? `\n Cut a minor — this is not something the notes can talk you out of.\n`
171
+ : `\n Check it is genuinely additive, and say what moved in the notes.\n`
165
172
  )
166
173
  }
167
174
 
@@ -202,6 +202,7 @@ export declare class TosiTable extends WebComponent {
202
202
  private rowData;
203
203
  private _array;
204
204
  private _columns;
205
+ private _renderedCols;
205
206
  private _filter;
206
207
  private _sort?;
207
208
  private _rowGroupId;
@@ -1311,6 +1311,11 @@ export class TosiTable extends WebComponent {
1311
1311
  };
1312
1312
  _array = [];
1313
1313
  _columns = null;
1314
+ /*
1315
+ The columns the CURRENT DOM was built from — see `setColumnWidths`. Not `_columns`: that one
1316
+ changes the instant a caller assigns it, which is exactly the window this exists to cover.
1317
+ */
1318
+ _renderedCols = [];
1314
1319
  _filter = passThru;
1315
1320
  _sort;
1316
1321
  _rowGroupId = null;
@@ -2144,9 +2149,29 @@ export class TosiTable extends WebComponent {
2144
2149
  this.style.cursor = column !== undefined ? 'col-resize' : '';
2145
2150
  };
2146
2151
  resizeColumn = (event) => {
2147
- const column = this.getColumn(event);
2148
- if (column !== undefined) {
2149
- const origWidth = Number(column.width);
2152
+ const grabbed = this.getColumn(event);
2153
+ if (grabbed !== undefined) {
2154
+ /*
2155
+ The drag follows the column BY PROP, not by holding the object it started on.
2156
+
2157
+ A drag used to capture the `ColumnOptions` object and mutate it for its whole life. That
2158
+ object stops being the table's the moment a caller assigns a new `columns` array — which
2159
+ the reporting app does on a page-size change, and a page-size change is a very likely
2160
+ thing to happen while someone is dragging a column edge. From then on the drag wrote
2161
+ widths into an orphan: pointer moving, column not moving, nothing reported anywhere.
2162
+
2163
+ Each step re-resolves from the current set instead. If the column is gone the drag ends
2164
+ (its premise went with it); if the object was replaced but the prop survives, the drag
2165
+ continues, rebased on the new width — `origWidth + dx` against a width the caller has
2166
+ since changed would make the column jump by the difference.
2167
+
2168
+ (Firefox needed a separate fix before any of this was observable there — it turned the
2169
+ first drag on a fresh page into a text selection. See track-drag.ts, and #107.)
2170
+ */
2171
+ const prop = grabbed.prop;
2172
+ let anchor = grabbed;
2173
+ let anchorWidth = Number(grabbed.width);
2174
+ let anchorDx = 0;
2150
2175
  const isTouchEvent = event.touches !== undefined;
2151
2176
  const touchIdentifier = isTouchEvent
2152
2177
  ? event.touches[0].identifier
@@ -2158,9 +2183,16 @@ export class TosiTable extends WebComponent {
2158
2183
  if (touch === undefined) {
2159
2184
  return true;
2160
2185
  }
2161
- const width = origWidth + dx;
2162
- column.width =
2163
- width > this.minColumnWidth ? width : this.minColumnWidth;
2186
+ const live = this.columns.find((c) => c.prop === prop);
2187
+ if (!live)
2188
+ return true;
2189
+ if (live !== anchor) {
2190
+ anchor = live;
2191
+ anchorWidth = Number(live.width);
2192
+ anchorDx = dx;
2193
+ }
2194
+ const width = anchorWidth + (dx - anchorDx);
2195
+ live.width = width > this.minColumnWidth ? width : this.minColumnWidth;
2164
2196
  this.setColumnWidths();
2165
2197
  if (event.type === 'mouseup') {
2166
2198
  return true;
@@ -2611,7 +2643,30 @@ export class TosiTable extends WebComponent {
2611
2643
  this.addEventListener('keydown', this.handleKeyNav);
2612
2644
  }
2613
2645
  setColumnWidths() {
2614
- const cols = this.visibleColumns;
2646
+ /*
2647
+ The widths describe the columns the DOM ACTUALLY HAS, not the ones it is about to get.
2648
+
2649
+ `set columns` updates `_columns` synchronously but defers the rebuild to `queueRender()`,
2650
+ so between the assignment and the frame, `visibleColumns` already answers for a column set
2651
+ no cell in the document belongs to. Anything that recomputes widths in that window — a
2652
+ resize drag, a scroll handler, a host reacting to a page-size change — used to write a
2653
+ template for the NEW columns over the OLD cells.
2654
+
2655
+ That reads as the header disagreeing with the body (#102), and the reason it looks like
2656
+ disagreement rather than simple breakage is worth knowing: with fewer tracks than cells,
2657
+ CSS Grid auto-places the surplus into IMPLICIT tracks, which size to content. Header text
2658
+ and body text differ, so the two rows resolve those tracks to different widths and the
2659
+ columns visibly step apart.
2660
+
2661
+ `_renderedCols` is the set the last render built the cells from; `render()` refreshes it
2662
+ immediately before calling here, so a legitimate column change still takes effect on the
2663
+ very frame that installs its cells. Column objects are shared by reference, so a resize
2664
+ drag that mutates `column.width` is still reflected — it is the SHAPE that is pinned to
2665
+ the DOM, not the widths.
2666
+ */
2667
+ const cols = this._renderedCols.length
2668
+ ? this._renderedCols
2669
+ : this.visibleColumns;
2615
2670
  const total = cols.reduce((w, c) => w + c.width, 0);
2616
2671
  /*
2617
2672
  `fullWidthHeader` lets ONE column absorb whatever width is left over, so a table narrower
@@ -2886,6 +2941,8 @@ export class TosiTable extends WebComponent {
2886
2941
  if (cols.length === 0)
2887
2942
  return;
2888
2943
  const stickyInfo = this.computeStickyInfo(cols);
2944
+ // Before setColumnWidths, so the widths describe the cells this render is about to build.
2945
+ this._renderedCols = cols;
2889
2946
  this.style.setProperty('--tosi-table-row-height', this.rowHeight > 0 ? `${this.rowHeight}px` : 'auto');
2890
2947
  this.setColumnWidths();
2891
2948
  // Build the regions. Header + optional pinned tbodies are siblings
@@ -22,6 +22,8 @@ export interface Doc {
22
22
  path: string;
23
23
  pin?: string;
24
24
  hidden?: boolean;
25
+ /** `'full-width'` drops the reading measure. See `Doc.layout` in docs.ts. */
26
+ layout?: 'full-width';
25
27
  testStatus?: 'passed' | 'failed' | 'pending';
26
28
  bakes?: Array<[string, {
27
29
  dialect: string;
@@ -711,6 +711,39 @@ export function createDocBrowser(options) {
711
711
  bakes: doc.bakes ? new Map(doc.bakes) : undefined,
712
712
  });
713
713
  }
714
+ /*
715
+ Keep the page's layout in step with the doc on SPA navigation.
716
+
717
+ The static generator stamps `data-layout` so the first paint is already right without JS;
718
+ this is the other half — navigating from a full-width page to a prose one must put the
719
+ reading column back, and vice versa. Written on every showDoc rather than only on change,
720
+ because the adopted first page also arrives here and the attribute it was served with must
721
+ survive rather than be assumed.
722
+ */
723
+ /*
724
+ On the SAME element the static generator stamps — `<tosi-doc-system>` — not on
725
+ `docContent.parentElement`.
726
+
727
+ Those are not the same node, which cost a debugging round: hydration wraps the adopted
728
+ `<article class="doc-content">` in a div, so the parent is that div while the generator's
729
+ attribute sits on the custom element above it. Writing to the div left the real host still
730
+ reading `full-width`, so navigating from the wide page to a prose one silently stayed wide —
731
+ and the test that only checked the wide direction would have passed.
732
+
733
+ A memory-routed browser is EXCLUDED deliberately. That mode exists so an embedded or nested
734
+ browser cannot reach out and change the page hosting it, and `closest()` from inside one
735
+ finds the OUTER doc-system — the host's, not its own. Same reasoning as its history/location
736
+ ban; a nested doc-browser rewriting its host's layout is the same bug wearing a hat.
737
+ */
738
+ if (!memoryRouting) {
739
+ const layoutHost = docContent.closest('tosi-doc-system');
740
+ if (layoutHost) {
741
+ if (doc.layout)
742
+ layoutHost.setAttribute('data-layout', doc.layout);
743
+ else
744
+ layoutHost.removeAttribute('data-layout');
745
+ }
746
+ }
714
747
  rewriteContentLinks();
715
748
  // Stamp each example with its source file (for the source↔doc map). doc.path
716
749
  // is the extracted file (.md, or a source file with doc comments).
@@ -98,6 +98,16 @@ export function docSystemStyleSpec(theme = {}) {
98
98
  // below — if these two ever disagree, the page jumps on hydration.
99
99
  _docContentMaxWidth: '44em',
100
100
  },
101
+ /*
102
+ Per-page layout, driven by `layout:` in a doc's metadata (see `Doc.layout`).
103
+
104
+ Both are variable overrides rather than new layout rules, which is what keeps them cheap
105
+ and non-invasive: the measure is already `--doc-content-max-width`, so a page opts out by
106
+ changing one value. Nothing here runs for a corpus that never sets `layout`.
107
+ */
108
+ 'tosi-doc-system[data-layout="full-width"]': {
109
+ _docContentMaxWidth: 'none',
110
+ },
101
111
  '.darkmode': {
102
112
  ...invertLuminance(colors),
103
113
  /*
@@ -221,6 +221,21 @@ Deliberately a small, named list rather than a clever heuristic: it has to be ob
221
221
  release was flagged, and obvious how to add to it. Matched as substrings of repo-relative
222
222
  paths.
223
223
  */
224
+ /*
225
+ Paths where a change is worth a second look before it ships as a patch.
226
+
227
+ These are all DEVELOPMENT tooling — a dev server, a tunnel, a deploy script. None of it runs in
228
+ an end user's browser as part of an adopter's app, which is why touching them WARNS rather than
229
+ blocks. The distinction is the point: a security-relevant change to code an adopter ships to
230
+ their users deserves a gate that stops the release, and a change to a tool a developer chooses
231
+ to run on their own machine deserves a sentence in the notes.
232
+
233
+ It blocked, briefly, and that was wrong in a way worth recording. A gate that halts a release
234
+ over dev-only tooling spends the maintainer's attention on the release with the least at stake,
235
+ and the reliable outcome of a guard that cries wolf is that it gets overridden or deleted —
236
+ taking the case it was RIGHT about (#79, a loosened dev-server default shipped as a patch) with
237
+ it. Warning keeps that signal and stops charging for it.
238
+ */
224
239
  export const SENSITIVE_PATHS = [
225
240
  'dev-auth',
226
241
  'build-lock',
@@ -264,8 +279,8 @@ export function bumpConcerns(opts) {
264
279
  const sensitive = changedPaths.filter((p) => SENSITIVE_PATHS.some((s) => p.includes(s)));
265
280
  if (sensitive.length) {
266
281
  out.push({
267
- level: 'block',
268
- reason: 'a security-relevant path changed in a PATCH — say what moved, under its own heading, in a release people will read',
282
+ level: 'warn',
283
+ reason: 'a dev-tooling path with a security surface changed in a PATCH — say what moved, under its own heading, in a release people will read',
269
284
  evidence: sensitive.slice(0, 8),
270
285
  });
271
286
  }
@@ -184,6 +184,23 @@ export declare function mayWriteSource(opts: {
184
184
  /** does the request carry a live session cookie? */
185
185
  hasValidSession: boolean;
186
186
  }): boolean;
187
+ /**
188
+ * Who may attach the haltija dev channel — i.e. let an agent DRIVE this page.
189
+ *
190
+ * Deliberately the same rule as `mayWriteSource`, delegating rather than restating it, because
191
+ * driving a page with an agent is at least as powerful as writing source: an agent that can
192
+ * evaluate script in the page can read whatever the page can read and act as the logged-in
193
+ * user. A weaker gate here would quietly become the weakest link, and a *parallel* copy of the
194
+ * rule would be a second thing to keep in step — which is how the two drift.
195
+ *
196
+ * It is a separate NAME because the capability is separate: if the policies ever need to
197
+ * diverge, this is the one place to change, and every caller already says which it means.
198
+ */
199
+ export declare function mayDriveWithAgent(opts: {
200
+ viaTunnel: boolean;
201
+ peer?: string | null;
202
+ hasValidSession: boolean;
203
+ }): boolean;
187
204
  /**
188
205
  * THE loopback test. dev-server re-exports this as `isLoopbackAddress`; it used to
189
206
  * carry its own byte-identical copy.
@@ -417,6 +417,21 @@ export function mayWriteSource(opts) {
417
417
  return opts.hasValidSession;
418
418
  return isLoopbackAddressForAuth(opts.peer);
419
419
  }
420
+ /**
421
+ * Who may attach the haltija dev channel — i.e. let an agent DRIVE this page.
422
+ *
423
+ * Deliberately the same rule as `mayWriteSource`, delegating rather than restating it, because
424
+ * driving a page with an agent is at least as powerful as writing source: an agent that can
425
+ * evaluate script in the page can read whatever the page can read and act as the logged-in
426
+ * user. A weaker gate here would quietly become the weakest link, and a *parallel* copy of the
427
+ * rule would be a second thing to keep in step — which is how the two drift.
428
+ *
429
+ * It is a separate NAME because the capability is separate: if the policies ever need to
430
+ * diverge, this is the one place to change, and every caller already says which it means.
431
+ */
432
+ export function mayDriveWithAgent(opts) {
433
+ return mayWriteSource(opts);
434
+ }
420
435
  /**
421
436
  * THE loopback test. dev-server re-exports this as `isLoopbackAddress`; it used to
422
437
  * carry its own byte-identical copy.
@@ -85,6 +85,28 @@ export declare function resolveLimitMb(configMb: number | undefined, envMb: stri
85
85
  export { isLoopbackAddress };
86
86
  export declare function haltijaIsDrivable(stdout: string): boolean;
87
87
  export declare function haltijaLoaderSnippet(httpsPort: number): string;
88
+ /**
89
+ * The SAME-ORIGIN loader, for a page reached over the tunnel (`haltijaDev: 'tunnel'`).
90
+ *
91
+ * Over the tunnel `localhost` is the HEADSET, so every hardcoded `https://localhost:8701` in
92
+ * the upstream chain points at the wrong machine. This skips that chain entirely rather than
93
+ * asking haltija to change: `dev.js` and `inject.js` exist only to set `__haltija_config__`
94
+ * and load `component.js`, and both carry their own localhost gates. `component.js` has NO
95
+ * such gate — it reads `__haltija_config__.serverUrl` and falls back to localhost only when
96
+ * the config is absent — so setting the config ourselves and loading the component directly
97
+ * is both sufficient and the only part that can work remotely.
98
+ *
99
+ * `serverUrl` is derived from the page's own origin at runtime, not baked in, because the
100
+ * tunnel hostname is not known when this string is built and can differ per request.
101
+ *
102
+ * `self===top` is kept from the localhost loader: an iframe should not attach a second widget,
103
+ * and the doc-test runner executes pages in hidden iframes.
104
+ */
105
+ export declare function haltijaTunnelLoaderSnippet(): string;
106
+ /** Same-origin paths the tunnel bridge serves. One place, so the loader and the routes agree. */
107
+ export declare const HALTIJA_BRIDGE_PREFIX = "/__haltija/";
108
+ export declare const HALTIJA_BRIDGE_WS = "/__haltija/ws";
109
+ export declare const HALTIJA_BRIDGE_COMPONENT = "/__haltija/component.js";
88
110
  /** br if the client takes it, else gzip, else nothing. */
89
111
  export declare function negotiateEncoding(accept: string | null): 'br' | 'gzip' | null;
90
112
  /** Is this worth compressing? Already-compressed formats only get bigger. */