@aiquants/daily-report 0.26.0 → 0.28.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.
package/README.md CHANGED
@@ -26,8 +26,9 @@ pnpm add @aiquants/daily-report @aiquants/virtualscroll @aiquants/swipe-overlay
26
26
  ```
27
27
 
28
28
  `@aiquants/virtualscroll` / `@aiquants/swipe-overlay` / `@aiquants/resize-panels` / `react` / `react-dom` / `react-router` / `drizzle-orm` / `zod` are **peer dependencies**. React must be **19 or later** (`react` / `react-dom` `>=19`): the rows register their keyboard focus targets with ref callbacks that return a cleanup.
29
- `@aiquants/virtualscroll` must be **3.9.0 or later**: both views pass its scroll-bar option `enableArrowButtonTabStops: false` (3.8.0), which takes the scroll bar's arrow buttons out of the Tab order (the views scroll by keyboard themselves; see [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)), its stylesheet stops the motion of its own parts under `prefers-reduced-motion: reduce` (3.8.2;
30
- see [Selection and focus appearance](#selection-and-focus-appearance)), and both views re-record their scroll anchor from its `onScrollAdjust` notification and rely on its edge-keeping snap (3.9.0; see **Host selections and list changes** and [View height](#view-height-host-layout)). The label catalog reuses its 7 engine keys (see [Localization](#localization-locale--labels)).
29
+ `@aiquants/virtualscroll` must be **3.11.0 or later**: both views pass its scroll-bar option `enableArrowButtonTabStops: false`, since 3.10.0 its one signal that the host scrolls by keyboard itself, under which the scroll bar is pointer-only — hidden from assistive technology, out of the Tab order and never taking focus on a press (the views scroll by keyboard themselves; see [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)) —; its stylesheet stops the motion of its own parts under `prefers-reduced-motion: reduce` (3.8.2;
30
+ see [Selection and focus appearance](#selection-and-focus-appearance)); and both views re-record their scroll anchor from its `onScrollAdjust` notification and rely on its edge-keeping snap (3.9.0; see **Host selections and list changes** and [View height](#view-height-host-layout)). The label catalog reuses its 11 engine keys, the names of the scroll bar and its thumb among them (3.10.0; see [Localization](#localization-locale--labels)).
31
+ The test handle's `DailyReportRevealOptions` is its `scrollToIndex` options, which take `align: "nearest"` (3.11.0), and DetailList rows stay at the tree's tops and heights after a batch of re-measurements whose deltas cancel only because its row memo follows the tree revision (3.11.0).
31
32
  That floor is declared once, in `peer-floors.json` (`{ "<peer name>": "<X.Y.Z>" }`), and every publish path runs `scripts/check-peer-floors.mjs` (`pnpm run check:peer-floors`) right after the leak check: each `publish:*` script before its version bump (so a refusal leaves no bumped version behind), and `prepublishOnly` before every `pnpm publish`, a bare one included (for example a re-run after a `publish:*` whose registry step failed).
32
33
  `workspace:^` publishes `^<version>` of the linked workspace package (`node_modules/@aiquants/virtualscroll/package.json`), so while that version is below the floor the check exits 1 and the publish stops (exit 2 for a configuration error, such as a floor that names no `workspace:` peer).
33
34
  `@aiquants/sse` (the SSE wire contract, server response helpers and the reopening client) is a regular **dependency**: it arrives transitively, so consumers do not declare it.
@@ -36,9 +37,11 @@ Build / test: `pnpm run build` (tsup → dist) / `pnpm run test` (vitest) / `pnp
36
37
  `pnpm run check:bundle` checks the gzip size at level 9 of every shipped ESM entry and the standalone stylesheet — `dist/client.mjs`, `dist/index.mjs`, `dist/server.mjs`, `dist/styles/daily-report.standalone.css` — against a budget that is derived, never written: `bundle-baseline.json` at the package root records each file's gzip-9 size and one `headroomPercent` (3), and each budget is ⌈baseline × (100 + headroomPercent) ÷ 100⌉ in integer arithmetic.
37
38
  An `.mjs` or `.css` target of `exports` without an entry, an entry for a file that is no longer a target and a hand-written `bundleBudget` in `package.json` fail the check.
38
39
  `pnpm run bundle:ratchet` (`--write`) records the sizes of a fresh build: an entry goes down freely and a stale one is removed, while a larger size or a new target is recorded only with `--write --accept` (a reviewed growth; without `--accept` the entry keeps its size and the growth is judged against the old budget). `headroomPercent` is policy and is never written by the script.
39
- Every `publish:*` script runs `node scripts/check-bundle-size.mjs --exact` right after `pnpm run verify` (which ends with the build and the bundle check), before the leak check and the version bump: besides the budgets, it fails when any measured size differs from its baseline entry in either direction and prints the `node scripts/check-bundle-size.mjs --write --accept` command that records that build.
40
+ Every `publish:*` script measures the build that `pnpm run verify` ends with (verify's last steps are the build and the bundle check) and does not build again: right after verify it runs `node scripts/check-bundle-size.mjs --write`, then `node scripts/check-bundle-size.mjs --exact`, before the leak check (which reads the same build) and the version bump.
41
+ `--write` lowers every entry the build shrank, so a shrink is recorded into the release by construction (no failed publish, no separate baseline commit); a growth is left unrecorded, and `--exact` — besides the budgets, it fails when any measured size differs from its baseline entry in either direction — stops it and prints the `node scripts/check-bundle-size.mjs --write --accept` command that records that build after review.
40
42
  So a release always ships the baseline of its own build: an entry left high after a shrink would loosen every budget derived from it, and one left low would judge the next change against a build nobody shipped. `--exact` cannot be combined with `--write` (exit 2).
41
43
  `pnpm run verify` runs the type checks, Biome (`src/` and `scripts/`), markdownlint, the two count ratchets (docstrings, then the no-fallback guard), the unit tests with per-file coverage floors, the coverage ratchet, the examples, the build and, last, the bundle check.
44
+ The unit tests include the repository's publish leak guard over the markdown the package ships (`src/shipped-markdown-leaks.spec.ts`: every markdown file `package.json` `files` ships — `README.md`, `CHANGELOG.md` and any shipped docs — packed with `package.json` into a tarball and checked by the guard itself, with its own rules), so an internal name in them fails verify instead of stopping a publish; the publish paths still run the guard on the packed tarball, `dist` included.
42
45
  Every source file under `src` (without specs, tests, declarations and test helpers) and every gate script under `scripts` (`scripts/**/*.mjs`) has a committed floor of branch and function coverage in `coverage-floors.json`: Vitest fails a file below its floor, `pnpm run check:coverage` (`scripts/ratchet-coverage.mjs`) fails a file without an entry or an entry without a file, and `pnpm run coverage:ratchet` raises each floor to the measured percentage rounded down after a whole-suite coverage run (a file at 100 % stays at 100; no floor is ever lowered by the script).
43
46
  Each gate script is a two-statement command-line entry around the `main({ packageRoot, argv })` of its module under `scripts/lib/` (the shared exit codes, JSON readers and error descriptions are `scripts/lib/cli.mjs`); the specs run `main` in the test process against temporary packages and keep one subprocess test of each entry, so the scripts' logic is measured by the same floors as `src` (all at 100).
44
47
  A new file enters the ratchet only at 100 % of both metrics: the script records no entry below that, so a file that cannot reach 100 % needs a committed entry with a non-empty `exemption` (the reason, reviewed with the floors), which the script keeps while it raises the floors.
@@ -47,16 +50,18 @@ The guards are the violations of the workspace's docstring and comment language
47
50
  A substitution that the specification or a port contract defines is a reviewed exception of the no-fallback guard instead (`EXCEPTIONS` in `scripts/lib/check-no-fallback.mjs`: the file, the exact expression, the number of `occurrences` it covers and the reason), and its matches are not counted; an exception that matches another number of expressions than it declares fails (exit 1: fewer means the expression was fixed or rewritten, more a new copy that needs its own review), and a malformed list is exit 2.
48
51
  Every TypeScript or JavaScript code block of this README names its source in its info string: an example file, or a `#region` of one, which `src/docs-examples.spec.ts` compares byte for byte (`ts examples/<file>.ts[#<region>]`), or `illustrative` for a fragment that is not type-checked.
49
52
 
50
- **Browser floor**: Chrome 121, Firefox 122, Safari 17.4 (Baseline 2024). The client uses each platform feature below as it is, with no feature detection and no second code path, so on an engine under the floor the listed behaviour fails and nothing else replaces it. Three CSS features of the table are newer than parts of the floor, and the row says what those engines draw:
53
+ **Browser floor**: Chrome 121, Firefox 122, Safari 17.4 (Baseline 2024). The client uses each platform feature below as it is, with no feature detection and no second code path, so on an engine under the floor the listed behaviour fails and nothing else replaces it. Five CSS features of the table are newer than parts of the floor, and the row says what those engines draw:
51
54
 
52
55
  | Feature | Supported from | Used for | Below the floor |
53
56
  | --- | --- | --- | --- |
54
57
  | `Element.checkVisibility({ visibilityProperty: true })` | Chrome 121, Firefox 122, Safari 17.4 (the method alone: Chrome 105, Firefox 106) | Finding Tab stops for the `Ctrl+Home` / `Ctrl+End` exits and the mobile overlay's Tab wrap | Without the method (Safari before 17.4) those key handlers throw a `TypeError`: the exit keys keep their browser meaning, and at the overlay's ends Tab moves to the browser's own interface instead of wrapping (the page behind the dialog stays inert). With the method but not the option (Chrome 105–120, Firefox 106–121) an element hidden by `visibility: hidden` counts as a stop, so an exit key whose nearest stop is such an element is consumed while focus stays where it was |
55
58
  | `:has()` | Chrome 105, Firefox 121, Safari 15.4 | The List card's selection ring and keyboard focus outline, and the forced-colours selection outline of a List row with a card (all read from the card's primary button) | List cards show neither the selection nor keyboard focus (the DetailList is unaffected) |
56
- | Container size queries and container query units (`cqi`) | Chrome 105, Firefox 110, Safari 16 | The attachment grid's column count; the tile frame's height and the tile's lattice pad (`100cqi` of the tile root, an inline-size container as wide as its track); the view-mode toolbar's end inset in the single-column List | One attachment column at every width; frames sized by `aspect-ratio` alone and tiles without the pad (below); the toolbar's end edge overhangs the List's scroll bar by 8 px |
57
- | CSS `round()` | Chrome 125, Firefox 118, Safari 15.4 | The tile frame's whole-pixel height `round(nearest, 100cqi * 320 / 480, 1px)` and the tile's lattice pad `round(up, h, 4px) − h` under its last line (see [Attachment display](#attachment-display)); the views' column origin on the 4 px lattice (see **Origin on the 4 px lattice**) | Chrome 121–124, inside the floor, drop those declarations: the frame's `aspect-ratio: 480 / 320`, always declared beside its height, sizes the frame at the exact fractional 2t / 3, so the frames of one grid can paint one device pixel apart; a tile stays its frame's fractional height + 48 px tall, so the rows below an image grid can start between device pixels; and the column keeps the `mx-auto` centre |
59
+ | Container size queries and container query units (`cqi`) | Chrome 105, Firefox 110, Safari 16 | The attachment grid's column count and its whole-pixel width (both read from the grid's own size container); the tile frame's height and the tile's lattice pad (`100cqi` of the tile root, an inline-size container as wide as its track); the view-mode toolbar's end inset in the single-column List | One attachment column at every width; frames sized by `aspect-ratio` alone and tiles without the pad (below); the toolbar's end edge overhangs the List's scroll bar by 8 px |
60
+ | CSS `round()` | Chrome 125, Firefox 118, Safari 15.4 | The attachment grid's whole-pixel width `calc(round(down, 100% − 16 (n − 1), n px) + 16 (n − 1))` per column count, which makes every track a whole pixel, the tile frame's whole-pixel height `round(nearest, 100cqi * 320 / 480, 1px)` and the tile's lattice pad `round(up, h, 4px) − h` under its last line (see [Attachment display](#attachment-display)); the views' column origin on the 4 px lattice (see **Origin on the 4 px lattice**) | Chrome 121–124, inside the floor, drop those declarations: the grid fills its container with the fluid tracks (W − 16 (n − 1)) / n (every breakpoint keeps its column count, which no `round()` declaration sets), so tile edges can fall between pixels and the names are fitted less than 1 px narrower than the track; the frame's `aspect-ratio: 480 / 320`, always declared beside its height, sizes the frame at the exact fractional 2t / 3, so the frames of one grid can paint one device pixel apart; a tile stays its frame's fractional height + 48 px tall, so the rows below an image grid can start between device pixels; and the column keeps the `mx-auto` centre |
58
61
  | CSS `calc-size()` | Chrome 129 | The DetailList row bodies' height, their content height rounded up to the 4 px lattice (`calc-size(auto, round(up, size, 4px))`, see **Row slots on the lattice**) | Chrome 121–128, and any engine that does not implement it, drop the declaration: a body keeps its content height, which is on the lattice at a 16 px root and can leave it at other roots, so the DetailList rows below can start between device pixels |
59
62
  | `scrollbar-gutter: stable` | Chrome 94, Firefox 97, Safari 18.2 | The side pane's tab panels keep their scroll bar's width whatever their content's height | Safari 17.4–18.1, inside the floor, ignore it; their default scroll bars overlay the content and take no width |
63
+ | `text-wrap: balance` | Chrome 114, Firefox 121, Safari 17.5 | The short centred labels that can wrap (`WRAPPING_LABEL_CLASS_NAME`: the side pane's selection prompt, an attachment tile's unavailable label) keep their lines about equally long | Safari 17.4, inside the floor, drops the value and wraps them greedily, so a wrapped label can end with a short last line |
64
+ | `word-break: auto-phrase` | Chrome 119 (not in Firefox or Safari) | The same labels break Japanese at phrase boundaries where the host document's `lang` is `ja` | Firefox and Safari drop the declaration and break Japanese between any two characters (the default), so a narrow label can break inside a word |
60
65
  | `<dialog>` with `showModal()` | Chrome 37, Firefox 98, Safari 15.4 | The mobile detail overlay (a modal dialog in the top layer) | Opening the overlay throws a `TypeError` from a layout effect, which React hands to the nearest error boundary |
61
66
 
62
67
  File names are cut at code-point boundaries rather than grapheme boundaries, because `Intl.Segmenter` (Firefox 125) is above the floor (see [Attachment display](#attachment-display)).
@@ -161,6 +166,7 @@ export const loader = (args) => dailyReportServer.sse.loader(args)
161
166
  - `resolveVisibleSourceTypes(request)` — **Optional row-level authorization port.** Returns the `Hub.source_type` values this request may view. Applies uniformly to every server data path: list (ids stream), business-date list, detail, comments, attachment bytes, and SSE. See [Source-type visibility](#source-type-visibility) below.
162
167
  - `enableDevCacheClear` — Gates the dev-only `POST /action` `intent=clearCache` (flush every worker's cache). Default `false` → the handler returns `400` before touching the service. Wire `import.meta.env.DEV` to enable it only in development (any authenticated user could otherwise flush all caches without limit).
163
168
  - `attachmentIdCodec` / `readAttachment` / `attachmentThumbnailRenderer` / `attachmentMaxBytes` — Attachment delivery ports and size limit, owned by the service. See [Attachments](#attachments).
169
+ - `logger` — Optional console-compatible logger (`debug` / `info` / `warn` / `error`) that receives every server log line of the package as it is. Without it each area logs to the console with its own prefix and lowest level, for example `[DailyReportAttachment]` from `info` (see **Log levels** under [Attachments](#attachments)) and `[DailyReportIsolation]` from `warn` (the refusal record of [Request isolation](#request-isolation)).
164
170
  - Primary tuning parameters: `idsTtlMs` (180s) / `businessDateTtlMs` (300s) / `streamKey` / `streamMaxLen` / `loginRedirectPath`, plus the attachment tuning keys listed under [Attachments](#attachments).
165
171
 
166
172
  **Configuration errors** follow one convention on the server and on the client: the message reads `[daily-report] <path> must be <expectation>`, `<path>` being the public key or argument to fix, and ends with `; got <value>` only when it shows a value.
@@ -168,6 +174,29 @@ A rejected value — a wrong type included, also a port or a function given with
168
174
  For example `[daily-report] attachmentConcurrency must be an integer >= 1; got "2"`, `[daily-report] config key must be one of "apiBasePath", "ssePath", …; got "title"` and `[daily-report] readAttachment must be injected when attachmentIdCodec is given`.
169
175
  The three helpers of `src/shared/config-errors.ts` (`configValueError`, `configKeyError`, `configPortError`) are the only code that constructs a `RangeError` or a `TypeError`, so every such message has the prefix and the form: `src/host-facing-errors.spec.ts` reads the sources' syntax tree (specs and test helpers aside) and fails on a construction anywhere else.
170
176
 
177
+ ### Request isolation
178
+
179
+ Every resource route the handlers serve — `api.loader`, `api.action`, `sse.loader` and `attachment.loader` — is wrapped once, where `createDailyReportHandlers` returns it, in the request isolation, which runs first: before authentication, any rate charge, authorization or service call (`isCrossSiteRequest(request, policy)` in `src/server/request-isolation.ts`). `index.loader`, the document route, is not wrapped.
180
+ The check is an allow-list: it names what a route serves from another origin, so everything else — frames (`iframe`, `frame`), `fencedframe`, `object`, `embed`, a navigation without `Sec-Fetch-Dest`, and any destination a later specification adds — is refused by construction.
181
+
182
+ | Request | Answer |
183
+ | --- | --- |
184
+ | `Sec-Fetch-Site: same-origin` or `none` (a load the user started: the address bar, a bookmark) | Served: authentication and the route's own checks follow |
185
+ | Any other `Sec-Fetch-Site` — `same-site` (another origin of the same site, such as a sibling subdomain), `cross-site` or an unknown value | 403 before authentication, unless the request is a top-level document navigation that the route serves: method `GET`, `Sec-Fetch-Mode: navigate`, `Sec-Fetch-Dest` exactly `document`, and a route policy that accepts it. Only the attachment route accepts one, and only for an original (`inline` or `download`, read by the loader's own strict query parser): a link in another page opens or downloads the original. The API, SSE and thumbnail routes accept no navigation from another origin |
186
+ | No `Sec-Fetch-Site` | A safe method (`GET`, `HEAD`, `OPTIONS`) is served. An unsafe method is decided by `Origin`: no `Origin` is served (not a browser page), an `Origin` whose host is the host the request was addressed to (`new URL(request.url).host`, which the adapter builds from `Host`) is served, and `null`, an unparsable value, another host or another port is 403 |
187
+
188
+ - **Requests without Fetch Metadata**: a browser sends no `Sec-Fetch-*` to an origin that is not potentially trustworthy (plain HTTP other than `localhost`), so such a request comes either from a client that is not a browser or from a page of such an origin. A browser always sends `Origin` with an unsafe method (`null` for an opaque origin), so a sibling page's form `POST` to a plain-HTTP host is refused, while a client that is not a browser (no `Origin`) and the host's own pages are served (the rule of Go's `net/http` `CrossOriginProtection`).
189
+ Safe methods change no state, so a no-cors `GET`, which carries no `Origin`, is served.
190
+ Full isolation therefore needs a potentially trustworthy origin (HTTPS): only there does the browser send the Fetch Metadata that also refuses a sibling page's `<img>` and no-cors probes before authentication.
191
+ - **The refusal**: on the API and SSE routes, JSON `{"error":{"message":"Cross-site request rejected"}}` with `Cache-Control: no-store` and `X-Content-Type-Options: nosniff` (`crossSiteRequestRejection`); on the attachment route, the same body through the attachment failure builder, so it carries every attachment security header (**Same-origin loads only** in [Attachments](#attachments)). Nothing has authenticated, so no `Set-Cookie` is forwarded.
192
+ On the attachment route the refusal comes before the query's 400 and the method's 405: a malformed query, a `HEAD` navigation (browsers never navigate with `HEAD`) or a form `POST` from another origin answers 403.
193
+ - **The refusal record**: each handler factory keeps one bounded record of its refusals (`createCrossSiteRejectionRecorder`; state per factory, not per module), written to the configured `logger`, or by default to a console logger at the `warn` level prefixed `[DailyReportIsolation]`.
194
+ The first refusal of a window of `CROSS_SITE_REJECTION_LOG_WINDOW_MS` (60 s) writes one warn line, `cross_site_rejected route=<api|action|sse|attachment> site=<value> mode=<value> dest=<value> method=<method>` (`-` for an absent header; a value outside the specification's shape — lower-case letters and hyphens, at most 32 characters — is written as the JSON string of its first 32 characters, so a sender cannot forge the line's fields); later refusals in the window are only counted.
195
+ The first refusal after the window writes `cross_site_rejected_suppressed count=<N>` for the window before (only when N > 0) and opens a new window with its own line, and a clock that moved back before the window's start opens a new window too. Served requests neither open nor close a window, so an active probe or a CSRF attempt shows in the log at once, at most two lines a minute per factory, however fast the refusals come.
196
+ - **Why the request side**: the host's session cookie (`SameSite=Lax` in the usual setting) also accompanies requests from other origins of the same site, form submissions (`application/x-www-form-urlencoded`, `multipart/form-data`, `text/plain`) and no-cors requests need no preflight, so CORS does not stop them, and React Router checks CSRF only for document requests and single fetch, not for resource routes.
197
+ A response policy such as `Cross-Origin-Resource-Policy` or `X-Frame-Options` decides only whether a response may be read or drawn; it does not stop a state change or the authentication, rate and authorization work a request starts. With the check, a page of another origin can neither post to the API (create, update, publish or delete a report, comment, star, mark read), nor open any of these routes in a frame, nor probe attachment tokens: a visible and a hidden attachment get the same 403 at the same cost, and the viewer's rate buckets are not spent.
198
+ - **Same origin only**: the package's client calls its endpoints from the page's own origin. A host that serves the API from another origin than the page is refused by this check.
199
+
171
200
  ### Source-type visibility
172
201
 
173
202
  Restrict which report categories a viewer may see, without the package depending on any authorization library. The port receives the request and returns plain strings; your app decides the policy.
@@ -246,8 +275,10 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
246
275
  - **Parsing is strict.** `thumbnail` must be one known variant name, `download` must be exactly `1`, the two cannot be combined, and neither may repeat. Anything else — `?thumbnail=1`, `?thumbnail=true`, `?download=true`, `?download=yes`, `?thumbnail=tile&download=1` — answers **400** `{"error":{"message":"Invalid attachment request"}}` right after authentication, before any port or database query runs, and writes no log line. Other parameters are ignored, and names are case-sensitive (`?Download=1` is an unknown parameter, so the request stays inline).
247
276
  - **The original's type**: the declared type is the row's `file_type` when it is a valid media type, otherwise the type the read port reported. An inline-safe declared type (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `application/pdf`, `text/plain`) is sent as itself, inline (as an attachment for a download); any other declared type, and a missing one, is sent as `application/octet-stream` with `Content-Disposition: attachment`, because the declared type comes from outside the package and an inline `text/html` or `image/svg+xml` would run script in the host's origin.
248
277
  - **Methods**: the original (inline and download) answers `GET` and `HEAD` (a HEAD reads metadata only and reports the same `Content-Length` as GET); any other method answers **405** with `Allow: GET, HEAD`. The thumbnail answers `GET` only (see **Thumbnail endpoint** below). Both 405s are checked right after the query, before the ports and the token.
249
- - **Same-origin loads only**: every attachment response, success or failure — the original's 200 (inline and download, GET and HEAD), the thumbnail's 200 and 304, and every error status the loader answers (400, 401, 403, 404, 405, 413, 429, 500, 502 and 503, for GET and HEAD alike) — carries `Cross-Origin-Resource-Policy: same-origin` and `X-Content-Type-Options: nosniff`, so the browser lets only pages of the host's own origin load it. Both headers come from one set that every path building an attachment response spreads last, so no status can lose them.
250
- The session cookie also accompanies requests from other origins of the same site, so a response without the policy would let a same-site page learn, token by token, whether the viewer can see each attachment: an image load succeeds or fails, and a no-cors `fetch` resolves with an opaque response for a response without the policy while the browser blocks one that carries it. With the policy on every status, every cross-origin load fails alike.
278
+ - **Same-origin loads only**: a request from another origin's page — an `<img>`, a no-cors `fetch`, a `HEAD` probe, an `<iframe>` or `<frame>` from a sibling subdomain, any request for a thumbnail — is refused with 403 before authentication ([Request isolation](#request-isolation);
279
+ on a host served from a potentially trustworthy origin such as HTTPS, where the browser sends Fetch Metadata), so it learns nothing about a token: a visible and a hidden attachment get the same 403 at the same cost, with no authentication, rate token, visibility resolution or query. The one exception is a top-level `GET` document navigation to an original (a link to an attachment in another page), which is served after authentication and authorization as usual.
280
+ As defence in depth, every attachment response, success or failure — the original's 200 (inline and download, GET and HEAD), the thumbnail's 200 and 304, and every error status the route answers (400, 401, 403, 404, 405, 413, 429, 500, 502 and 503, for GET and HEAD alike, the isolation's 403 included) — carries `Cross-Origin-Resource-Policy: same-origin`, `X-Content-Type-Options: nosniff` and `X-Frame-Options: SAMEORIGIN`, so the browser lets only pages of the host's own origin read it or draw it in a frame (a frame of another origin could otherwise tell a blocked 200 from a drawn 404).
281
+ The three headers come from one set (`ATTACHMENT_RESPONSE_SECURITY_HEADERS`) that every path building an attachment response spreads last — the failure builder `attachmentFailureResponse`, which every JSON failure goes through, and the 200 and 304 of both deliveries — so no status can lose them. `Content-Security-Policy: default-src 'none'; sandbox` stays on the two content 200s.
251
282
 
252
283
  **Configuration**
253
284
 
@@ -409,11 +440,11 @@ type DailyReportAttachmentThumbnailRenderer = {
409
440
  - `createDailyReportService` / `createDailyReportServer` check the port when they are created, in this order, by the convention under **Configuration errors**: a port that is not an object (`null` included)
410
441
  throws `RangeError` (`[daily-report] attachmentThumbnailRenderer must be an object with id and render; got null`), a missing `render` throws `TypeError` (`[daily-report] attachmentThumbnailRenderer.render must be a function`), a `render` that is given but is not a function throws `RangeError` (`…render must be a function; got "sharp"`), and an `id` that is not a string with non-whitespace content throws `RangeError` (`[daily-report] attachmentThumbnailRenderer.id must be a non-blank string naming the rendering pipeline version; got ""`).
411
442
  - `render`: fit the image inside `maxWidth` × `maxHeight` — the box of the requested variant — keeping the aspect ratio (never enlarge), apply the EXIF orientation, drop metadata, flatten transparency onto white, bound decode memory before decoding (a pixel count alone does not: a 16-bit sample takes twice the bytes of an 8-bit one), start no decoder other than the one for `format`, and never throw.
412
- - `signal` aborts when every request waiting for that content has gone or when the render stage's deadline passes (`renderMs`). A renderer that can stop (a remote image service, for example) should forward it and settle with `{ ok: false, reason: "failed" }`. A render cut short by the signal must never answer `unsupported`: a late `unsupported` is recorded as a content-determined outcome (below).
443
+ - `signal` aborts when every request waiting for that content has gone or when the render stage's deadline passes (`renderMs`). A renderer that can stop (a remote image service, for example) should forward it and settle with `{ ok: false, reason: "failed" }`. A render cut short by the signal must never answer `unsupported`: an `unsupported` that settles after the generation stopped waiting, at the deadline or on the abort, is recorded as a content-determined outcome (below).
413
444
  A render still running at its deadline is answered with 502 (`reason=render_timeout`) without waiting for it. A renderer that cannot stop (in-process libvips) is still valid: the response is bounded by the deadline, but the generation slot stays held until the render settles (`render_overrun ms=<elapsed>`; a render that has still not settled at twice its budget is logged once as `render_stuck ms=<elapsed>`).
414
- What such a render has paid for is kept: when it settles after the deadline with a thumbnail that passes the output checks below, or with `unsupported`, and the generation's read matched the row's size (**verified**, see **Read port** above), the outcome is cached before the slot is released. A late result is classified by the same rule as one that settles in time, so only those conclusions are kept: a late `failed`, a late exception, a late output that fails the checks and a late value outside the contract are not cached.
415
- Until such a render settles, a new generation of the same content waits for it instead of reading and decoding the same source again beside it (**Generation** below), so a content key has at most one decode at a time: a slow but legitimate source costs one overrun, and the next view is answered from the cache.
416
- A generation that every waiting request left before its render settled or reached the deadline ends as 503 (`reason=aborted`) and keeps nothing, whatever the render's result.
445
+ What a render has paid for is kept, whoever still waits for it. The generation stops waiting for its render at the deadline (the 502 above) or as soon as every request waiting for that content has gone (503 `reason=aborted`, which no client receives); when the render then settles with a thumbnail that passes the output checks below, or with `unsupported`, and the generation's read matched the row's size (**verified**, see **Read port** above), the outcome is cached before the slot is released.
446
+ A result that settles after the generation stopped waiting is classified by the same rule as one that settles in time, so only those conclusions are kept: `failed` (what a renderer that honours the signal answers to an abort), an exception, an output that fails the checks and a value outside the contract are not cached.
447
+ Until such a render settles, a new generation of the same content waits for it instead of reading and decoding the same source again beside it (**Generation** below), so a content key has at most one decode at a time: a slow but legitimate source costs one overrun, a tile that leaves and re-enters the rendered window during its decode costs one decode, and the next view is answered from the cache.
417
448
  - `format` is detected by the package from the leading bytes (PNG, JPEG, GIF87a / GIF89a, RIFF…WEBP), not taken from the declared type. Bytes with any other signature (SVG, TIFF, HEIF, BMP, ...) are never handed to the port.
418
449
  - `unsupported` means the content itself cannot be downscaled (deterministic); `failed` means a transient failure (retry may succeed).
419
450
  - The result's `contentType` is typed by the closed set (`DailyReportAttachmentThumbnailOutputMediaType`, derived from `DAILY_REPORT_ATTACHMENT_THUMBNAIL_OUTPUT_MEDIA_TYPES`) and must have 1 byte to `DAILY_REPORT_ATTACHMENT_THUMBNAIL_MAX_OUTPUT_BYTES`. Both are also checked at run time, for hosts written in JavaScript.
@@ -458,7 +489,7 @@ Values for implementing the port:
458
489
  - changes sharp's settings for the whole process when it is created: libvips' operation cache is turned off (the package caches the outcomes), libvips uses one thread per image (`sharp.concurrency(1)`: the model's rows were measured at one thread, and libvips' line caches grow with the thread count — a 7,071² 8-bit RGBA PNG took 42.2 MB at 1 thread, 64.5 MB at 4 and 89.2 MB at 8; the package's generation gate decides how many renders run at once), and every loader is blocked except one per format the package hands over (PNG, JPEG, GIF, WebP). An Ultra HDR JPEG is a valid JPEG:
459
490
  the JPEG loader decodes its SDR base image, and the 480 × 320 WebP is byte-identical to the one the Ultra HDR loader made. A host that uses sharp for anything else gets the same settings there, so create the renderer once.
460
491
  - answers `failed` (transient, retried by the next request) only when the error message reports exhausted resources (memory, threads, open files, disk space; for example `webpsave: picture memory error` or `Error creating thread: Resource temporarily unavailable`). Every other failure is `unsupported` and cached: treating an unknown message as transient would read and decode a broken attachment again on every view.
461
- - checks the abort signal before it starts and after the header read, before decoding, and answers `failed` when it is aborted by then. libvips cannot be stopped midway, so a decode that has started returns its own result (the thumbnail, `unsupported` or a resource `failed`) even when the signal aborts meanwhile; the package decides what to keep (a late verified result is cached, see **Thumbnail renderer port** above).
492
+ - checks the abort signal before it starts and after the header read, before decoding, and answers `failed` when it is aborted by then. libvips cannot be stopped midway, so a decode that has started returns its own result (the thumbnail, `unsupported` or a resource `failed`) even when the signal aborts meanwhile; the package decides what to keep (a verified content-determined result is cached whether the generation stopped waiting at the deadline or because every requester left, see **Thumbnail renderer port** above).
462
493
  - is named `sharp-<sharp version>/vips-<libvips version>/webp-q75-e4/flatten-#ffffff/v1`, from `sharp.versions` at run time.
463
494
 
464
495
  Example — the wiring; the host imports its own sharp (type-checked, not shipped):
@@ -544,7 +575,7 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
544
575
  **Thumbnail endpoint (`?thumbnail=tile`)**
545
576
 
546
577
  - **GET only**: `HEAD` and every other method answer 405 with `Allow: GET` (checked right after the query). A HEAD response would need a generated body to report the same `Content-Length` as GET.
547
- - **Order**: authenticate → query (400) → method → ports → token → viewer → renderer port → rate admission (below; an empty bucket answers 429 without resolving visibility or running the authorization query)
578
+ - **Order**: request isolation (403 for a request from another origin's page, a navigation included, [Request isolation](#request-isolation)) → authenticate → query (400) → method → ports → token → viewer → renderer port → rate admission (below; an empty bucket answers 429 without resolving visibility or running the authorization query)
548
579
  → authorization (`getAttachmentForUser`, the SQL predicate) → eligibility, identity and ETag → `If-None-Match` (304) → the generation token of a revalidation (below) → not visible / not eligible (404) → cache → generation. The cache, joining a generation and the 304 all come **after** authorization, so a cached preview is never returned to a viewer who cannot see the attachment.
549
580
  - **Rate admission uses two buckets per user and process**. Every token is taken synchronously — before the first `await` on admission, right after authorization's last `await` otherwise — so concurrent requests can never spend one token twice. With L = `attachmentThumbnailRateLimitPerMinute`:
550
581
  - The **generation bucket** holds L tokens and refills L per minute. Every answer except a matching 304 needs one of its tokens. A request without `If-None-Match` can never be a 304, so it spends its token on admission or is answered 429 (`reason=rate_limit`) before authorization.
@@ -559,9 +590,9 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
559
590
  - **Revalidation**: a verified preview carries `Cache-Control: private, no-cache` and the ETag. `If-None-Match` is compared weakly (`W/` stripped, lists accepted); `*` never matches. A match answers 304 without touching the gate, storage or renderer. Re-mounted thumbnails cost one 304 round trip, and a logout or a visibility change takes effect on the next revalidation.
560
591
  An unverified preview is sent with `Cache-Control: no-store` and no `ETag`: the browser neither keeps nor revalidates it, so a later `If-None-Match` can never pin it through 304s, and the next view generates again.
561
592
  - **Generation**: a process-local gate with `attachmentThumbnailConcurrency` slots and a FIFO wait queue (at most 64 waiting, 8 per user, waiting at most `queueWaitMs`). A slot is held while the original is read and rendered. Concurrent requests for the same identity join one generation; the joined requests share one read made with the first requester's principal. A request that disconnects withdraws from the shared generation, but its handler still settles with the shared outcome (which no client receives).
562
- **One decode per content key at a time**: a render that passed its deadline keeps its slot until it settles, and while it runs, a new generation of the same identity does not queue for a slot: it first waits for that render to settle and its result to be recorded or dropped, then reads the cache, and only then queues at the gate.
563
- The wait for the late render and the wait for a slot share one `queueWaitMs` budget, and the generation's abort signal ends both; when the budget runs out first, the request is answered 503 `reason=queue` without a second read or decode. A generation that receives its slot also reads the cache again before it reads the original, so a request that waited at the gate behind an overrunning render of the same content is answered from that render's late result.
564
- The generation has its own abort signal, which fires only when every joined request has gone, and passes it to the gate wait and to both ports. An abandoned generation leaves the wait queue; a port that honours the signal settles early and hands the slot to the next queued generation; and a stage that settles after the abort is discarded before its result is looked at (see **Read port** above).
593
+ **One decode per content key at a time**: a render that its generation no longer waits for — past its deadline, or after every requester left — keeps its slot until it settles, and while it runs, a new generation of the same identity does not queue for a slot: it first waits for that render to settle and its result to be recorded or dropped, then reads the cache, and only then queues at the gate.
594
+ The wait for the still-running render and the wait for a slot share one `queueWaitMs` budget, and the generation's abort signal ends both; when the budget runs out first, the request is answered 503 `reason=queue` without a second read or decode. A generation that receives its slot also reads the cache again before it reads the original, so a request that waited at the gate behind a still-running render of the same content is answered from that render's result.
595
+ The generation has its own abort signal, which fires only when every joined request has gone, and passes it to the gate wait and to both ports. An abandoned generation leaves the wait queue, and a port that honours the signal settles early and hands the slot to the next queued generation. A read that settles after the abort is discarded before its result is looked at (see **Read port** above) and nothing is rendered; a render that is running at the abort is kept as above.
565
596
  - **Time budget**: one budget bounds a cache miss from the generation gate onward — the wait for a slot, the read, the render and the transfer — so a request that has reached the gate leaves the server within the sum of those stages, and the client waits exactly that sum before counting a load as failed. Each port stage runs under its own deadline and answers 502 at the deadline even when the port ignores the signal; a deadline that passes after every requester has gone ends as 503 `aborted` instead, since nobody receives it.
566
597
  Two things are outside the budget. Authentication and authorization run before the gate and are bounded only by the host's own timeouts; their time is taken out of the client's sum as well. And the budget cannot end a port that ignores its signal: after the 502 such a port keeps its generation slot, and the server logs `read_stuck` / `render_stuck` once when it has not settled at twice its stage budget.
567
598
 
@@ -574,16 +605,17 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
574
605
  | Client load timeout | 75 s (the sum) | the load counts as one failure |
575
606
 
576
607
  - **Cache**: an LRU bounded by `attachmentThumbnailCacheBytes` and 1,024 entries (each entry weighs its body plus 256 bytes). A preview is stored as an owned copy sized exactly to its body, so a view into a larger buffer never keeps that buffer alive, and later writes to the port's buffer do not change the cached preview.
577
- It stores only verified outcomes: successful previews and the content-determined failures (signature mismatch, the port's `unsupported`, and the read port's `too_large` for a row whose size is unknown — on a row with a known size, which eligibility keeps within `attachmentMaxBytes`, a `too_large` is always unverified), including those a render delivers after its deadline (see **Thumbnail renderer port** above). It never stores unverified outcomes, `failed`, the 502 of a timeout, storage errors (including `not_found`), queue rejections, aborts or exceptions.
608
+ It stores only verified outcomes: successful previews and the content-determined failures (signature mismatch, the port's `unsupported`, and the read port's `too_large` for a row whose size is unknown — on a row with a known size, which eligibility keeps within `attachmentMaxBytes`, a `too_large` is always unverified), including those a render delivers after its generation stopped waiting for it, past its deadline or after every requester left (see **Thumbnail renderer port** above).
609
+ It never stores unverified outcomes, `failed`, the 502 of a timeout, the 503 of an abort, storage errors (including `not_found`), queue rejections or exceptions.
578
610
  - **Presence records**: `not_found` sets the missing mark (`markAttachmentMissing`). A successful read does **not** call `markAttachmentPresent` (it would issue an unconditional UPDATE on every view, and `present` and `unknown` look the same on screen);
579
611
  the client never requests a thumbnail from a summary that already says `absent` (`hasThumbnail: false`). The endpoint itself does not check `state`, though, so a request from an older summary (for example the automatic retry right after a `not_found`) still reads and renders, and a successful read leaves the mark unchanged. Recovery is left to the original download. Cache hits and 304s record nothing.
580
612
 
581
613
  | Status | When |
582
614
  | --- | --- |
583
615
  | 200 | Preview. Headers: the port's `Content-Type`, `Content-Length`, `Cache-Control: private, no-cache` and `ETag` (an unverified preview: `Cache-Control: no-store` and no `ETag`), `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox`, `X-Frame-Options: SAMEORIGIN`, `Cross-Origin-Resource-Policy: same-origin`, forwarded `Set-Cookie` |
584
- | 304 | `If-None-Match` matches (carries `Cache-Control`, `ETag`, `X-Content-Type-Options: nosniff`, `Cross-Origin-Resource-Policy: same-origin`, `Set-Cookie`) |
616
+ | 304 | `If-None-Match` matches (carries `Cache-Control`, `ETag`, `X-Content-Type-Options: nosniff`, `Cross-Origin-Resource-Policy: same-origin`, `X-Frame-Options: SAMEORIGIN`, `Set-Cookie`) |
585
617
  | 400 | A query that names no delivery (unknown or repeated variant such as `?thumbnail=1`, combined with `download`), or a malformed token |
586
- | 401 / 403 | Not authenticated / no internal user |
618
+ | 401 / 403 | Not authenticated / no internal user, or (403, before authentication, the query and the method) a request from another origin's page that the isolation refuses ([Request isolation](#request-isolation): it serves no thumbnail to another origin, not even to a navigation) |
587
619
  | 404 | Ports not injected, not visible, not eligible, signature mismatch, `unsupported`, `too_large`, `not_found`, `denied`, `invalid_path` — one identical body for all |
588
620
  | 405 | Any method other than GET (`Allow: GET`) |
589
621
  | 429 | An empty generation bucket (`reason=rate_limit`) or revalidation bucket (`reason=revalidation_rate_limit`), or a revalidation that is not a matching 304 when no generation token is left after authorization (`reason=rate_limit`) (`Retry-After: 60`) |
@@ -591,10 +623,11 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
591
623
  | 503 | Wait queue full or wait timed out (`queue`), or the generation abandoned by every waiting request (`aborted`; no one receives it) (`Retry-After: 5`) |
592
624
  | 500 | Unexpected exception, including one thrown by a port during generation (logged as `500 attachment=<id> viewer=<id> variant=thumbnail cache=miss reason=port_exception message=<msg>`, never cached), or a port contract violation (`port_contract`: a render result outside the contract, logged with `code=result` for a value that is not an object, `code=failure_reason` for a failure whose reason is neither `unsupported` nor `failed`, or the output check that failed, `code=` `content_type`, `bytes`, `empty`, `too_large`, `signature` or `dimensions`; or a read over `maxBytes`, `code=over_max_bytes`), or a host route that passes no `token` parameter (a route misconfiguration, below) |
593
625
 
594
- Error responses are JSON (`{ "error": { "message": "..." } }`) with `Cache-Control: no-store` (a 404 or 405 without freshness information may otherwise be cached heuristically, `Set-Cookie` included), carry `X-Content-Type-Options: nosniff` and `Cross-Origin-Resource-Policy: same-origin` like every attachment response (**Same-origin loads only** above), and forward `Set-Cookie`. Log lines carry `attachment=<id> viewer=<id> variant=thumbnail`, plus `cache=hit|miss|revalidated` once the cache stage is reached; the 200 and 404 lines of an unverified outcome end with `identity=unverified`.
626
+ Error responses are JSON (`{ "error": { "message": "..." } }`) with `Cache-Control: no-store` (a 404 or 405 without freshness information may otherwise be cached heuristically, `Set-Cookie` included), carry `X-Content-Type-Options: nosniff`, `Cross-Origin-Resource-Policy: same-origin` and `X-Frame-Options: SAMEORIGIN` like every attachment response (**Same-origin loads only** above), and forward `Set-Cookie` (except the isolation's 403, which comes before authentication).
627
+ Log lines carry `attachment=<id> viewer=<id> variant=thumbnail`, plus `cache=hit|miss|revalidated` once the cache stage is reached; the 200 and 404 lines of an unverified outcome end with `identity=unverified`.
595
628
  Unexpected exceptions outside generation go to the loader's shared catch and are logged as `500 attachment=? viewer=? reason=unexpected message=<msg>` (no `variant`).
596
629
  A route that mounts `attachment.loader` without a `token` route parameter is the host's configuration error, on both deliveries: right after the port check the loader throws `[daily-report] params.token must be passed by the route that mounts attachment.loader; declare a "token" route parameter ({apiBasePath}/attachment/{token})`, which that catch logs and answers 500, without decoding an empty token or querying the database.
597
- Rejections up to the renderer-port check (401 / 400 / 405 / 404 for a missing port / 403) are not logged.
630
+ Rejections up to the renderer-port check (401 / 400 / 405 / 404 for a missing port / the 403 of a viewer without an internal user) are not logged by the loader; the request isolation's 403 goes to the handlers' bounded refusal record instead (at most two lines a minute, [Request isolation](#request-isolation)).
598
631
  The package never writes the file path itself; a `port_exception` line includes the port's own error message verbatim, so keep paths out of your port's error messages.
599
632
 
600
633
  **Log levels** (both deliveries; the line formats are fixed):
@@ -667,6 +700,7 @@ The first load of a report that is not cached starts inside the hook's effect, w
667
700
  Mutation failures (save / publish / delete / comment) are surfaced through an error seam: inject `config.onError(info: DailyReportErrorInfo)` to route them into your own toast/notification system, or omit it to use the package's built-in `role="alert"` banner (auto-dismiss + manual close). Failures on a continuation that resolves after a no-reload user switch are suppressed. If you compose `DailyReportActionProvider` yourself instead of using `DailyReportPage`, wrap it in `DailyReportErrorProvider` (both are exported from `@aiquants/daily-report/client`) so `onError` / the banner work.
668
701
 
669
702
  The report id list is **not** part of the loader data: a module-resident NDJSON stream session (`GET {apiBasePath}/ids-stream`, resilient client with cursor resume + exponential backoff) supplies it.
703
+ The client splits the stream into lines as the chunks arrive, examining each decoded character once (a chunk of a 4,000-item line of about 300 KB does not re-read the part of the line already received), and reads a final line without a newline at the end of the stream; a line cut short there is not valid JSON, so it is skipped with one warning and the attempt resumes from its cursor, like any interrupted stream.
670
704
  `createDailyReportClientLoader` warms an existing session (`primeDailyReportIdsStreamSession`) and then bootstraps (`bootstrapDailyReportIdsStreamSession`): when no session exists it is **created at loader time** so the stream fetch runs in parallel with hydration (the dominant cold-load optimization); when one exists, the obfuscated user id is reconciled (a different user destroys and recreates the session before render).
671
705
  If your app overrides `config.apiBasePath`, pass the same value to `createDailyReportClientLoader({ apiBasePath })` — forgetting it costs one wrong-path request on the very first load, self-healed by the page-mount `ensureDailyReportIdsStreamSession`.
672
706
  `DailyReportPage` subscribes via `useDailyReportIdsStream`, rendering the list as soon as the first chunk arrives (on cache misses the server races a fast `TOP 200` first page against the cached full query, so first paint does not wait for the full id scan).
@@ -690,6 +724,7 @@ Each view (List and DetailList) is as tall as the space the host's layout leaves
690
724
  - **G-symmetric frame**: the rows' gutter G is inside the view's box, so the last aligned surface — a List or DetailList row aligned to the bottom, or the List's side pane — sits G = 8 px above the page's bottom edge (where a footer that follows the page starts), the distance from the view-mode toolbar to the first surface at the top.
691
725
  `VirtualScroll` snaps the layer that moves the rows to whole device pixels away from the edge a row is aligned to (the start at position 0 and after an alignment to the top, the end at the maximum position and after an alignment to the bottom; 3.9.0, "Device-pixel snapping" in its README), so an aligned row's surface never comes closer than G to that edge: what the snap adds is less than one device pixel.
692
726
  It adds nothing — the surface sits exactly G from the edge — when the view's height and the row slots are whole numbers of device pixels: the slots are multiples of 4 px (**Row slots on the lattice**), and a host gives the view a height on the 4 px lattice by sizing its own bars on that lattice (for example a window height that is a multiple of 4 under a header and a footer whose heights are rounded up to 4 px).
727
+ That unit is a host contract, exported as `DAILY_REPORT_LAYOUT_LATTICE_PX` (4 CSS px, client entry): it is the package's own lattice unit, not a copy, so a host whose bars are sized from it — or whose own unit is checked to be a multiple of it — stays on the package's lattice.
693
728
  - **Empty list**: an empty view shows exactly one message, `VirtualScroll`'s own empty state with the engine label `labels.noItems` (en "No items", ja 「項目がありません」), which `VirtualScroll` draws over the top of its content, out of the flow, so an empty view keeps the same box. The views render no message of their own; they only theme the engine's (`VIEW_EMPTY_TEXT_THEME_CLASS_NAME` on `VirtualScroll`'s root: 16 px above and below, slate-500 in light and slate-400 in dark: 4.55:1 and 7.66:1 against the page, where the engine's default grey reaches only 4.17:1 in dark).
694
729
 
695
730
  ### Keyboard, focus and selection (List / DetailList)
@@ -721,7 +756,9 @@ The keys are delegated to each view's **list**: the element that holds the view'
721
756
  - **One Tab order rule for both views: only the Tab-stop row's controls are in the Tab order.** The Tab-stop row is the row that owns focus (below) while it is inside the visible range, otherwise the selected row while it is visible, otherwise the first visible row (until the view has reported its visible range: the focus owner, then the selection).
722
757
  Every focusable control the package draws inside a row reads the rule: the List card's primary button, ★ and 既読, the DetailList row's ★, 既読, 編集 (on the viewer's own reports) and 削除, the comment controls (delete and its confirmation, the comment field and its send button), the edit form's fields and buttons, and the attachment links keep their natural order in the Tab-stop row and have `tabIndex=-1` in every other row. The stop row's own element is a stop too where it takes focus: a DetailList row (`tabIndex=0`) and a List row frame whose card is not shown.
723
758
  So the Tab path through a view is one row's controls long, however many rows are rendered: crossing the List takes exactly 3 presses (the primary button, ★ and 既読). Because the focus owner is the stop, a control reached by pointer continues within its own row, and a row in edit mode keeps its form in the Tab order.
724
- The scroll bar's two arrow buttons are not Tab stops in either view (`enableArrowButtonTabStops: false` of `@aiquants/virtualscroll`), because the keys above already scroll the list. The arrows still scroll on a press and while held and keep their names; they sit inside the scroll bar's `role="scrollbar"` element, whose children ARIA 1.2 makes presentational, so whether assistive technology presents them as separate buttons depends on the browser (Chromium does).
759
+ The scroll bar is pointer-only in both views, like a native scroll bar, because the keys above already scroll the list: both views pass `enableArrowButtonTabStops: false`, `@aiquants/virtualscroll`'s one signal that the host scrolls by keyboard itself (3.10.0, its README "Scrollbar accessibility and the Tab order").
760
+ The bar carries `aria-hidden="true"`, so assistive technology sees no `scrollbar` and no `slider` in either view, named or not; its arrow buttons have `tabIndex=-1` and the bar and its thumb no `tabindex`, so the bar adds no Tab stop; and a press anywhere on the bar has its default prevented, so it moves no focus, neither onto a part of the bar nor away from the row that holds it. Every pointer interaction still scrolls: the arrows on a press and while held, the track, the thumb and the tap-scroll circle.
761
+ The bar and its thumb still carry their names (the engine keys `verticalScrollBar` and `verticalScrollThumb`, below), which the hidden bar does not expose.
725
762
  - **Leaving the list** (`Ctrl+Home` / `Ctrl+End`, as in the WAI-ARIA feed pattern): focus moves to the last Tab stop before the list or the first one after it — where real `Shift+Tab` from the list's first stop and real `Tab` from its last stop go — and the browser scrolls it into view as it does for Tab.
726
763
  In the List view the exits are measured from the list like in the DetailList, so on the desktop layout `Ctrl+End` moves to the resize handle (then Tab goes on to the side pane), and `Ctrl+Home` to the last stop before the list (in `DailyReportPage`, the last control of the view-mode toolbar).
727
764
  The stops come from the package's one model of sequential focus navigation (`readFocusCapability`, `listTabbables` and `partitionTabbables` in `src/client/keyboard/dom-node.ts`), which the mobile overlay's Tab wrap uses as well. The order is the flat tree's: open shadow roots at their hosts, slotted elements at their slots (an empty slot shows its own children), and a focusable ancestor before its descendants (the package writes no positive `tabindex`). The list's descendants do not count; a focusable ancestor of the list (a host tab panel around the view, for example)
@@ -768,11 +805,12 @@ The keys are delegated to each view's **list**: the element that holds the view'
768
805
  A change of the list alone (SSE inserts and deletes, a stale removal) keeps what is on screen in place. Rows are keyed by report id in both views, and both views anchor their scroll position on a report, the way CSS scroll anchoring does: the anchor is the first visible report, how many px of it are hidden above the viewport, the last visible row and the scroll position it was taken at, all read from `VirtualScroll`'s handle, whose position is current right after a scroll call.
769
806
  When the list changes, the view finds that report by id and restores the same offset with one scroll, in a layout effect before paint (only when that report or a row of the visible window changed).
770
807
  The anchor is recorded after every change of the position, so it describes the committed position, not the one before the last change:
771
- - **Position changes the view makes itself** — key moves (each frame of a held key included), the reveal of keyboard focus, the List's alignment of the card whose mobile overlay closed and its rescale when the slot P changes, the DetailList's selection alignment and its reveal after the viewer's comment, and the DetailList's row re-measurements — all go through one scroller (`ListViewScroller`: `toIndex`, `by` and `resizeRow`) that records the anchor right after the call, without waiting for the next visible-range report.
808
+ - **Position changes the view makes itself** — key moves (each frame of a held key included), the reveal of keyboard focus, the List's alignment of the card whose mobile overlay closed and its rescale when the slot P changes, the DetailList's selection alignment and its reveal after the viewer's comment, the DetailList's row re-measurements, and the end-to-end test handle's `revealIndex` — all go through one scroller (`ListViewScroller`: `toIndex`, `by` and `resizeRow`) that records the anchor right after the call, without waiting for the next visible-range report.
772
809
  A re-measurement (`resizeRow`) is `VirtualScroll`'s `updateItemSize`, which moves the position by the height change when the row lies above the first visible row (its layout-shift compensation); the update, the compensation and the position are all complete when the call returns, so the record reads the compensated state and never mistakes the compensation for a scroll of the user.
773
810
  An insert or a delete that commits between such a change and the next frame therefore keeps the position: `End`, `PageDown` and a held key are never reverted, focus stays on the key's destination, and a re-measured row shifts no report.
774
- The single path is a type: the keyboard navigation and the views receive only a read-only handle (`ListNavigationHandle`, the getters they read), so a view that calls `scrollToIndex`, `scrollBy`, `scrollTo`, `applyWheel` or `updateItemSize` fails the type check, however the call is spelled; only the anchor's scroller and the end-to-end test handle hold the full handle, and `src/client/components/view-scroller.spec.ts` also scans the sources for such a call outside the scroller.
775
- - **Position changes `VirtualScroll` makes on its own** — the layout-shift compensation of a row re-measured above the first visible row, the reconciliation with a new row height after a render, the re-pinning of a pending alignment after a size change, and the second stage of a compensation the pane had clamped — are reported synchronously, once each change is complete, through its `onScrollAdjust` (3.9.0, the peer floor).
811
+ The single path is a type: the keyboard navigation and the views receive only a read-only handle (`ListNavigationHandle`, the getters they read), so a view that calls `scrollToIndex`, `scrollBy`, `scrollTo`, `applyWheel` or `updateItemSize` fails the type check, however the call is spelled.
812
+ Only the anchor's scroller holds the full handle (the end-to-end test handle gets the read-only part and the scroller, so its one position change, `revealIndex`, records the anchor like a key; see [Test hooks](#test-hooks)), and `src/client/components/view-scroller.spec.ts` also scans the sources for such a call outside the scroller.
813
+ - **Position changes `VirtualScroll` makes on its own** — the layout-shift compensation of a row re-measured above the first visible row, the reconciliation with a new row height after a render, the re-pinning of a pending alignment after a size change, and the second stage of a compensation the pane had clamped — are reported synchronously, once each change is complete, through its `onScrollAdjust` (since 3.9.0).
776
814
  Both views pass the anchor's `handleScrollAdjust` there, which records the anchor from the handle at once, so every position change `VirtualScroll` makes is in the anchor before a list change can commit.
777
815
  - **Scrolls `VirtualScroll` makes for the user** (wheel, drag, the scroll bar, inertia) are recorded each time the view reports its visible range, once per frame. A list change in the frame before that report first advances the anchor by the distance scrolled since it was recorded and restores from there: it neither undoes the user's scroll nor shifts the content by a row.
778
816
  Over rows of one height (the List) the new anchor follows from the distance by arithmetic; over measured rows (the DetailList) it walks the previous list's row heights from the anchor to the report now at the top, so the walk is bounded by the rows passed since the record (one frame's scroll), whatever the list's length or the position.
@@ -833,16 +871,20 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
833
871
  R is read from the root element's computed style and read again whenever a hidden 1 rem probe inside the List (`data-daily-report-root-font-size-probe`) changes size, and the List renders its `VirtualScroll` only once P is known (measured before the first paint).
834
872
  That one value sizes the row frames, `VirtualScroll`'s rows and the keyboard's row geometry, and when it changes the List keeps the first visible row in place by rescaling the scroll position. At the default 16 px root the sum is exactly 160, so P = 160: the card is P − 2G = 144 px, cards are 16 px apart and an edge-aligned card sits 8 px from the edge (exactly 8 when the view's height is a whole number of device pixels, see **G-symmetric frame**). Roots of 12, 20 and 24 px give 136, 188 and 216 (the sums 133, 187 and 214, rounded up), so the card's spare space grows by less than 4 px.
835
873
  P is not a host contract: it follows the host's root font size, so a host or a test locates a row by `[data-daily-report-row="<id>"]` or through the test handle (below), never by its index times a slot height.
836
- - **Row slots on the lattice**: every row slot of both views is a multiple of the 4 px layout lattice u, which is a whole number of device pixels at the ratios 1, 1.25, 1.5, 1.75 and 2 (4, 5, 6, 7 and 8), so every row top sits on a device-pixel boundary wherever the list is scrolled (`VirtualScroll` snaps only the layer that moves the rows; a row starts on a device pixel when the rows above it add up to whole device pixels).
874
+ - **Row slots on the lattice**: every row slot of both views is a multiple of the 4 px layout lattice u (one constant, `LAYOUT_LATTICE_PX` in `src/shared/layout-lattice.ts`, which the List slot, the DetailList estimate and the attachment tile's pad all read), which is a whole number of device pixels at the ratios 1, 1.25, 1.5, 1.75 and 2 (4, 5, 6, 7 and 8), so every row top sits on a device-pixel boundary wherever the list is scrolled (`VirtualScroll` snaps only the layer that moves the rows; a row starts on a device pixel when the rows above it add up to whole device pixels).
837
875
  The List slot is P above. A DetailList row is its measured body plus 2G, and the body — the frame's direct child in every state: the card, the skeleton, the edit form and the error — rounds its content height up to the lattice (`LATTICE_BLOCK_SIZE_CLASS_NAME`: `height: calc-size(auto, round(up, size, 4px))`), so rem line boxes at a root other than 16 px (a 25 px line at a 20 px root) leave the body on the lattice; at a 16 px root the content is already on it. Engines without `calc-size()` drop the declaration and keep the content height (see the browser floor table).
838
- A row that has not been measured yet is laid out at an estimate of 352 px (`UNMEASURED_ROW_HEIGHT_ESTIMATE_PX`), also a multiple of 4, so the unmeasured rows above the window move the rows below by whole lattice steps. An image attachment tile is padded to the lattice as well (**Tile** in [Attachment display](#attachment-display)), so the rows below an image grid stay on it.
876
+ A row that has not been measured yet is laid out at an estimate of 352 px (`UNMEASURED_ROW_HEIGHT_ESTIMATE_PX` = 88 u), also on the lattice, so the unmeasured rows above the window move the rows below by whole lattice steps. An image attachment tile is padded to the lattice as well (**Tile** in [Attachment display](#attachment-display)), so the rows below an image grid stay on it.
839
877
  - **List card**: a wrapping row of the primary button and the action row (markers and toggles), with the preview always on the next line. The primary button takes the remaining width but never less than 96 px, enough for the business-date pill, and the action row keeps to the card's end; on a card too narrow for both (a phone with a pinned host menu), the action row wraps under the button instead of squeezing it to nothing, and the card clips the preview lines that no longer fit.
840
878
  The content of a full card is 6.75 rem: the pills' line 1.5 rem + 0.5 + three one-line preview paragraphs of 1.25 rem, 0.5 rem apart (24 + 8 + 3 × 20 + 2 × 8 = 108 px at a 16 px root), inside a 1 + 15 inset at the top and the bottom (the inset counts the border, below), so 1 + 15 + 6.75R + 15 + 1 ≤ P − 2G with at least 4 px to spare at every root font size (140 ≤ 144 at 16 px).
841
879
  - **Forced colours**: the ring (a box shadow) disappears, so the row frame draws the selection itself: a 2 px solid `Highlight` outline at offset −8 (−G), which puts the line in the ring's band, 0–2 px outside the surface. The frame's corner radius is the surface's radius plus G (`calc(var(--radius-2xl) + 8px)`, 24 at a 16 px root), so the line is concentric with the surface at every root font size.
842
880
  A DetailList row and a List row without a card read their own `aria-current`; a List row with a card reads its card's primary button (`:has(> [data-daily-report-card] > [aria-current=true])`). The selected row differs by shape as well as by colour (WCAG 1.4.1); the surface border stays 1 px in every state, and the focus outline (4–6 px outside) sits beside the selection line without touching it.
843
881
  - **State comes from the row itself**: a row frame styles its surface — the direct child that carries `data-daily-report-row-surface` in every load state — from its own `aria-current` / `:focus-visible`, and the List card surface styles itself from its direct-child primary button (`:has(> …)`). Conditions on an ancestor read only the attributes the package writes itself (`data-daily-report-scrolling`, `data-daily-report-keyboard-focus`), so an ancestor that carries shared attributes such as `aria-current` never lights up a row.
844
882
  - **A key press restyles only what paints the change**: every selector that depends on another element's state ends in the styled element's own class or attribute, and `:has()` sits only on the styled element itself. A featureless subject (`*:`, `group-*`) or an ancestor's `:has(:focus-visible)` would make the browser restyle whole rows or the whole view on each key; `src/client/ui/tailwind-selector-scope.spec.ts` compiles the package's classes and fails on either form.
845
- - **Every row is a relayout boundary**: the row frames of both views are `contain: size layout style` (one token, `ROW_CONTAINMENT_CLASS_NAME`), so a change inside one row — a held body rendered after the key's commit, a load that completes, the selection and focus indicators — lays out that row alone and restyles no other; the layout of a key move stays inside the rows it changes, wherever the list is scrolled.
883
+ - **Every row is a relayout boundary**: the row frames of both views are `contain: size layout style` (one token, `ROW_CONTAINMENT_CLASS_NAME`), so a change inside one row — a held body rendered after the key's commit, a load that completes, the selection and focus indicators — lays out that row alone and restyles no other.
884
+ A key move that shifts `VirtualScroll`'s rendering window (it mounts and unmounts rows) is laid out from the items wrapper's containing block. In `@aiquants/virtualscroll` 3.9 that block is a flex item, which Chromium does not make a relayout boundary, so such a shift lays out from the document root: in the app's keyboard harness (run `2026-10-03T23-04-07-660Z`, 4× CPU) a key's layout CPU p50 equals its document-rooted layout CPU p50, 3.99 ms in the List and 8.12 ms in the DetailList.
885
+ 3.10.0, the peer floor, puts the wrapper in a relayout boundary of its own (`.aqvs-items-boundary`, a zero-height box with `contain: size layout style`; its README "What a scroll step paints"), from which the same shift is laid out inside the list: in virtualscroll's own probe, 60 one-row shifts in a page of 2,231 layout objects ran 60 partial layouts of 218 objects instead of 60 layouts from the document root, with identical pixels.
886
+ In the views the box removes every document-rooted layout of a key's own rendering: in the app's keyboard harness with 3.10.0 no key's rendering lays out from `#document` in any condition, and a key's layout CPU p50 is about 0.2–0.4 ms at 1× CPU on an uncontended host and, at 4× CPU, about 1.1 ms in the List and 6.8–6.9 ms in the DetailList, all of it inside the list (3.99 and 8.12 ms from the document root before). What still lays out from the document root is the List side pane's deferred catch-up to the selection: about once per spaced key, and once per hold when the key is held.
887
+ Inside the box the rows' overflow is ink overflow, so a scroll the browser makes on its own to reveal an overscan row (a find-in-page match there) cannot move the list and moves the nearest outer scroller instead when the row's box lies outside its view; the views' own reveals do not depend on it (one Tab stop per view, rows focused with `preventScroll`, keyboard focus revealed by the scroller).
846
888
  Size containment makes the frame's size independent of its content: its width is the row box `VirtualScroll` lays out, and its height is the slot P the List writes on the frame or, in the DetailList, that row box itself (`h-full`), whose height `VirtualScroll` takes from the measured row.
847
889
  The DetailList therefore measures each row's body — the frame's direct child, as tall as its content rounded up to the 4 px lattice (**Row slots on the lattice**) — and adds 2G, handing the height to `VirtualScroll` through the scroller's `resizeRow` (see **Host selections and list changes**); measuring the frame would read back the box it fills. A held body is not measured, and a body that is replaced (another load state, a released hold) is observed in its place.
848
890
  A frame with layout and size containment that is neither a flex nor a grid item is a relayout boundary in Chromium. Neither frame contains paint, because the hover `shadow-lg` of an unselected surface (22 px below, 12 px to the sides) reaches beyond the 8 px gutter G that paint containment would clip (the resting `shadow-sm`, 4 px below, stays inside G; a selected surface paints no shadow).
@@ -894,13 +936,13 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
894
936
  Selection, focus, colours and borders switch instantly, and with reduced motion nothing moves, script included: the side pane's scroll after the viewer posts a comment is smooth only while the pane's own window does not prefer reduced motion and instant under `prefers-reduced-motion: reduce` (`scrollBehaviorFor` in `src/client/ui/motion.ts` reads the preference on every scroll, because browsers do not apply it to a script's `behavior: "smooth"`).
895
937
  A touch or pen flick starts no glide under `prefers-reduced-motion: reduce` either: `VirtualScroll` continues a flick with a scripted `requestAnimationFrame` glide (for up to 10 s with the views' fast-scroll options), so each view reads the preference of its own window (`useReducedMotion`, which follows a change from the next render and is `false` on the server) and passes inertia options whose start threshold no velocity reaches (`inertiaOptionsFor(true)`); the content still follows the finger 1:1 while it drags and stops when it is released.
896
938
  Both option sets are module constants, so the view's props to `VirtualScroll` stay equal from render to render.
897
- The floating tap-scroll circle and its visual, the scroll bar's thumb and arrow buttons and the scroll-to-edge buttons are `VirtualScroll`'s own: one rule of its stylesheet (since 3.8.2; the peer floor is 3.9.0) stops every transition and animation of every part it animates under `prefers-reduced-motion: reduce`
939
+ The floating tap-scroll circle and its visual, the scroll bar's thumb and arrow buttons and the scroll-to-edge buttons are `VirtualScroll`'s own: one rule of its stylesheet (since 3.8.2; the peer floor is 3.10.0) stops every transition and animation of every part it animates under `prefers-reduced-motion: reduce`
898
940
  (the circle and every element inside it, overriding the visual's inline transitions; the bar's circle wrapper; the arrow buttons; the thumb; the scroll-to-edge buttons and their overlay), so the circle follows a drag without easing and the other parts change state at once.
899
941
  While the list scrolls (`data-daily-report-scrolling` on the view's scroll container) the hover lift and its shadow are held still. `src/client/ui/motion-policy.spec.ts` checks the shipped CSS, and scans the sources so that no smooth scroll behaviour, no `animate()` call and no inertia option other than one chosen by `inertiaOptionsFor` is written outside `src/client/ui/motion.ts`.
900
942
  That check is lexical — the stylesheets and the literals of the scripts. What actually moves is checked in the browser by the host app's reduced-motion end-to-end guard: under `prefers-reduced-motion: reduce`, after each step of a pointer and a touch tour of both views (the tap-scroll circle pressed and dragged, a card hovered, a flick, the mobile overlay opened and closed, a thumbnail loaded), no animation or transition of a motion property runs in the view, including what `VirtualScroll` renders.
901
943
  - **Spacing**: every spacing the package writes is 4 (within a group), 8 (between parts) or 16 (between items), and every box length it writes sits on the 4 px grid, line boxes included: one-line text such as each List preview line has a 20 px line box (`leading-5`, the preview's lines 8 px apart at a 28 px pitch) and the reading text of the side pane, the DetailList content and the comments a 24 px one (`leading-6`); no line height is proportional to the font size.
902
944
  These are the values at the default 16 px root: lengths written in rem (type, line boxes, the card's content, most spacing) scale with the root font size, while the CSS-pixel quantities of the state indicators — G and the lengths built on it — are written in px and keep their values at every root (see **Row gutter G** and **List slot P**).
903
- Two lengths follow the container instead, by design: the attachment grid's fluid track width t = (W − 16 (n − 1)) / n (horizontal) and the tile frame's height derived from it, h(t) = round(2t / 3) (see [Attachment display](#attachment-display)).
945
+ Two lengths follow the container instead, by design: the attachment grid's track width t = ⌊(W − 16 (n − 1)) / n⌋, a whole pixel but not a multiple of 4 (horizontal), and the tile frame's height derived from it, h(t) = round(2t / 3) (see [Attachment display](#attachment-display)).
904
946
  The frame height is not snapped to 4 px: snapping moves the frame's shape more than 0.01 away from 3 : 2 (196 → 132 gives |t / h − 3 / 2| = 0.0152, 171 → 116 gives 0.026) and letterboxes a 3 : 2 image, which fills an unsnapped frame exactly.
905
947
  The tile around the frame is on the lattice anyway: its last line carries the pad δ(t) = ⌈h / 4⌉ · 4 − h (0–3 px) as a bottom margin, so every tile is ⌈h / 4⌉ · 4 + 48 px tall, every tile row top and every grid's height are multiples of 4 px, and what follows a grid stays on the lattice (**Tile** in [Attachment display](#attachment-display)).
906
948
  A bordered box counts its border in its inset, so that its content edge sits on the grid measured from the visible edge: border + padding is on the ladder — 16 for the card surfaces, the error panel, the sides of the error banner and the mobile overlay's close button (1 + 15), 8 for the form fields, the comment input and the top and bottom of the error banner (1 + 7), 4 for the bordered tab bar, the development box and the top and bottom of the text input (1 + 3) — or equals a 12 px corner.
@@ -911,7 +953,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
911
953
  Plain centring puts the column on a half pixel whenever the space beside it is odd (x = 56.5 in a 1,265 px area), and at a fractional ratio each 2 px stroke then blends into its neighbours.
912
954
  On the lattice, each 2 px stroke of the two-channel indicator — the ring, the separation band and the outline — paints ⌊2 × ratio⌋ full device pixels on the left and right edges (2, 3 and 3 at 1.25, 1.5 and 1.75) with no blended pixel on its inner side, and the 1 px border paints its own colour (measured in Chromium at those ratios, light and dark). An engine without CSS `round()` drops the declaration and keeps the `mx-auto` centre.
913
955
  The block-axis origin and the view's height are the host's: the views start and end where the host's layout puts them.
914
- A host that wants the same whole-pixel strokes on the top and bottom edges at fractional ratios puts both edges on the lattice too — for example with a header and a footer whose heights are their content rounded up to 4 px (`height: calc-size(auto, round(up, size, 4px))`) in a window whose height is a multiple of 4, since a bar sized by its font metrics alone ends on a fraction (such as 30.4375 px); the bottom-aligned surface then sits exactly G from the view's end (**G-symmetric frame** in [View height](#view-height-host-layout)).
956
+ A host that wants the same whole-pixel strokes on the top and bottom edges at fractional ratios puts both edges on the lattice too — for example with a header and a footer whose heights are their content rounded up to the lattice unit `DAILY_REPORT_LAYOUT_LATTICE_PX` (`height: calc-size(auto, round(up, size, 4px))`) in a window whose height is a multiple of it, since a bar sized by its font metrics alone ends on a fraction (such as 30.4375 px); the bottom-aligned surface then sits exactly G from the view's end (**G-symmetric frame** in [View height](#view-height-host-layout)).
915
957
  - **Side pane** (List, desktop layout): the panel group fills the view's root, and the list and the side-pane panels both take its height ([View height](#view-height-host-layout)), so a long report scrolls only inside the pane's article tab and never lengthens the page. The pane sits G inside its panel on all four sides, so its top and bottom edges line up with the first and last cards, and its bottom edge sits exactly G above the page's bottom edge, as far as the toolbar is above the first surface.
916
958
  The List panel ends G after its scroll bar (`LIST_PANEL_END_GUTTER_CLASS_NAME`, `pr-[8px]`), so a 2G = 16 px channel lies between the scroll bar and the pane, and the resize handle that `@aiquants/resize-panels` centres on the panel boundary (a 10 px hit area around a 6 px grip) covers neither the scroll bar nor the pane's edge.
917
959
  The pane's header and its scrolling body end on one edge: the date and author column and the tab list sit in boxes that reserve the same scroll-bar gutter as the article and relations tab panel (`SIDE_PANE_HEADER_BOX_CLASS_NAME` and the panel both compose `SCROLLBAR_GUTTER_CLASS_NAME`, `scrollbar-thin` with `scrollbar-gutter: stable`, which an `overflow: hidden` box reserves too), so with a classic thin scroll bar, a wider one or an overlay one of no width, the header's end and the body's end share one x, in the desktop pane and in the mobile overlay alike.
@@ -920,6 +962,11 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
920
962
  - **View-mode toolbar** (`DailyReportResolvedContent`, above every view): its start edge is inset by G and lines up with the cards; its end edge lines up with the active view's content: G in the desktop List, where the side pane ends the row, and G + the scroll-bar width (8 + 8 = 16) in the DetailList and in the single-column List, where the scroll bar ends it. Both insets are written in px (`ml-[8px]`, `mr-[8px]` / `mr-[16px]`), like G and the scroll bar, so they line up at every root font size.
921
963
  It never scrolls sideways and hides nothing: when it is narrower than its one-row width its controls wrap onto a second row, every label kept. It clips nothing, so the tabs' outlines, which reach 4 px outside them into the bar's 4 px inset, are never cut (the **Focus reach** rule above). Its tab list is named by `labels.viewTabList`.
922
964
  - **Scroll bars**: both views theme VirtualScroll's scroll bar through its root class (`VIEW_SCROLL_BAR_THEME_CLASS_NAME`): the track is slate-100 / dark slate-900 and the thumb slate-500 (slate-600 / 400 on hover, slate-700 / 300 while dragged), at least 3:1 against the track in both themes (lowest 4.35 / 3.74); the arrow glyphs reach 6.90 / 6.14 in light and 6.78 / 5.56 in dark on their resting and hover backgrounds.
965
+ In forced colours the browser replaces every background that is not a system colour with `Canvas`, which would leave the thumb invisible on its track, so the token restates the thumb in system colours with the same selector as each state — `CanvasText` at rest, `Highlight` hovered and dragged, `GrayText` disabled — after the light and dark rules, and gives the track a 1 px inset `CanvasText` outline (an outline: the layout does not change).
966
+ These rules win over virtualscroll's own forced-colours rules (`@aiquants/virtualscroll` 3.10.0, its README "Forced colours") the same way the token wins over its default colours, so the views' thumb is drawn by this token in every mode.
967
+ - **Line breaks of wrapping labels**: a short centred label that can wrap — the side pane's selection prompt, whose panel narrows to the panel minimum, and an attachment tile's unavailable label, as wide as its track — breaks its lines through one token, `WRAPPING_LABEL_CLASS_NAME`: `text-wrap: balance` keeps the lines about equally long, so no one-glyph last line is left under the others (engines balance only blocks of a few lines, so the token is for labels, not body text),
968
+ and `word-break: auto-phrase` breaks Japanese at phrase boundaries where the host document's `lang` is `ja` (ja 「プレビューを / 表示できません」 instead of 「プレビューを表示できませ / ん」 in a 171 px track) and behaves as `normal` in other languages.
969
+ The token sets nothing else, and the labels that use it declare no other line-breaking property, so nothing cancels it: `src/client/ui/style-tokens.spec.ts` pins the token's two declarations, the side pane's spec the prompt's classes, and the tile's spec compiles the label's classes and checks that its line-breaking declarations are exactly the token's.
923
970
  - **Type and contrast**: no text is smaller than 12 px (`text-xs`); text reaches 4.5:1 and the indicators 3:1 in both themes (section labels slate-500 / slate-400: 4.77 / 7.09). The floating tap-scroll circle hides while the view root carries `data-daily-report-keyboard-focus` (through the class both views pass in `VirtualScroll`'s tap-scroll circle options).
924
971
  - **Action buttons and icons**: the List card's star and read toggles, the star, read, edit and delete buttons of the DetailList header and the side pane, and the trash button of the viewer's comments share one 24 px round target that never shrinks, with a 16 px SVG icon centred in it (4 px on every side), so the icons sit on the 4 px grid without depending on a font. In the List card the markers and toggles stand 4 px apart; the header pills and the source badge are 24 px tall like the targets.
925
972
  The star is a regular five-pointed star, outlined when off and filled gold when on (with a darker gold edge in the light theme); read is a check, unread an 8 px dot, edit a pencil, delete a trash can. Every icon state reaches at least 3.59:1 against each background it sits on, the button's hover background included, in both themes; in forced colours the star is drawn in `CanvasText` (off) and `Highlight` (on). The side pane shows them in the order star, read, edit, delete, centred on the first line of the subject.
@@ -930,21 +977,26 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
930
977
 
931
978
  - Attachments with `hasThumbnail: true` are laid out as tiles in a grid; the others as rows. Each group keeps the original order.
932
979
  - **Section heading**: the section is headed by `labels.attachments`, a heading one level below the report heading (`config.headingLevel` + 1), and both the grid and the rows list are named by it (`aria-labelledby`).
933
- - **Grid**: the attachment section is a size container, and the number of columns follows its width W: n(W) = 1 + ⌊(W + 16) / 256⌋ up to 5 (breakpoints at 240, 496, 752 and 1,008 px; 256 = the largest frame width 240 + the 16 px gap).
934
- The grid itself is at most 1,264 px wide, the width at which 5 tracks are exactly 240 px, so a track t is never wider than 240 px (t ≤ 240, equal only at the cap) inside any container, however wide, and at a device pixel ratio up to 2 a thumbnail is never upscaled.
935
- Each track is t(W) = (W − 16 (n − 1)) / n. For example, at 1280 × 800 and at 1920 × 1080 the DetailList card's content column is W = 1,136 (the rows, beside the 8 px scroll bar) − 2 × 8 (G) − 2 × (1 + 15) (the card's border and padding) − 16 − 240 (the gap and the metadata column) = 832 px, which holds 4 tracks of 196 px.
980
+ - **Grid**: the grid sits alone in its own size container (`data-testid="daily-report-attachment-grid-container"`, an inline-size container that is also the grid's containing block), so one width W, the container's content width, decides the number of columns, the grid's width and the track width the names are fitted to (**File name**, below).
981
+ The number of columns is n(W) = 1 + ⌊(W + 16) / 256⌋ up to 5 (breakpoints at 240, 496, 752 and 1,008 px; 256 = the largest frame width 240 + the 16 px gap).
982
+ **Every track is a whole pixel**: at n columns the grid is the widest n · t + 16 (n − 1) ≤ W whose track t is a whole pixel, written beside each breakpoint's column count (`calc(round(down, 100% − 16 (n − 1), n px) + 16 (n − 1))`, `round(down, 100%, 1px)` for one column; the tracks stay `1fr`).
983
+ So every track is t(W) = ⌊(min(W, 1,264) − 16 (n − 1)) / n⌋, every gap is exactly 16 px, and the remainder W − (n · t + 16 (n − 1)), 0 to n − 1 px for a whole-pixel W (below n px for a fractional one), is left at the inline end, outside the grid. A fractional track (231⅓ px at W = 726) would put tile edges between pixels and paint the gaps 16 and 17 px wide at a device pixel ratio of 1.
984
+ Every tile edge then sits a whole number of CSS pixels from the grid's start, so it lands on a device pixel at an integer ratio (1, 2, 3) whenever the start does; at a fractional ratio (1.25, 1.5, 1.75) an edge does when its offset is a multiple of 4 px (5, 6 and 7 device pixels there), so all of them do when t is a multiple of 4 (196 below), but not in general (231 at 1.25) — the same bound as the frame's whole-pixel height (**Frame**, below).
985
+ Chrome 121–124, inside the floor, drop the width declarations with `round()` and keep the fluid tracks (W − 16 (n − 1)) / n with the same column counts (see the browser floor table).
986
+ The grid itself is at most 1,264 px wide, the width at which 5 tracks are exactly 240 px (1,264 − 4 × 16 = 5 × 240, so the cap leaves no remainder), so a track t is never wider than 240 px (t ≤ 240, equal only at the cap) inside any container, however wide, and at a device pixel ratio up to 2 a thumbnail is never upscaled.
987
+ For example, at 1280 × 800 and at 1920 × 1080 the DetailList card's content column is W = 1,136 (the rows, beside the 8 px scroll bar) − 2 × 8 (G) − 2 × (1 + 15) (the card's border and padding) − 16 − 240 (the gap and the metadata column) = 832 px, which holds 4 tracks of 196 px with nothing left over.
936
988
  The side pane's grid sits in the pane's scroll box, whose stable scroll-bar gutter g is taken whatever the content's height (the panel's classic scroll-bar width, 10 px for Chromium's thin scroll bar on Linux; 0 for overlay scroll bars), and whose focus reach (`-mx-[4px] px-[4px]`) leaves the content's width unchanged. In a 1,144 px content column with the List panel at its default 360 px the pane's content is 1,144 − 360 − 2 × 8 (the detail panel's G) − 2 × (1 + 15) (the pane's border and padding) = 736 px, so W = 736 − g:
937
- n = 3 at any scroll-bar width (W stays between 496 and 752), with t = (736 − 32) / 3 = 234⅔ for overlay scroll bars and (726 − 32) / 3 = 231⅓ beside a 10 px gutter. The 390 px phone overlay's pane is 358 px wide, W = 358 − g: 2 tracks of 171 px (overlay scroll bars) or of 166 px (a 10 px gutter).
989
+ n = 3 at any scroll-bar width (W stays between 496 and 752), with t = ⌊(736 − 32) / 3⌋ = 234 for overlay scroll bars (2 px left over) and ⌊(726 − 32) / 3⌋ = 231 beside a 10 px gutter (1 px left over). The 390 px phone overlay's pane is 358 px wide, W = 358 − g: 2 tracks of 171 px (overlay scroll bars) or of 166 px (a 10 px gutter).
938
990
  Every native scroll container that holds a grid reserves its scroll-bar gutter (the side pane's scroll box; the views' rows sit beside `VirtualScroll`'s own scroll bar of fixed width), so a grid's track width never depends on whether its container overflows.
939
- Every length of the grid — the breakpoints, the cap and the gap — is written in px, because the bounds are CSS-pixel quantities: in rem they would grow with the root font size (a 20 px root would make the tracks 300 px wide, upscaled 1.25× at a device pixel ratio of 2).
991
+ Every length of the grid — the breakpoints, the cap, the gap and the gaps inside the width expressions — is written in px, because the bounds are CSS-pixel quantities: in rem they would grow with the root font size (a 20 px root would make the tracks 300 px wide, upscaled 1.25× at a device pixel ratio of 2).
940
992
  - **Tile**: one link to the original wraps the thumbnail frame and the file name; below it one line holds the size and the download link. The frame is decorative (`aria-hidden="true"`, no link of its own), so the tile link is named by the file name and a tile has two Tab stops (the preview, then the download). The download link has a 24 px hit area, and its accessible name is the download label followed by the file name.
941
993
  The tile root (the grid item) is the tile's one inline-size container, exactly as wide as its track, so `100cqi` inside the tile is t. A tile is the frame (h, below), the 8 px gap, the 20 px name, the 4 px gap and the 16 px last line, h + 48 px in all, and h need not be a multiple of 4.
942
994
  The last line therefore carries a bottom margin, the lattice pad δ(t) = ⌈h / 4⌉ · 4 − h (`ATTACHMENT_TILE_LATTICE_PAD_STYLE`: `margin-block-end: calc(round(up, h, 4px) - h)`, where h is the frame height's own expression, resolved against the same container), which puts the remainder of 0–3 px under the last line and keeps the gaps between the frame, the name and the last line.
943
- Every tile is then ⌈h / 4⌉ · 4 + 48 px tall, so every tile row top and the grid's height stay on the 4 px lattice, and so does what follows the grid (the DetailList rows below an image grid start on device pixels); for the tracks below, t = 196 gives h = 131 and δ = 1, 171 gives 114 and 2, 231⅓ gives 154 and 2, and 234⅔ gives 156 and 0. On Chrome 121–124, which lack `round()`, the margin declaration is dropped with the frame height, and a tile stays its frame's height + 48 px tall.
995
+ Every tile is then ⌈h / 4⌉ · 4 + 48 px tall, so every tile row top and the grid's height stay on the 4 px lattice, and so does what follows the grid (the DetailList rows below an image grid start on device pixels); for the tracks above, t = 196 gives h = 131 and δ = 1, 171 gives 114 and 2, 231 gives 154 and 2, and 234 gives 156 and 0. On Chrome 121–124, which lack `round()`, the margin declaration is dropped with the frame height, and a tile stays its frame's height + 48 px tall.
944
996
  - **File name**: a long name is shortened in its stem and keeps its extension visible, on one line. In a tile grid the name is fitted exactly to the track: the longest start of the stem that still fits, an ellipsis that touches the extension, then the extension, drawn as one run clipped to the track (so the ellipsis never floats a glyph's width away from the extension, and a sub-pixel misfit is clipped instead of adding a second ellipsis).
945
997
  The extension is kept whole up to half the track; a longer one keeps the start that fits in half the track plus an ellipsis. The names are cut between code points of the NFC-normalized name, not between graphemes, so a combining sequence or a joined emoji at the cut can be split (`Intl.Segmenter` is above the browser floor).
946
- One `ResizeObserver` per document watches every grid in it (created from the document's own window, disconnected when its last grid stops) and supplies the track width, computed from the grid's content width with the column formula above, not by measuring tiles.
947
- A notification only reads its records: it keeps each grid's track width and asks for one animation frame when a track width differs from the one last handed out. It never writes the DOM, never commits and never reads layout, so nothing done during a delivery can resize an element that any observer watches — the grid, or a virtual-scroll row around it — and the browser never reports a ResizeObserver loop.
998
+ One `ResizeObserver` per document watches every grid's size container in it (created from the document's own window, disconnected when its last grid stops) and supplies the track width, computed from the container's content width W with the track formula above, not by measuring tiles. It reads the container, not the grid: the grid's own width leaves the remainder out and does not tell its column count (a 3-column grid at W = 496 is 494 px wide, as wide as a 2-column grid at W = 494).
999
+ A notification only reads its records: it keeps each grid's track width and asks for one animation frame when a track width differs from the one last handed out. It never writes the DOM, never commits and never reads layout, so nothing done during a delivery can resize an element that any observer watches — the grid's container, or a virtual-scroll row around it — and the browser never reports a ResizeObserver loop.
948
1000
  In that frame, before its style and layout, every grid whose track width changed receives it and re-fits its names, all in one `flushSync` however many grids changed. A notification with an unchanged track width (a height-only change, or a new width with the same track, such as 150 px in one column and 316 px in two) or a width of 0 (an ancestor with `display: none`) hands out nothing, a width that changes back before the frame cancels its own hand-out, and a grid hidden and shown again at the same width does not re-render.
949
1001
  The frames never wait for this: CSS sizes them in the layout that sizes the tracks (**Frame**, below). Only the names do, so a grid's first frame draws its long names with the CSS truncation described below, and the next frame draws them fitted.
950
1002
  The text is measured with one canvas `measureText` per document in the names' computed font (no forced layout), with one measuring function per font string, so a fit whose width and measure are unchanged keeps its identity and no name re-renders. One `loadingdone` listener per document (on `document.fonts`, added after the first fit) re-fits only the grids whose names a loaded face can draw — the face's family is one of the names' families and its `unicode-range` covers a character of a name, of its NFC form or the ellipsis (an unreadable range counts as covering)
@@ -955,8 +1007,8 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
955
1007
  - **Marker**: the List card and the DetailList header show `DailyReportAttachmentIndicator` when a report has attachments: a 24 px tall box without padding or radius (its first ink starts at the content edge) holding a 16 px paperclip icon and, from 2 attachments on, the count in 12 px type on a 16 px line; it is named `<labels.attachments>: <count>` (`role="img"`).
956
1008
  Its required `id` prop lands on that `role="img"` element, so a row's description can reference the marker (pass an id unique in the document, for example from `useId()`); with no attachment nothing is rendered, and the description leaves it out.
957
1009
  - **Frame**: as wide as its track and h(t) = round(2t / 3) whole CSS px tall — the ratio of the variant box (480 × 320 for `tile`), rounded to the nearest pixel. CSS computes it, in the same layout that sizes the track: each frame sits in its tile, whose root is the inline-size container exactly as wide as its track (so `100cqi` is t; the frame's parent is a plain block wrapper, not a container), and has the style `aspect-ratio: 480 / 320; height: round(nearest, 100cqi * 320 / 480, 1px); contain: strict`, the sizes built from the variant box (`ATTACHMENT_TILE_FRAME_STYLE`).
958
- No script measures or writes a frame, so its height is right from the first frame, a width change resizes the frames in the layout that changes the width, and every row of a grid draws its frames with the same number of device pixels at an integer device pixel ratio (a fractional height such as 130.33 px would give rows of 130 and 131; at a fractional ratio such as 1.25 or 1.5 the product can still be fractional).
959
- The rounding keeps the frame within |t / h − 3 / 2| ≤ 0.75 / h, under 0.01 for every track of a grid with two or more columns (t ≥ 112, h ≥ 75); for the widths above, 196 → 131, 234⅔ → 156, 231⅓ → 154, 171 → 114 and 166 → 111 (each tile adds the lattice pad under its last line, **Tile** above). On Chrome 121–124, which lack `round()`, the height declaration is dropped and the aspect ratio alone gives the fractional 2t / 3 (see the browser floor table).
1010
+ No script measures or writes a frame, so its height is right from the first frame, a width change resizes the frames in the layout that changes the width, and every row of a grid draws its frames with the same number of device pixels at an integer device pixel ratio (a fractional height such as 130.67 px, 2t / 3 at t = 196, would give rows of 130 and 131; at a fractional ratio such as 1.25 or 1.5 the product can still be fractional).
1011
+ The rounding keeps the frame within |t / h − 3 / 2| ≤ 0.75 / h, under 0.01 for every track of a grid with two or more columns (t ≥ 112, h ≥ 75); for the widths above, 196 → 131, 234 → 156, 231 → 154, 171 → 114 and 166 → 111 (each tile adds the lattice pad under its last line, **Tile** above). On Chrome 121–124, which lack `round()`, the height declaration is dropped and the aspect ratio alone gives the fractional 2t / 3 (see the browser floor table).
960
1012
  The height depends only on the track and stays the same before, during and after loading and after a failure (virtual-scroll row heights do not shift).
961
1013
  The frame paints nothing itself: its only child, the **skin** (`data-testid="daily-report-attachment-thumbnail-skin"`), is an absolutely positioned box that fills it and paints every state — the background, the waiting pulse, the rounded clip, the inner rim — and holds the image or the failure message.
962
1014
  The image keeps its aspect ratio inside the skin and is not enlarged; it has no corner radius of its own, and the skin's rounded clip (`overflow: hidden`) alone makes the corners, so an image narrower or shorter than the frame shows no notches of the background where it meets the straight edges (the frame does not clip the corners as well: a second clip at the same edge anti-aliases the corner pixels twice and lightens them).
@@ -964,7 +1016,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
964
1016
  The frame is a relayout boundary: its strict containment (size, layout, paint, style) changes neither its size, which comes from its own style alone (the container's full width and the height above, or the aspect ratio), nor what is painted, since paint containment clips at the frame's edge and the skin lies inside it;
965
1017
  and as a positioned box that is not a flex or grid item (its parent is a block wrapper inside the tile link), Chromium lays out what changes inside the frame — the image's intrinsic size arriving on load, a style change of the skin — inside the frame alone, not from the document root.
966
1018
  Only a change inside the frame stops there: a boundary whose own computed style changes is laid out by its parent, from the document root. So the frame's own style never changes after mount — its classes are its box alone (`relative block w-full`: no variant, no paint, no animation) and its inline style is `ATTACHMENT_TILE_FRAME_STYLE` — and every state is drawn inside it, by the skin and the image, which read the frame's attributes.
967
- - **`data-thumbnail-state`** on the frame is `pending` (waiting or loading), `loaded` (the image fades in) or `unavailable` (an icon and `labels.attachmentThumbnailUnavailable` inside the skin; the icon reaches 4.35:1 / 5.56:1 and the label 6.90:1 / 5.58:1 in the light / dark theme). The loader's internal phases are not exposed.
1019
+ - **`data-thumbnail-state`** on the frame is `pending` (waiting or loading), `loaded` (the image fades in) or `unavailable` (an icon and `labels.attachmentThumbnailUnavailable` inside the skin; the icon reaches 4.35:1 / 5.56:1 and the label 6.90:1 / 5.58:1 in the light / dark theme, and the label breaks its lines by **Line breaks of wrapping labels** in [Selection and focus appearance](#selection-and-focus-appearance)). The loader's internal phases are not exposed.
968
1020
  - **The waiting pulse runs only where the tile can be seen**: a `pending` frame's skin pulses (under `prefers-reduced-motion: no-preference`) only while the frame also carries the boolean attribute `data-thumbnail-active`. The frame's load controller decides it (`showActivity`) and the attribute is toggled on the frame element, without a React render, so visibility changes and key moves add no commit; only the skin reads it, so a toggle restyles the skin, never the frame (see **Frame**):
969
1021
  an observed frame (waiting for its 250 ms or for a slot, below) carries the attribute exactly while it is visible, and before its first visibility record it carries none; a granted load sets it and stops observing, so the frame keeps it through the load and, after a failed attempt, through the retry wait — both bounded — until observation starts again (the attribute is removed then) or the frame is released (unmount, a URL change, hiding under `<Activity>`). Once the image has loaded the state is no longer `pending`, so a remaining attribute pulses nothing.
970
1022
  A frame rendered but never seen — a row in the overscan — therefore never pulses: an animation whose element is not visible is not composited, and Chromium would restyle it on the main thread on every frame of an otherwise idle page. A frame that starts from the page's memory (a loaded URL, a remembered failure) is not observed and is only told to hide its activity when it is released.
@@ -986,7 +1038,8 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
986
1038
 
987
1039
  | `data-testid` | Element |
988
1040
  | --- | --- |
989
- | `daily-report-attachment-section` | Attachment section (the size container) |
1041
+ | `daily-report-attachment-section` | Attachment section: the heading, then the grid's container and the rows |
1042
+ | `daily-report-attachment-grid-container` | The grid's size container (an inline-size container and the grid's containing block): its content width W decides the column count, the grid's whole-pixel width and the track width the names are fitted to, and the width observer watches it |
990
1043
  | `daily-report-attachment-grid` | `<ul>` of tiles (only when at least one attachment has a thumbnail) |
991
1044
  | `daily-report-attachment-rows` | `<ul>` of rows (only when at least one attachment has no thumbnail) |
992
1045
  | `daily-report-attachment-item` | One attachment (tile or row), with `data-attachment-state` = `present` / `absent` / `unknown` |
@@ -1026,7 +1079,9 @@ DOM hooks are `data-*` attributes, test ids and ARIA; class names are styling an
1026
1079
 
1027
1080
  Only the package writes `data-daily-report-keyboard-focus` and `data-daily-report-scrolling`. Hosts may select on them — in tests, or in CSS to adapt their own content inside a view — but never set them. When the pane content matters, wait for `data-displayed-report-id`, not `data-report-id`: during a key's render the frame already names the new selection while the content still shows the previous report.
1028
1081
 
1029
- `[data-testid="daily-report-list"]` and `[data-testid="daily-report-detail-list"]` carry a read-only `__virtualScroll` accessor for end-to-end tests, with the same shape in both views: the current `VirtualScroll` handle plus `findReportIndex(id)`, `getReportItem(index)` and `getReportIds(limit = 20)` over the view's committed list. It only observes: select through the UI (a click, the keys). Read it on every use; it is `undefined` while the list is not mounted.
1082
+ `[data-testid="daily-report-list"]` and `[data-testid="daily-report-detail-list"]` carry a `__virtualScroll` accessor for end-to-end tests, with the same shape in both views. It is the read-only part of the current `VirtualScroll` handle — `getViewportSize()`, `getScrollPosition()`, `getScrollAnchor()`, `getRange()` and `getFenwickSize()` — plus `findReportIndex(id)`, `getReportItem(index)` and `getReportIds(limit = 20)` over the view's committed list, and one way to move the position, `revealIndex(index, options)`.
1083
+ The client entry exports the contract as types, so a host's tests import it instead of restating it (type-only, no bundle bytes): `DailyReportViewTestHandle` is the accessor, `DailyReportViewTestReadHandle` its read-only part, and `DailyReportRevealOptions` the options of `revealIndex`, which are the second argument of `VirtualScroll`'s `scrollToIndex` (its alignment and offset, as the peer defines them).
1084
+ `revealIndex` goes through the view's scroller (the `VirtualScroll` alignment of `scrollToIndex`, then the anchor's record, like a key move; see **Host selections and list changes**), so a list change right after it keeps the revealed report in place. The accessor has none of the handle's functions that move the position (`scrollToIndex`, `scrollBy`, `scrollTo`, `applyWheel`, `updateItemSize`) and no selection backdoor: select through the UI (a click, the keys). Read it on every use; it is `undefined` while the list is not mounted.
1030
1085
 
1031
1086
  ### Localization (`locale` / `labels`)
1032
1087
 
@@ -1044,14 +1099,14 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1044
1099
  />
1045
1100
  ```
1046
1101
 
1047
- - **One surface for all wording.** The catalog covers the 7 engine chrome keys of
1102
+ - **One surface for all wording.** The catalog covers the 11 engine chrome keys of
1048
1103
  `@aiquants/virtualscroll` plus 83 own keys: field headings, the page title (`title`, passed to
1049
1104
  `renderHeader` and used as the accessible name of both lists), the lists' keyboard help, the row state read to
1050
1105
  screen readers, tabs, buttons, placeholders, `confirm()` / `alert()` texts, stream badges, the load-error screen
1051
1106
  and the mutation-failure message. The engine part of each catalog is `VIRTUAL_SCROLL_LABEL_CATALOGS[locale]`, reused by
1052
- value, so the scroll arrows and the "No items" text always speak the same language as the rest.
1107
+ value, so the scroll arrows, the names of the scroll bar and its thumb, and the "No items" text always speak the same language as the rest.
1053
1108
  - **Forwarded to every embedded `VirtualScroll`.** Both the List and the DetailList pass `locale`
1054
- and the 7 engine keys of the resolved labels to their `VirtualScroll`. The engine label object keeps
1109
+ and the 11 engine keys of the resolved labels to their `VirtualScroll`. The engine label object keeps
1055
1110
  its identity while its values are unchanged, so rebuilding `config.labels` on every render does not
1056
1111
  re-render the memoized list subtree.
1057
1112
  - **Formatter keys.** Ten keys take arguments and are functions: `totalCount(count)`,
@@ -1064,7 +1119,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1064
1119
  the BCP 47 tag bound to their locale (`en` → `"en-US"`, `ja` → `"ja-JP"`); a host override receives
1065
1120
  the raw number and formats it itself.
1066
1121
  - **Fail-fast**: an unsupported `locale` (`"EN"`, `"ja-JP"`, `"fr"`, `""`, `null`, ...) throws a
1067
- `RangeError` at render. So do an unknown `labels` key (a key error that lists the 90 keys), a string
1122
+ `RangeError` at render. So do an unknown `labels` key (a key error that lists the 94 keys), a string
1068
1123
  key whose value is not a string with non-whitespace content, and a formatter key whose value is not a
1069
1124
  function. An `undefined` value keeps the catalog value.
1070
1125
  - **`config` has a closed key set**: `apiBasePath`, `ssePath`, `draftLabelName`, `locale`, `labels`,
@@ -1101,7 +1156,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1101
1156
  grouping follows `locale` (`en` → `"en-US"`, `ja` → `"ja-JP"`) and dates stay in ISO form. Packages
1102
1157
  that do format dates (for example `@aiquants/period-slider`) name that separate axis `formatLocale`.
1103
1158
 
1104
- Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 90 keys, the 7 engine keys
1159
+ Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 94 keys, the 11 engine keys
1105
1160
  first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReportLabels(locale, labels)`
1106
1161
  (the effective frozen labels — the catalog object itself when `labels` is `undefined`), the resolved
1107
1162
  `locale` / `labels` on `useDailyReportConfig()`, and the types `DailyReportLocale` (= `VirtualScrollLocale`),
@@ -1113,6 +1168,10 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
1113
1168
  | `scrollDown` | engine: vertical ScrollBar down arrow (`aria-label`) | Scroll down | 下へスクロール |
1114
1169
  | `scrollLeft` | engine: horizontal arrow (not rendered by this package) | Scroll left | 左へスクロール |
1115
1170
  | `scrollRight` | engine: horizontal arrow (not rendered by this package) | Scroll right | 右へスクロール |
1171
+ | `verticalScrollBar` | engine: name (`aria-label`) of the vertical ScrollBar (`role="scrollbar"`); both views hide their pointer-only bar from assistive technology, so it is not announced | Vertical | 縦方向 |
1172
+ | `horizontalScrollBar` | engine: name of the horizontal ScrollBar (not rendered by this package) | Horizontal | 横方向 |
1173
+ | `verticalScrollThumb` | engine: name (`aria-label`) of the vertical ScrollBar's thumb (`role="slider"`), inside the hidden bar | Vertical scroll position | 縦スクロール位置 |
1174
+ | `horizontalScrollThumb` | engine: name of the horizontal ScrollBar's thumb (not rendered by this package) | Horizontal scroll position | 横スクロール位置 |
1116
1175
  | `scrollToTop` | engine: top pill (not enabled by this package) | Top | 先頭へ |
1117
1176
  | `scrollToBottom` | engine: bottom pill (not enabled by this package) | Bottom | 末尾へ |
1118
1177
  | `noItems` | engine: the one message of an empty List or DetailList, over the top of the list | No items | 項目がありません |
@@ -1149,7 +1208,7 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
1149
1208
  | `createReport` | create button | New report | 日報作成 |
1150
1209
  | `createReportTitle` | create button tooltip | Create a daily report | 日報を作成 |
1151
1210
  | `devControls` | dev toolbox tooltip (`showDevControls`) | Development-only controls | 開発環境限定コントロール |
1152
- | `devSseToggle` | dev toolbox: SSE switch tooltip | Toggle SSE real-time sync | SSE リアルタイム同期切替 |
1211
+ | `devSseToggle` | dev toolbox: the SSE switch's accessible name (`aria-label` on the `role="switch"` button) and tooltip | Toggle SSE real-time sync | SSE リアルタイム同期切替 |
1153
1212
  | `devReload` | dev toolbox: reload tooltip | Reload data | データ再取得 (リロード) |
1154
1213
  | `devClearCache` | dev toolbox: clear-cache tooltip | Clear the cache and reload | キャッシュ破棄 & 再取得 |
1155
1214
  | `autoRead` | auto-read switch label | Auto-read | 自動既読 |
@@ -1272,19 +1331,20 @@ Every breaking change and its migration is listed per version in `CHANGELOG.md`,
1272
1331
  ## Realtime Architecture
1273
1332
 
1274
1333
  1. Mutation services publish to Redis Stream (`daily-report:sse-stream`) in sequence: `invalidate -> epoch increment -> SSE publish`.
1275
- 2. `DailyReportSseReader` uses a single blocking `xRead` loop to fan-out events to all SSE connections (eliminating per-connection Redis TCP connections).
1334
+ 2. `DailyReportSseReader` uses a single blocking `xRead` loop to fan-out events to all SSE connections (eliminating per-connection Redis TCP connections). `createDailyReportServer` creates it; `createDailyReportHandlers` takes the reader as `sseReader` typed by the port `DailyReportSseReaderPort` — `ready()` and `subscribe(onEntry, onError)`, with the contract under **`connected` frame** below — which is the type a host's own reader implements: a plain object with the two methods is accepted, while the class type, which has private fields, would refuse one.
1276
1335
  3. `sse.loader` is built on `@aiquants/sse/server` (`createSseResponse`, `terminalStreamResponse`, `startSseHeartbeat`, `readLastEventId`). Its responses carry only `SSE_RESPONSE_HEADERS` plus the forwarded `Set-Cookie` (no `Connection` or other hop-by-hop header, which HTTP/2 forbids). `recipientRawUserId` is filtered server-side and removed before transmission to prevent internal ID leaks. Catch-up and live entries pass through the same filters (recipient, source-type visibility, comment redaction).
1277
- 4. On the client, `useDailyReportSseConnection` runs on `useReopeningEventSource` (`@aiquants/sse/react`): full-jitter backoff from 2 s to 30 s, the failure count reset only after 30 s of healthy open, one native browser reconnect (which carries the cursor in the `Last-Event-ID` header), and a stale watch of 45 s (three heartbeat periods).
1336
+ 4. On the client, `useDailyReportSseConnection` runs on `useReopeningEventSource` (`@aiquants/sse/react`): full-jitter backoff from 2 s to 30 s, the failure count reset only after 60 s of healthy open (the 45 s stale window plus one heartbeat period), one native browser reconnect (which carries the cursor in the `Last-Event-ID` header), and a stale watch of 45 s (three heartbeat periods).
1278
1337
  The action context correlates optimistic updates with SSE echoes using `clientTempId`, and exposes the connection status as `sseStatus` (`DailyReportSseConnectionStatus`: the reopening status, or `{ kind: "resyncing" }` while a `resync-required` waits for a fresh ids anchor).
1279
1338
 
1280
1339
  ### SSE wire contract
1281
1340
 
1282
- Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000` before one named terminal event, so a client that does not listen for the event (an older bundle) reconnects at most once a day instead of in a loop. A non-200 response is reserved for a misconfigured endpoint.
1341
+ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000` before one named terminal event, so a client that does not listen for the event (an older bundle) reconnects at most once a day instead of in a loop. A non-200 response is reserved for a misconfigured endpoint, an unexpected error and a request that is not the package's client (another origin's page).
1283
1342
 
1284
1343
  | Server-side situation | Response | Client (`useDailyReportSseConnection`) |
1285
1344
  | --- | --- | --- |
1286
1345
  | Not authenticated | 200, `retry: 86400000` → `event: auth-required` (`Set-Cookie` forwarded) | terminated; calls `config.onSessionExpired()` once; never reconnects |
1287
1346
  | Authenticated but no internal user | 200, `retry: 86400000` → `event: forbidden` | terminated; no navigation (a reload cannot fix it); visible through `sseStatus` |
1347
+ | A request from another origin's page that the isolation refuses ([Request isolation](#request-isolation)) | 403 JSON before authentication (`Cache-Control: no-store`, no `Set-Cookie`), counted in the bounded refusal record | never met: the package's client connects from the page's own origin |
1288
1348
  | Unknown endpoint | 404 | treated as retryable; backoff up to 30 s |
1289
1349
  | Unexpected error before the stream starts (for example the internal-user lookup fails) | 500 with the body `Internal Server Error` (no error detail), `Set-Cookie` forwarded | treated as retryable; backoff up to 30 s |
1290
1350
  | Malformed cursor | 200, `retry: 86400000` → `event: resync-required` | rereads the ids (server cache bypassed) and reopens from the new anchor |
@@ -1326,12 +1386,14 @@ Every entry exports by name (no `export *`), so a module-internal helper never b
1326
1386
  - Components: `DailyReportPage` / `DailyReportResolvedContent` / `DailyReportList` / `DailyReportDetailList` / `DailyReportAttachmentIndicator`, the providers `DailyReportConfigProvider` / `DailyReportActionProvider` / `DailyReportErrorProvider`.
1327
1387
  - Hooks: `useDailyReportConfig` / `useDailyReportActionContext` (`sseStatus`) / `useDailyReportDetail` / `useDailyReportPrefetch` / `useDailyReportComments` / `useDailyReportSseConnection` (returns `DailyReportSseConnectionStatus`) / `useDailyReportIdsStream` (state carries `streamAnchor`).
1328
1388
  - The ids stream: `DailyReportIdsStreamClient` (`resync()`) / `DailyReportIdsStreamStatus` / `primeDailyReportIdsStreamSession` / `bootstrapDailyReportIdsStreamSession` / `ensureDailyReportIdsStreamSession` / `resyncDailyReportIdsStream`; the route helpers `createDailyReportClientLoader` / `dailyReportShouldRevalidate`.
1329
- - Configuration and wording: `defaultDailyReportClientConfig` / `resolveDailyReportLabels` / `DAILY_REPORT_LABEL_CATALOGS` / `DAILY_REPORT_LABEL_KEYS` / `resolveSourceTypeConfig`. No layout value is public: the row geometry follows the host's root font size (see **List slot P**), so rows are located by `[data-daily-report-row]` or the test handle.
1330
- - Types: `DailyReportClientConfigInput` / `DailyReportClientConfigDefaults` / `SourceTypeConfig` / `DailyReportLocale` / `DailyReportLabels` / `DailyReportLabelOverrides` / `DailyReportOperation` / `DailyReportErrorInfo` / `DailyReportSseConnectionStatus`.
1389
+ - Configuration and wording: `defaultDailyReportClientConfig` / `resolveDailyReportLabels` / `DAILY_REPORT_LABEL_CATALOGS` / `DAILY_REPORT_LABEL_KEYS` / `resolveSourceTypeConfig`.
1390
+ - Layout: `DAILY_REPORT_LAYOUT_LATTICE_PX`, the 4 px lattice unit a host sizes its bars on (see **G-symmetric frame**); it is the only public layout value. The row geometry is not public: it follows the host's root font size (see **List slot P**), so rows are located by `[data-daily-report-row]` or the test handle.
1391
+ - Types: `DailyReportClientConfigInput` / `DailyReportClientConfigDefaults` / `SourceTypeConfig` / `DailyReportLocale` / `DailyReportLabels` / `DailyReportLabelOverrides` / `DailyReportOperation` / `DailyReportErrorInfo` / `DailyReportSseConnectionStatus`, and the end-to-end test handle's contract `DailyReportViewTestHandle` / `DailyReportViewTestReadHandle` / `DailyReportRevealOptions` (see [Test hooks](#test-hooks)).
1331
1392
  - **server** (`@aiquants/daily-report/server`, Node.js):
1332
1393
  - Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor }`; `readSseStreamAnchor`; `attachmentDelivery`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
1333
1394
  - Attachments: `createSharpThumbnailRenderer` / `predictSharpThumbnailWorkingSetBytes` / `DAILY_REPORT_SHARP_THUMBNAIL_WORKING_SET_MODEL` / `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_BUDGET_BYTES` / `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_WORK_MODEL` (the render deadline `renderMs` and the decode-work and walk bounds, for a host's conformance checks) / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_OUTPUT_MEDIA_TYPES` / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_MAX_OUTPUT_BYTES`.
1334
1395
  - SSE and caching: `DailyReportSseReader` / `DAILY_REPORT_SSE_VISIBILITY_REFRESH_MS` / `DAILY_REPORT_SSE_CATCH_UP_BATCH` / `isStreamIdLte` / `SqlResultCache` / `transformJsonArray` / `jsonResponseWithETag` / `generateETag`.
1335
- - Types: `DailyReportServerConfig` / `DailyReportServiceConfig` / `DailyReportTables` / `DailyReportDb` / `DailyReportAuthenticate` / `DailyReportAuthResult` / `DailyReportIdCodec` / `DailyReportRedisProvider` / `StreamEntry` / `ExternalReportFields`, and for attachments `DailyReportAttachmentDelivery` / `DailyReportReadAttachment` / `DailyReportAttachmentError` / `DailyReportAttachmentFailure` / `DailyReportAttachmentThumbnailRenderer` / `DailyReportAttachmentThumbnail` / `DailyReportAttachmentThumbnailSourceFormat` / `DailyReportAttachmentThumbnailOutputMediaType` / `SharpLike`.
1396
+ - Types: `DailyReportServerConfig` / `DailyReportServiceConfig` / `DailyReportTables` / `DailyReportDb` / `DailyReportAuthenticate` / `DailyReportAuthResult` / `DailyReportIdCodec` / `DailyReportRedisProvider` / `DailyReportSseReaderPort` (the shared reader `createDailyReportHandlers` takes) / `StreamEntry` / `ExternalReportFields`,
1397
+ and for attachments `DailyReportAttachmentDelivery` / `DailyReportReadAttachment` / `DailyReportAttachmentError` / `DailyReportAttachmentFailure` / `DailyReportAttachmentThumbnailRenderer` / `DailyReportAttachmentThumbnail` / `DailyReportAttachmentThumbnailSourceFormat` / `DailyReportAttachmentThumbnailOutputMediaType` / `SharpLike`.
1336
1398
 
1337
1399
  MIT