@aiquants/daily-report 0.25.0 → 0.27.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.8.2 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)), and its stylesheet stops the motion of its own parts under `prefers-reduced-motion: reduce` (3.8.2; see [Selection and focus appearance](#selection-and-focus-appearance)). The label catalog reuses its 7 engine keys (see [Localization](#localization-locale--labels)).
30
- That floor is declared once, in `peer-floors.json` (`{ "@aiquants/virtualscroll": "3.8.2" }`), 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).
29
+ `@aiquants/virtualscroll` must be **3.10.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
+ 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).
31
32
  `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).
32
33
  `@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.
33
34
  The server entry needs **Node.js 20.3 or later** (`engines.node` `>=20.3.0`): the thumbnail stage deadlines combine the generation's signal with a timer through `AbortSignal.any`. `src/server/node-engine-floor.spec.ts` reads the server-side modules' syntax tree and fails when one of them uses a listed runtime API newer than the declared floor.
@@ -35,6 +36,9 @@ Build / test: `pnpm run build` (tsup → dist) / `pnpm run test` (vitest) / `pnp
35
36
  `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.
36
37
  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.
37
38
  `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 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.
40
+ `--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.
41
+ 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).
38
42
  `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.
39
43
  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).
40
44
  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,14 +48,15 @@ The guards are the violations of the workspace's docstring and comment language
44
48
  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.
45
49
  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.
46
50
 
47
- **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. Two CSS features of the table are newer than parts of the floor, and the row says what those engines draw:
51
+ **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:
48
52
 
49
53
  | Feature | Supported from | Used for | Below the floor |
50
54
  | --- | --- | --- | --- |
51
55
  | `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 |
52
56
  | `: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) |
53
- | 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 (`100cqi` of the frame's own inline-size container); the view-mode toolbar's end inset in the single-column List | One attachment column at every width; frames sized by `aspect-ratio` alone (below); the toolbar's end edge overhangs the List's scroll bar by 8 px |
54
- | CSS `round()` | Chrome 125, Firefox 118, Safari 15.4 | The tile frame's whole-pixel height `round(nearest, 100cqi * 320 / 480, 1px)` (see [Attachment display](#attachment-display)) | Chrome 121–124, inside the floor, drop that height declaration: the frame's `aspect-ratio: 480 / 320`, always declared beside it, sizes the frame at the exact fractional 2t / 3, so the frames of one grid can paint one device pixel apart |
57
+ | 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 |
58
+ | 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 |
59
+ | 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 |
55
60
  | `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 |
56
61
  | `<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 |
57
62
 
@@ -164,6 +169,20 @@ A rejected value — a wrong type included, also a port or a function given with
164
169
  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`.
165
170
  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.
166
171
 
172
+ ### Request isolation
173
+
174
+ Every resource route the handlers serve — `api.loader`, `api.action`, `sse.loader` and `attachment.loader` — checks the request's Fetch Metadata first (`isCrossSiteRequest` in `src/server/request-isolation.ts`), before authentication, any rate charge, authorization or service call:
175
+
176
+ | `Sec-Fetch-Site` | Answer |
177
+ | --- | --- |
178
+ | `same-origin`, `none` (a load the user started: the address bar, a bookmark), or no header (a client that is not a browser; every browser of the floor sends it) | Served: authentication and the route's own checks follow |
179
+ | `same-site` (another origin of the same site, such as a sibling subdomain), `cross-site` or any other value | 403 before authentication, unless the request is a top-level navigation: `Sec-Fetch-Mode: navigate`, method `GET` or `HEAD`, and a `Sec-Fetch-Dest` other than `object` and `embed` (a link from another page to an original opens it) |
180
+
181
+ - **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`; on the attachment route, the same body through the loader's own failure builder, so it carries the attachment headers (**Same-origin loads only** in [Attachments](#attachments)). Nothing has authenticated, so no `Set-Cookie` is forwarded, and no log line is written. A cross-site navigation that is not `GET` or `HEAD` (a form `POST` from another site) is refused too, on the attachment route before its method check.
182
+ - **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.
183
+ A response policy such as `Cross-Origin-Resource-Policy` decides only whether a response may be read; 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 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.
184
+ - **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.
185
+
167
186
  ### Source-type visibility
168
187
 
169
188
  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.
@@ -242,7 +261,8 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
242
261
  - **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).
243
262
  - **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.
244
263
  - **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.
245
- - **Same-origin loads only**: every attachment response with a body or a validator — the original's 200 (inline and download, GET and HEAD) and the thumbnail's 200 and 304 — carries `Cross-Origin-Resource-Policy: same-origin`, so the browser lets only pages of the host's own origin load it. The session cookie also accompanies requests from other origins of the same site, so without the header a same-site page could load guessed tokens as images and learn, token by token, whether the viewer can see each attachment (a load or an error); with it every cross-origin load fails alike.
264
+ - **Same-origin loads only**: a request from another origin's page that is not a top-level `GET` / `HEAD` navigation — an `<img>`, a no-cors `fetch`, a `HEAD` probe from a sibling subdomain — is refused with 403 before authentication ([Request isolation](#request-isolation)), 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. A top-level navigation from another site (a link to an original) is served after authentication and authorization as usual.
265
+ 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 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 read it. Both headers come from one set that every path building an attachment response spreads last, so no status can lose them.
246
266
 
247
267
  **Configuration**
248
268
 
@@ -404,10 +424,11 @@ type DailyReportAttachmentThumbnailRenderer = {
404
424
  - `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)
405
425
  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 ""`).
406
426
  - `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.
407
- - `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).
427
+ - `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).
408
428
  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>`).
409
- 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 slow but legitimate source therefore costs one overrun, and the next view is answered from the cache. A late `failed`, a late exception and a late output that fails the checks are not cached.
410
- 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.
429
+ 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.
430
+ 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.
431
+ 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.
411
432
  - `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.
412
433
  - `unsupported` means the content itself cannot be downscaled (deterministic); `failed` means a transient failure (retry may succeed).
413
434
  - 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.
@@ -430,6 +451,11 @@ Values for implementing the port:
430
451
  The work is counted in block visits: the sum, over the scans up to EOI, of the 8 × 8 blocks of every component a scan codes (counted as libjpeg lays them out, rounded up to whole MCUs). The walk skips each scan's coded data without decoding it and stops at EOI or as soon as the sum passes the bound; a single-scan (baseline) file stops at its first scan, which is all libjpeg decodes.
431
452
  The bound comes from the render deadline: ⌊`renderMs` × 10⁶ ÷ (125 ns per visit × a margin of 4)⌋ = 20,000,000 visits, so a source at the bound decodes in about a quarter of the 10 s deadline on the host it was measured on (sharp 0.35.5 / libvips 8.18.7 at one thread; 125 ns is a visit of the costliest scan, a refinement of the whole 1–63 band; single-coefficient scans cost 13–27 ns).
432
453
  A JPEG over the bound is `unsupported` without starting sharp, and so is one whose work cannot be predicted: more scans than the JPEG standard allows (one per component in a sequential frame, 896 per component in a progressive one), an arithmetic-coded (SOF9–SOF15) or hierarchical (SOF5–SOF7) frame, or a malformed marker stream. The default progressions libjpeg writes stay well inside the bound even at the 7,071² pixel limit (about 6.3 M visits at 4:2:0, 10.9 M at 4:4:4 and 18.8 M for CMYK), and lossless (SOF3) frames, one scan per component like a sequential frame, are admitted.
454
+ - bounds the reading itself: the walks that read a source before sharp starts (the JPEG marker walk with its skip over each scan's coded data, and the WebP chunk walk) are synchronous and block the worker's event loop, so they count steps — one per 0xFF byte the walk visits (a marker, a fill byte before it, a stuffed `FF 00`, a restart marker or a fill byte inside coded data) and one per WebP chunk; coded data between them is skipped by the native byte search.
455
+ A walk that needs more than `maxWalkSteps` = ⌊`walkBudgetMs` 16 ms × 10⁶ ÷ (50 ns per step × a margin of 2)⌋ = 160,000 steps stops there, and the source is `unsupported` (content-determined, cached) without starting sharp, so neither a hostile file nor an abort during the walk can make the stall repeat. The walk's time is then bounded whatever the content: at most 160,000 steps plus the byte search, which grows with the file (2.4–2.8 ms for 32 MiB).
456
+ Measured on Node 24 over 32 MiB sources built to stall the walk (a run of 0xFF, `FF 00` pairs, restart markers inside a scan or between segments, fill bytes, only comment segments, a WebP of zero-size chunks): 1–2.4 ms each, and 5.0–7.0 ms for the costliest shape, `FF 00` pairs spread every 3 to 209 bytes so that every visit restarts the search, within the 8 ms the margin leaves.
457
+ Legitimate sources stay far inside the bound: a baseline JPEG stops at its first scan, the coded data of a noisy photo that sharp encoded at quality 98 holds about 2,800 0xFF bytes per MiB (about 90,000 steps at 32 MiB), and noisy progressive photos above the size limit took 147,010 steps (64 MB) and 155,365 steps (58 MB).
458
+ The model is the frozen `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_WORK_MODEL` (server entry): `renderMs` (the render stage's deadline, 10,000 ms), `nanosecondsPerBlockVisit`, `margin` and `maxBlockVisits` above, and `walkBudgetMs`, `nanosecondsPerWalkStep`, `walkMargin` and `maxWalkSteps`. A host that proves its own decoders against the bound reads `renderMs` from it instead of restating the deadline.
433
459
  - predicts the working set of one render with `predictSharpThumbnailWorkingSetBytes(source, format, header)` from one frozen table, `DAILY_REPORT_SHARP_THUMBNAIL_WORKING_SET_MODEL` (both in the server entry): the buffer the layout's decoder holds for the whole image before it can shrink, plus `pipelineRows` = 2,048 full-width rows of decoded pixels (width × bands × bytes per sample; `uchar` / `char` 1, `ushort` / `short` 2, `uint` / `int` / `float` 4, `complex` / `double` 8) that libvips holds, plus `fixedBytes` = 4 MiB (the WebP encoder of the 480 × 320 output and libvips' own structures):
434
460
 
435
461
  | Layout | Whole-image buffer | Largest square admitted |
@@ -447,7 +473,7 @@ Values for implementing the port:
447
473
  - 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:
448
474
  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.
449
475
  - 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.
450
- - 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).
476
+ - 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).
451
477
  - is named `sharp-<sharp version>/vips-<libvips version>/webp-q75-e4/flatten-#ffffff/v1`, from `sharp.versions` at run time.
452
478
 
453
479
  Example — the wiring; the host imports its own sharp (type-checked, not shipped):
@@ -533,7 +559,7 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
533
559
  **Thumbnail endpoint (`?thumbnail=tile`)**
534
560
 
535
561
  - **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.
536
- - **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)
562
+ - **Order**: request isolation (403 for another origin's page, [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)
537
563
  → 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.
538
564
  - **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`:
539
565
  - 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.
@@ -548,8 +574,9 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
548
574
  - **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.
549
575
  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.
550
576
  - **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).
551
- A generation that receives its slot reads the cache again before it reads the original, so a request that waited behind an overrunning render of the same content is answered from that render's late result instead of decoding it again.
552
- 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).
577
+ **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.
578
+ 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.
579
+ 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.
553
580
  - **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.
554
581
  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.
555
582
 
@@ -562,24 +589,25 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
562
589
  | Client load timeout | 75 s (the sum) | the load counts as one failure |
563
590
 
564
591
  - **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.
565
- 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.
592
+ 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).
593
+ 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.
566
594
  - **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);
567
595
  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.
568
596
 
569
597
  | Status | When |
570
598
  | --- | --- |
571
599
  | 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` |
572
- | 304 | `If-None-Match` matches (carries `Cache-Control`, `ETag`, `Cross-Origin-Resource-Policy: same-origin`, `Set-Cookie`) |
600
+ | 304 | `If-None-Match` matches (carries `Cache-Control`, `ETag`, `X-Content-Type-Options: nosniff`, `Cross-Origin-Resource-Policy: same-origin`, `Set-Cookie`) |
573
601
  | 400 | A query that names no delivery (unknown or repeated variant such as `?thumbnail=1`, combined with `download`), or a malformed token |
574
- | 401 / 403 | Not authenticated / no internal user |
602
+ | 401 / 403 | Not authenticated / no internal user, or (403, before authentication) a request from another origin's page that is not a top-level `GET` / `HEAD` navigation ([Request isolation](#request-isolation)) |
575
603
  | 404 | Ports not injected, not visible, not eligible, signature mismatch, `unsupported`, `too_large`, `not_found`, `denied`, `invalid_path` — one identical body for all |
576
604
  | 405 | Any method other than GET (`Allow: GET`) |
577
605
  | 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`) |
578
606
  | 502 | Storage unreachable (`unavailable`), the port's `failed` (`render_failed`), or a stage deadline (`read_timeout`, `render_timeout`) |
579
607
  | 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`) |
580
- | 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=` `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) |
608
+ | 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) |
581
609
 
582
- 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), 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`.
610
+ 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`.
583
611
  Unexpected exceptions outside generation go to the loader's shared catch and are logged as `500 attachment=? viewer=? reason=unexpected message=<msg>` (no `variant`).
584
612
  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.
585
613
  Rejections up to the renderer-port check (401 / 400 / 405 / 404 for a missing port / 403) are not logged.
@@ -667,14 +695,17 @@ Each view (List and DetailList) is as tall as the space the host's layout leaves
667
695
  - **Host contract**: render `DailyReportPage` (or `DailyReportResolvedContent`) as the content of a column flex container (`display: flex; flex-direction: column`) whose height is bounded — a definite height, or the growing item of a column with a minimum height, such as a `min-height: 100dvh` shell whose footer follows the content. The header that `renderHeader` returns and the page box are items of that container, and the page box takes the rest of it. Nothing is bound and no host variable is read.
668
696
  Outside such a container the view has no height to fill: its size is contained (below), so its content cannot size it, and it is 0 px tall.
669
697
  - **The fill chain**: every box from the page box down to a view's root — the page box, its centred width-capped wrapper, the view-mode tabs and the active tab panel — fills the rest of its parent column (`flex: 1 1 0%; min-height: 0`, `FILL_COLUMN_CLASS_NAME`) and declares no height. The wrapper's padding is 4 px at the top and the sides and none at the bottom, so the view's box ends exactly at the page's bottom edge.
698
+ - **One page frame and one column for every screen**: the loading screen, the load-error screen and the loaded screen share the page box (`VIEW_PAGE_FRAME_CLASS_NAME`: the fill rule and the page surface, slate-50 / dark slate-950) and its centred column (`VIEW_COLUMN_CLASS_NAME`: the fill rule, at most `max-w-6xl`, 4 px from the frame's top and side edges, starting on the 4 px lattice). So the page keeps its colour in both schemes while loading ends (a dark page never shows the light surface first) and the content does not move sideways between the screens.
670
699
  - **The view's root contains its size** (`VIEW_ROOT_CLASS_NAME`: the fill rule, `contain: size` and `overflow: clip`): its box is sized as if it were empty, so what the view renders — a `VirtualScroll` given the measured height — never feeds back into the root's size, into the intrinsic size of an ancestor (a host column with a minimum height does not grow) or into the document's scroll height. The clip keeps rows drawn for the previous height from painting outside the box during the one frame before the view renders the new height.
671
700
  Layout and paint are not contained, so the mobile overlay, a fixed-position descendant, keeps the viewport as its containing block.
672
- - **One measurement, in one unit**: `VirtualScroll` needs its viewport size as a number (`viewportSize`), so each view reads its root's border-box block size in layout px (`useViewBoxHeight`).
673
- The first value is read in a layout effect before the first paint, as the root's computed `block-size` in its own window (the root has no padding or border, so that is its border box; until then the view renders no body, so no row is ever drawn at a guessed height; the List also waits for its row slot, below).
674
- Every later value comes from one `ResizeObserver` on the root alone (`box: "border-box"`), whose delivery only hands `borderBoxSize[0].blockSize` to React state and reads and writes nothing.
675
- Both readings are the same layout primitive, taken before any ancestor transform or zoom (`getBoundingClientRect` would give the first value in visual px, corrected one frame later), so the same layout gives the same height whatever the order of the measurements and a scaled or zoomed ancestor causes no second commit.
676
- Every change is followed, a sub-pixel one included, and 0 stays 0 (a root that is not rendered has no `px` block size and reads 0, the value `ResizeObserver` reports for it). A view whose document has no window throws. The value reaches only `viewportSize`: the list column, the panel group and the scroll container write no height.
677
- - **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 exactly 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.
701
+ - **One measurement, in one unit**: `VirtualScroll` needs its viewport size as a number (`viewportSize`), so each view takes its root's border-box block size in layout px from one `ResizeObserver` of the root's own window, observing the root alone (`box: "border-box"`; `useViewBoxHeight`). Every value, the first included, is the delivery's `borderBoxSize[0].blockSize`: the layout effect only starts the observation, and no code reads a size from the DOM.
702
+ The value is `null` until the first delivery, and the view renders no body until then, so no row is ever drawn at a guessed height (the List also waits for its row slot, below). The platform delivers the first observation in the rendering update after the observation starts, after layout and before paint, and that one delivery is committed at once (`flushSync`), so the view's content is drawn before the same frame paints; later deliveries only set React state, which React renders after the delivery.
703
+ A delivery reads and writes nothing in the DOM, and a value equal to the last one written is not written again.
704
+ The observer reports the layout px value itself — the exact `LayoutUnit`, before any ancestor transform or zoom — so the same layout gives the same height whatever the order of the measurements, and each height commits once (the computed `block-size` would serialize a fractional height to 6 significant digits, such as 743.656 for 743.65625, and the observer's value would then commit a second time).
705
+ Every change is followed, a sub-pixel one included. A root without a box (not rendered, or detached) reads 0 in Chromium, whose first delivery reports 0 for it; an engine that follows the specification's 0 × 0 starting size reports nothing for such a root, and the value stays `null` until the root has a box. A view whose document has no window throws. The value reaches only `viewportSize`: the list column, the panel group and the scroll container write no height.
706
+ - **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.
707
+ `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.
708
+ 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).
678
709
  - **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).
679
710
 
680
711
  ### Keyboard, focus and selection (List / DetailList)
@@ -706,7 +737,9 @@ The keys are delegated to each view's **list**: the element that holds the view'
706
737
  - **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).
707
738
  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.
708
739
  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.
709
- 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).
740
+ 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").
741
+ 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.
742
+ The bar and its thumb still carry their names (the engine keys `verticalScrollBar` and `verticalScrollThumb`, below), which the hidden bar does not expose.
710
743
  - **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.
711
744
  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).
712
745
  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)
@@ -752,10 +785,16 @@ The keys are delegated to each view's **list**: the element that holds the view'
752
785
  - **Host selections and list changes**: when the host changes the selection, the selection ring follows it and the DetailList scrolls that row to the top (when the selected report is not in the list yet, as soon as it arrives); focus does not move.
753
786
  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.
754
787
  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).
755
- The anchor is recorded after every scroll, so it describes the committed position, not the one before the last scroll:
756
- - **Scrolls 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 — all go through one scroller that records the anchor right after scrolling, without waiting for the next visible-range report.
757
- An insert or a delete that commits between such a scroll and the next frame therefore keeps the scrolled position: `End`, `PageDown` and a held key are never reverted, and focus stays on the key's destination. No other code of the views scrolls the handle (`src/client/components/view-scroller.spec.ts` fails on a direct call).
758
- - **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, walking the previous list's row heights from the anchor to the report now at the top, and restores from there: it neither undoes the user's scroll nor shifts the content by a row. The walk covers only the rows scrolled past in that frame, whatever the list's length or the position.
788
+ The anchor is recorded after every change of the position, so it describes the committed position, not the one before the last change:
789
+ - **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.
790
+ 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.
791
+ 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.
792
+ 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.
793
+ 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.
794
+ - **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).
795
+ 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.
796
+ - **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.
797
+ 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.
759
798
  An insert or a delete before or inside the rendered window therefore leaves the first visible report — and the focus inside the rows — where it was. At the start of the list (scroll position 0) no anchor is kept, so reports that arrive at the top are shown;
760
799
  the anchor yields only to a pending selection reveal (a DetailList selection waiting for its report; a key move needs no precedence, since its scroll, its destination's render and the focus all end inside its own commit); and when the anchor report itself is removed, the first remaining report that followed it on screen is shown at its offset.
761
800
  - **List side pane**: keys select through `onSelectItem` directly, so on a mobile layout they never open the detail overlay.
@@ -779,7 +818,9 @@ The keys are delegated to each view's **list**: the element that holds the view'
779
818
  A draft that opens in the editor by itself does not move focus. The editor is a real `<form>` whose submission is cancelled: Save, Publish and Cancel are `type="button"`, and an implicit submission (Enter in the title) or a `requestSubmit()` neither navigates nor saves nor publishes, so what was typed stays. As an input region the form keeps the list keys out (above).
780
819
  - **Deleting a comment**: the trash button of the viewer's own comment is a disclosure (`aria-expanded`; while open, `aria-controls` names the confirmation pill it shows under itself). The confirmation has no time limit (WCAG 2.2.1): it stays open until it is confirmed or cancelled — by pressing the trash button again, by Escape (the comment list takes it before the mobile overlay while the confirmation is open; an Escape that belongs to an IME composition is left alone), by focus leaving the trash button and the confirmation, or by a pointer press outside them.
781
820
  A cancel by the trash button or by Escape returns focus to the trash button when focus was on the confirmation.
782
- Confirming moves focus before the comment is hidden: to the next remaining trash button, else the previous one, else the comment section's heading (`tabIndex=-1`) — the same in the side pane, the mobile overlay and the DetailList row — so focus never falls to `body`.
821
+ Confirming moves focus before the comment is hidden, to a destination computed from what remains: the next remaining trash button, else the previous one, else the comment section's heading (`tabIndex=-1`) when the section stays, else the report's own anchor — the row in the DetailList (through the view's focus request), the pane's report heading in the side pane and the mobile overlay — so focus never falls to `body`.
822
+ - **One comment section rule**: the DetailList row, the side pane and the mobile overlay (which shows the side pane's content) render one comment section, its `labels.comments` heading included, only while the report has comments or the viewer may comment on it (the same decision that shows the comment form: see [External Source Badge Configuration](#external-source-badge-configuration-sourcetypeconfigs)).
823
+ A report of a source that takes no comments and has none shows no empty section, and deleting the last comment of such a report removes the section, so focus goes to the report's anchor above.
783
824
  - **Mobile detail overlay** (List, single-column layout): the platform's modal dialog, a `<dialog data-daily-report-mobile-overlay>` (implicit `dialog` role, no `aria-modal`) opened with `showModal()`. It is drawn in the top layer, above every host `z-index`, and everything outside it is inert while it is open, host chrome included: no focus, no pointer, nothing in the accessibility tree.
784
825
  It is named by the report heading at its top and the author's value under it (`aria-labelledby` lists both: `<date> <author>`). That heading (`tabIndex=-1`) is the dialog's first focusable descendant, so `showModal()`'s own focusing steps move focus to it in the same commit (the overlay focuses it explicitly as well). Tab and Shift+Tab wrap inside it (its stops are counted on every press by the model of **Leaving the list**;
785
826
  on Shift+Tab the container of a roving-focus widget around the origin, such as the tabs' `tablist`, is not counted before it, because real Shift+Tab leaves such a widget without stopping at it), and Escape closes it unless the key belongs to an IME composition.
@@ -807,17 +848,25 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
807
848
 
808
849
  - **Row gutter G = 8 px** on all four sides of both row frames: ring 2 + separation 2 + outline 2 + hover lift 2. Every term is a CSS pixel quantity and is written in px (`p-[8px]`, the lift `-translate-y-[2px]`), because the ring and the outline do not scale with the root font size and a `rem` term would break the sum at any root other than 16 px; the lengths built on G — the focus reach, the toolbar insets, the frame radius — are px too. A row aligned to either edge of the viewport while hovered, selected and focused is therefore never clipped, at any root font size;
809
850
  a DetailList row is its measured body plus 2G = 16.
810
- - **List slot P**: the List card's content is sized in rem, so the slot follows the page's root font size R: P(R) = 2G + 2 × (1 + 15) (the card's border and padding) + 4 (spare) + 6.75R = 52 px + 6.75 rem, rounded up to a whole CSS pixel (`listRowSlotHeight`), so every row top stays on a whole pixel. 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).
811
- 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 P = 160: the card is P − 2G = 144 px, cards are 16 px apart and an edge-aligned card sits exactly 8 px from the edge.
851
+ - **List slot P**: the List card's content is sized in rem, so the slot follows the page's root font size R: P(R) = 4 · ⌈(2G + 2 × (1 + 15) (the card's border and padding) + 4 (spare) + 6.75R) / 4⌉ = 4 · ⌈(52 px + 6.75 rem) / 4⌉, the sum rounded up to the 4 px layout lattice (`listRowSlotHeight`, `LAYOUT_LATTICE_PX`), so every row top stays on the lattice.
852
+ 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).
853
+ 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.
812
854
  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.
855
+ - **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).
856
+ 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).
857
+ 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.
813
858
  - **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.
814
859
  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).
815
860
  - **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.
816
861
  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.
817
862
  - **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.
818
863
  - **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.
819
- - **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.
820
- 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. The DetailList therefore measures each row's body — the frame's direct child, as tall as its content — and adds 2G; 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.
864
+ - **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.
865
+ 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.
866
+ 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. The views' numbers with the box come from the app's keyboard harness.
867
+ 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).
868
+ 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.
869
+ 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.
821
870
  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).
822
871
  Scrolling repaints rows only where `VirtualScroll` shifts its rendering window. A scroll step that mounts no row writes only the items wrapper's `transform` and repaints no row; a step that mounts one makes Chromium re-centre the area it paints the wrapper's layer in (`will-change: transform`), and every kept row whose content clips its own overflow repaints, which the rows of both views do. `@aiquants/virtualscroll`'s README ("What a scroll step paints") gives the cost of a one-row shift in Chromium 148: 36 layers with 160 px rows and 26 with 448 px rows, Paint 0.99 and 0.66 ms.
823
872
  Containing paint would not avoid it (an overflow clip inside a row still marks the row as clipped by that area) and would clip the hover shadow; a `perspective` on the wrapper would avoid it, but it composites the rows under a non-2D transform that Chromium resamples at some device-pixel ratios (at 1.25 the band between the ring and the outline blends). Neither is used, so the strokes below stay whole device pixels.
@@ -826,6 +875,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
826
875
  The panel reserves its scroll bar's width whatever the content's height (`scrollbar-gutter: stable`, the token `SCROLLBAR_GUTTER_CLASS_NAME` that the pane's header shares, see **Side pane** below), so the width its content gets — and with it the attachment grid's tracks — never depends on whether the article overflows, and it keeps the focus reach (below) inside its clipping edges.
827
876
  The tokens are `ROW_CONTAINMENT_CLASS_NAME` and `SIDE_PANE_ARTICLE_CONTAINMENT_CLASS_NAME`; `src/client/ui/row-containment.spec.ts` compiles them and measures the two shadows against G, and `src/client/components/report-views.spec.tsx` checks that both views' frames carry the boundary and are neither flex nor grid items.
828
877
  Outside the rows, the side pane's tab panels are the package's own (`role="tabpanel"`, named by their trigger, hidden and empty while not selected) and read no computed style when they mount, and the scroll bar's business-day bubble reads its date and wheel state from a store of its own (`useSyncExternalStore`), so a change of the visible range or a wheel re-renders only the bubble, never the view or `VirtualScroll`.
878
+ The bubble sits at a constant offset from the thumb overlay's box, 16 px beside the bar, and follows the thumb's centre by `transform` alone, with no transition, and the thumb itself moves by a translate snapped to device pixels (`@aiquants/virtualscroll` 3.9.0); so a scroll step that keeps the rendering window writes no `top` or `left` and adds no layout from the document root while the bubble shows.
829
879
  - **Focus indicators are the package's own**: every focus indicator the package draws is one of the outlines above, including those of its tabs, buttons, switches, inputs and text areas (`src/client/ui`); none uses the host's `--ring` (measured at 2.43:1 in light and 1.22:1 in dark against the tab list), and no element that carries a focus indicator transitions its colours (in Tailwind 4 `transition-colors` also fades the outline in).
830
880
  No element is scaled: a scaled element scales its outline with it. The switches are drawn at their real size — a 40 × 24 track whose transparent 4 px border (its style declared) frames a 16 px thumb that moves 16 px — so the 24 px track is its own target and its 2 px outline at offset 2 is drawn at full width. `src/client/ui/focus-indicator.spec.ts` checks the shipped CSS, including that no rule scales an element.
831
881
  - **Focus reach**: a control's outline reaches 4 px outside it (offset 2 + width 2 = 4 = u; a row's or a card's outline stays inside the gutter G), and every box that clips its content and holds focusable content keeps that reach inside its clipping edges, so no outline is ever cut.
@@ -836,7 +886,9 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
836
886
  - **State colours are the package's own too**: the switches and the tabs take every state colour from package tokens (`SWITCH_TRACK_COLOR_CLASS_NAME`, `SWITCH_THUMB_COLOR_CLASS_NAME`, `SEGMENTED_TRIGGER_CLASS_NAME`), never from the host's `--input`, `--primary`, `--background` or `--muted`.
837
887
  The switch track is slate-500 when off and blue-600 when on (dark: slate-400 / blue-400) under a white thumb (dark: slate-950). The selected tab is a white surface (dark: slate-950) with slate-900 text (dark: slate-100) and a straight 2 px blue-600 bar (dark: blue-400) along the straight part of its bottom edge, the bar being the cue that does not depend on colour. Unselected labels are slate-600 (dark: slate-300).
838
888
  The bar is an `::after` box at the tab's bottom edge, inset on each side by the tab's own corner radius (`--radius-lg`), so it never runs into the rounded corners: it is 2 px thick along its whole length at every device pixel ratio (a bottom border on a rounded box thins and rises along both corners), and it stays on the straight part for any host `--radius`. It is out of the flow and takes no space, so selecting a tab moves nothing and every label sits in the centre of its 24 px tab.
839
- In forced colours the thumb is `ButtonText`, the track has a `ButtonText` border and the on track a `Highlight` fill, and the selected bar is `Highlight`, painted as declared (`forced-color-adjust: none` on the bar), so the state survives the colour override. `src/client/ui/ui-state-contrast.spec.ts` computes every pair below from the compiled CSS and the palette (the toolbar surface is slate-100 at 60 % over the slate-50 page, dark slate-900 at 60 % over slate-950):
889
+ In forced colours the thumb is `ButtonText`, the track has a `ButtonText` border and the on track a `Highlight` fill, and the selected bar is `Highlight`, painted as declared (`forced-color-adjust: none` on the bar), so the state survives the colour override.
890
+ The package's error messages — the load-error screen's message and its badge in the host's header, and the side pane's report-load error — take their colour from one token (`ERROR_TEXT_CLASS_NAME`: red-700, dark red-400); the badge sits on the host's header, whose colour is the host's, so it has no pair below.
891
+ `src/client/ui/ui-state-contrast.spec.ts` computes every pair below from the compiled CSS and the palette (the toolbar surface is slate-100 at 60 % over the slate-50 page, dark slate-900 at 60 % over slate-950; the card surface is white, dark slate-900 at 70 % over the page; the overlay panel is white, dark slate-900):
840
892
 
841
893
  | Pair | Light | Dark | Minimum |
842
894
  | --- | --- | --- | --- |
@@ -850,6 +902,9 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
850
902
  | Unselected label / the side pane's tab track | 6.90 | 9.83 | 4.5 |
851
903
  | Unselected label / toolbar surface | 7.03 | 12.71 | 4.5 |
852
904
  | Selected label / selected tab | 17.83 | 18.40 | 4.5 |
905
+ | Error text / card surface (the desktop side pane) | 6.42 | 6.45 | 4.5 |
906
+ | Error text / overlay panel (the side pane's content on a phone) | 6.42 | 6.17 | 4.5 |
907
+ | Error text / the load-error panel (the card surface over the page frame) | 6.42 | 6.45 | 4.5 |
853
908
 
854
909
  - **Borders declare their style**: every border the package draws sets `border-solid` itself, so a host whose base layer resets the border style of every element (for example `* { border: none }` in Tailwind v4, which turns `--tw-border-style` into `none`) cannot erase it; the hover border and the switch track depend on it.
855
910
  The card-like surfaces — the List card and its skeleton, the desktop side pane, the DetailList card and its skeleton — share one token (`CARD_SURFACE_CLASS_NAME`): a 16 px corner, a 1 px slate-200 / dark slate-700 border, white / dark slate-900 at 70 %, a 16 px inset that counts the border (1 px border + 15 px padding, so the content edge is 16 px from the visible edge and its corner concentric with the surface's) and `shadow-sm`.
@@ -861,21 +916,24 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
861
916
  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"`).
862
917
  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.
863
918
  Both option sets are module constants, so the view's props to `VirtualScroll` stay equal from render to render.
864
- 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 (3.8.2, the peer floor) stops every transition and animation of every part it animates under `prefers-reduced-motion: reduce`
919
+ 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`
865
920
  (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.
866
921
  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`.
867
922
  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.
868
923
  - **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.
869
924
  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**).
870
- 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)). A surface that holds k rows of tiles is therefore off the 4 px grid by exactly k · h(t) (mod 4), which the app's visual contract asserts.
925
+ 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)).
871
926
  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.
927
+ 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)).
872
928
  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.
873
929
  **The 12 px tier**: every panel with a 12 px corner has one inset, 12, equal to its corner, so its content box's corner is concentric with the panel's — the reading panel, the DetailList metadata column and the DetailList skeleton's inner panel (`p-3`, no border), the placeholder panel, a posted comment and the bordered pill (1 + 11); a 24 px pill has the same 12 px padding at its round ends. The tab bars are not in that tier: their 4 px inset makes them concentric with their 8 px tabs (12 − 4 = 8).
874
930
  The only other exception is the visually hidden text. `src/client/ui/spacing-ladder.spec.ts` checks every class the package writes, that every bordered box's border + padding is on the ladder or equals its corner, and that every 12 px-corner panel's inset is 12.
875
- - **Origin on the 4 px lattice**: the views' centred column (at most 72 rem, `max-w-6xl`, centred with `mx-auto`) starts at the centring offset rounded down to a multiple of 4 px (`VIEW_COLUMN_ORIGIN_CLASS_NAME`: `ml-[max(0px,round(down,calc((100%-72rem)/2),4px))]`), less than 4 px left of the exact centre. 4 CSS px 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 inline edge built on the origin — G, the ladder steps, the card, the row frame — starts on a whole device pixel.
931
+ - **Origin on the 4 px lattice**: the views' centred column (at most 72 rem, `max-w-6xl`, centred with `mx-auto`) starts at the centring offset rounded down to a multiple of 4 px (`VIEW_COLUMN_ORIGIN_CLASS_NAME`: `ml-[max(0px,round(down,calc((100%-72rem)/2),4px))]`, part of `VIEW_COLUMN_CLASS_NAME`, so the loading and load-error screens start there too), less than 4 px left of the exact centre.
932
+ 4 CSS px 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 inline edge built on the origin — G, the ladder steps, the card, the row frame — starts on a whole device pixel.
876
933
  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.
877
934
  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.
878
- The block-axis origin is the host's: the views start where the host's layout puts them. A host that wants the same whole-pixel strokes on the top and bottom edges at fractional ratios starts the views on the lattice too — for example with a header whose height is its content rounded up to 4 px (`height: calc-size(auto, round(up, size, 4px))`), since a header sized by its font metrics alone ends on a fraction (such as 30.4375 px).
935
+ 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.
936
+ 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)).
879
937
  - **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.
880
938
  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.
881
939
  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.
@@ -884,6 +942,8 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
884
942
  - **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.
885
943
  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`.
886
944
  - **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.
945
+ 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).
946
+ 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.
887
947
  - **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).
888
948
  - **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.
889
949
  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.
@@ -894,18 +954,26 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
894
954
 
895
955
  - Attachments with `hasThumbnail: true` are laid out as tiles in a grid; the others as rows. Each group keeps the original order.
896
956
  - **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`).
897
- - **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).
898
- 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.
899
- 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.
957
+ - **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).
958
+ 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).
959
+ **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`).
960
+ 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.
961
+ 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).
962
+ 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).
963
+ 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.
964
+ 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.
900
965
  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:
901
- 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).
966
+ 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).
902
967
  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.
903
- 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).
968
+ 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).
904
969
  - **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.
970
+ 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.
971
+ 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.
972
+ 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.
905
973
  - **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).
906
974
  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).
907
- 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.
908
- 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.
975
+ 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).
976
+ 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.
909
977
  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.
910
978
  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.
911
979
  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)
@@ -915,14 +983,15 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
915
983
  - **Tab order inside list rows**: the attachment links follow the row rule of [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist): inside a DetailList row they are Tab stops only in the view's Tab-stop row (`tabIndex=-1` in every other row); in the side pane and the mobile overlay they keep their natural order.
916
984
  - **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"`).
917
985
  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.
918
- - **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 an inline-size container of its own (an `@container` wrapper exactly as wide as its track, so `100cqi` is t) 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`).
919
- 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).
920
- 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. 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).
986
+ - **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`).
987
+ 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).
988
+ 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).
921
989
  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).
922
990
  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.
923
991
  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).
924
992
  The skin's inner rim turns blue while the tile link (`data-daily-report-attachment-preview`) is hovered, on devices that can hover, and a deeper blue while it is pressed; the background never animates.
925
- 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; and as a positioned box that is not a flex or grid item (its parent is the block `@container` wrapper), 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.
993
+ 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;
994
+ 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.
926
995
  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.
927
996
  - **`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.
928
997
  - **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**):
@@ -946,7 +1015,8 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
946
1015
 
947
1016
  | `data-testid` | Element |
948
1017
  | --- | --- |
949
- | `daily-report-attachment-section` | Attachment section (the size container) |
1018
+ | `daily-report-attachment-section` | Attachment section: the heading, then the grid's container and the rows |
1019
+ | `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 |
950
1020
  | `daily-report-attachment-grid` | `<ul>` of tiles (only when at least one attachment has a thumbnail) |
951
1021
  | `daily-report-attachment-rows` | `<ul>` of rows (only when at least one attachment has no thumbnail) |
952
1022
  | `daily-report-attachment-item` | One attachment (tile or row), with `data-attachment-state` = `present` / `absent` / `unknown` |
@@ -986,7 +1056,8 @@ DOM hooks are `data-*` attributes, test ids and ARIA; class names are styling an
986
1056
 
987
1057
  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.
988
1058
 
989
- `[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.
1059
+ `[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, { align, offset })`.
1060
+ `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.
990
1061
 
991
1062
  ### Localization (`locale` / `labels`)
992
1063
 
@@ -1004,14 +1075,14 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1004
1075
  />
1005
1076
  ```
1006
1077
 
1007
- - **One surface for all wording.** The catalog covers the 7 engine chrome keys of
1078
+ - **One surface for all wording.** The catalog covers the 11 engine chrome keys of
1008
1079
  `@aiquants/virtualscroll` plus 83 own keys: field headings, the page title (`title`, passed to
1009
1080
  `renderHeader` and used as the accessible name of both lists), the lists' keyboard help, the row state read to
1010
1081
  screen readers, tabs, buttons, placeholders, `confirm()` / `alert()` texts, stream badges, the load-error screen
1011
1082
  and the mutation-failure message. The engine part of each catalog is `VIRTUAL_SCROLL_LABEL_CATALOGS[locale]`, reused by
1012
- value, so the scroll arrows and the "No items" text always speak the same language as the rest.
1083
+ 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.
1013
1084
  - **Forwarded to every embedded `VirtualScroll`.** Both the List and the DetailList pass `locale`
1014
- and the 7 engine keys of the resolved labels to their `VirtualScroll`. The engine label object keeps
1085
+ and the 11 engine keys of the resolved labels to their `VirtualScroll`. The engine label object keeps
1015
1086
  its identity while its values are unchanged, so rebuilding `config.labels` on every render does not
1016
1087
  re-render the memoized list subtree.
1017
1088
  - **Formatter keys.** Ten keys take arguments and are functions: `totalCount(count)`,
@@ -1024,7 +1095,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1024
1095
  the BCP 47 tag bound to their locale (`en` → `"en-US"`, `ja` → `"ja-JP"`); a host override receives
1025
1096
  the raw number and formats it itself.
1026
1097
  - **Fail-fast**: an unsupported `locale` (`"EN"`, `"ja-JP"`, `"fr"`, `""`, `null`, ...) throws a
1027
- `RangeError` at render. So do an unknown `labels` key (a key error that lists the 90 keys), a string
1098
+ `RangeError` at render. So do an unknown `labels` key (a key error that lists the 94 keys), a string
1028
1099
  key whose value is not a string with non-whitespace content, and a formatter key whose value is not a
1029
1100
  function. An `undefined` value keeps the catalog value.
1030
1101
  - **`config` has a closed key set**: `apiBasePath`, `ssePath`, `draftLabelName`, `locale`, `labels`,
@@ -1061,7 +1132,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1061
1132
  grouping follows `locale` (`en` → `"en-US"`, `ja` → `"ja-JP"`) and dates stay in ISO form. Packages
1062
1133
  that do format dates (for example `@aiquants/period-slider`) name that separate axis `formatLocale`.
1063
1134
 
1064
- Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 90 keys, the 7 engine keys
1135
+ Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 94 keys, the 11 engine keys
1065
1136
  first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReportLabels(locale, labels)`
1066
1137
  (the effective frozen labels — the catalog object itself when `labels` is `undefined`), the resolved
1067
1138
  `locale` / `labels` on `useDailyReportConfig()`, and the types `DailyReportLocale` (= `VirtualScrollLocale`),
@@ -1073,6 +1144,10 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
1073
1144
  | `scrollDown` | engine: vertical ScrollBar down arrow (`aria-label`) | Scroll down | 下へスクロール |
1074
1145
  | `scrollLeft` | engine: horizontal arrow (not rendered by this package) | Scroll left | 左へスクロール |
1075
1146
  | `scrollRight` | engine: horizontal arrow (not rendered by this package) | Scroll right | 右へスクロール |
1147
+ | `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 | 縦方向 |
1148
+ | `horizontalScrollBar` | engine: name of the horizontal ScrollBar (not rendered by this package) | Horizontal | 横方向 |
1149
+ | `verticalScrollThumb` | engine: name (`aria-label`) of the vertical ScrollBar's thumb (`role="slider"`), inside the hidden bar | Vertical scroll position | 縦スクロール位置 |
1150
+ | `horizontalScrollThumb` | engine: name of the horizontal ScrollBar's thumb (not rendered by this package) | Horizontal scroll position | 横スクロール位置 |
1076
1151
  | `scrollToTop` | engine: top pill (not enabled by this package) | Top | 先頭へ |
1077
1152
  | `scrollToBottom` | engine: bottom pill (not enabled by this package) | Bottom | 末尾へ |
1078
1153
  | `noItems` | engine: the one message of an empty List or DetailList, over the top of the list | No items | 項目がありません |
@@ -1109,7 +1184,7 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
1109
1184
  | `createReport` | create button | New report | 日報作成 |
1110
1185
  | `createReportTitle` | create button tooltip | Create a daily report | 日報を作成 |
1111
1186
  | `devControls` | dev toolbox tooltip (`showDevControls`) | Development-only controls | 開発環境限定コントロール |
1112
- | `devSseToggle` | dev toolbox: SSE switch tooltip | Toggle SSE real-time sync | SSE リアルタイム同期切替 |
1187
+ | `devSseToggle` | dev toolbox: the SSE switch's accessible name (`aria-label` on the `role="switch"` button) and tooltip | Toggle SSE real-time sync | SSE リアルタイム同期切替 |
1113
1188
  | `devReload` | dev toolbox: reload tooltip | Reload data | データ再取得 (リロード) |
1114
1189
  | `devClearCache` | dev toolbox: clear-cache tooltip | Clear the cache and reload | キャッシュ破棄 & 再取得 |
1115
1190
  | `autoRead` | auto-read switch label | Auto-read | 自動既読 |
@@ -1232,31 +1307,33 @@ Every breaking change and its migration is listed per version in `CHANGELOG.md`,
1232
1307
  ## Realtime Architecture
1233
1308
 
1234
1309
  1. Mutation services publish to Redis Stream (`daily-report:sse-stream`) in sequence: `invalidate -> epoch increment -> SSE publish`.
1235
- 2. `DailyReportSseReader` uses a single blocking `xRead` loop to fan-out events to all SSE connections (eliminating per-connection Redis TCP connections).
1310
+ 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.
1236
1311
  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).
1237
- 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).
1312
+ 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).
1238
1313
  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).
1239
1314
 
1240
1315
  ### SSE wire contract
1241
1316
 
1242
- 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.
1317
+ 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).
1243
1318
 
1244
1319
  | Server-side situation | Response | Client (`useDailyReportSseConnection`) |
1245
1320
  | --- | --- | --- |
1246
1321
  | Not authenticated | 200, `retry: 86400000` → `event: auth-required` (`Set-Cookie` forwarded) | terminated; calls `config.onSessionExpired()` once; never reconnects |
1247
1322
  | Authenticated but no internal user | 200, `retry: 86400000` → `event: forbidden` | terminated; no navigation (a reload cannot fix it); visible through `sseStatus` |
1323
+ | A request from another origin's page ([Request isolation](#request-isolation)) | 403 JSON before authentication (`Cache-Control: no-store`, no `Set-Cookie`) | never met: the package's client connects from the page's own origin |
1248
1324
  | Unknown endpoint | 404 | treated as retryable; backoff up to 30 s |
1249
1325
  | 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 |
1250
1326
  | Malformed cursor | 200, `retry: 86400000` → `event: resync-required` | rereads the ids (server cache bypassed) and reopens from the new anchor |
1251
1327
  | Cursor older than the oldest retained entry (the stream is trimmed with `MAXLEN ~ streamMaxLen`) | 200, `connected` → heartbeat → `retry: 86400000` → `event: resync-required` → end | same as above |
1252
1328
  | The start of a catch-up page is trimmed away before the page is read | 200, the entries delivered so far → `retry: 86400000` → `event: resync-required` → end | same as above |
1253
1329
  | Empty stream, or a cursor inside the retained window or in the future | 200, `connected` → heartbeat → catch-up (every entry) → live | continues |
1254
- | Redis failure during the catch-up | the body ends (`producer-failed`) | one native reconnect with `Last-Event-ID`, then the utility's own backoff; the cursor is kept |
1330
+ | Redis failure during the catch-up, or the stream tail cannot be read while the shared reader fixes its read position (`ready()` rejects; on the path without a cursor before `connected` is written) | the body ends (`producer-failed`) | one native reconnect with `Last-Event-ID`, then the utility's own backoff; the cursor is kept |
1255
1331
 
1256
1332
  - **Resume cursor**: `readLastEventId` reads the `Last-Event-ID` header first, then the `lastEventId` query parameter (`DAILY_REPORT_SSE_CURSOR_PARAM`). The browser's own reconnect reuses the URL it opened with (a stale query) but sends a fresh header, so the header is always the newer value. A cursor must be a Redis stream id of the form `<ms>-<seq>` with 1–15 digits per part (`isDailyReportSseStreamId`); anything else is answered with `resync-required`. The client URL-encodes the cursor.
1257
1333
  - **`connected` frame**: `data: {"type":"connected"}` with `id:` set to the resume position (the cursor, or the newest entry at connect time when there is no cursor). On the cursor path it is written before any Redis round trip.
1258
1334
  Without a cursor it is written only after the shared reader has fixed its read position (`DailyReportSseReader.ready()`), so every entry appended after a client sees `connected` reaches that client; on the cursor path the catch-up is read after that point instead, so nothing appended between the catch-up and the position fix is lost.
1259
- When the stream tail cannot be read, `ready()` still resolves and the reader degrades to `$`, where an entry appended while a read is being re-armed can be missed; a running reader moves to the position as soon as a later `ready()` fixes it.
1335
+ The read position is always a concrete stream id: the shared reader fixes it by reading the stream tail (XREVRANGE) before its first run starts reading, and it never issues an XREAD from `$` (which the server resolves to the tail again at every read, so entries appended between two blocking reads would reach nobody).
1336
+ When the tail cannot be read, `ready()` rejects and the failed read is not kept, so the next call reads again: the connection that waited for it ends (`producer-failed`, the row above) and the client reopens it, with its cursor when it has one, so the catch-up covers everything since; and a run that cannot fix its position fails like any other failing run (every subscriber's `onError`). A host with its own SSE handler awaits `ready()` the same way and closes the connection when it rejects.
1260
1337
  The same frame is used as an anchor-only frame for entries the viewer's filters drop (addressed to another user, or a source type the viewer cannot see), so the client's cursor keeps advancing without receiving their content.
1261
1338
  Without it, a tab whose visible traffic is quiet while other users' read / star updates flow would keep an old cursor, and its next reconnect would fall outside the retained window (`resync-required`, then an ids rescan that bypasses the server cache).
1262
1339
  - **Catch-up**: entries after the cursor are read in pages of 1,000 (`xRange` is inclusive, so each page skips its start id) until the stream end; every entry is delivered. When a page does not start with its start id, the oldest retained entry is read again: if it is newer than the start id, the range after it may have been trimmed, so the connection ends with `resync-required` instead of silently skipping it (a cursor that is simply not an entry id inside the retained window continues).
@@ -1289,8 +1366,9 @@ Every entry exports by name (no `export *`), so a module-internal helper never b
1289
1366
  - Types: `DailyReportClientConfigInput` / `DailyReportClientConfigDefaults` / `SourceTypeConfig` / `DailyReportLocale` / `DailyReportLabels` / `DailyReportLabelOverrides` / `DailyReportOperation` / `DailyReportErrorInfo` / `DailyReportSseConnectionStatus`.
1290
1367
  - **server** (`@aiquants/daily-report/server`, Node.js):
1291
1368
  - Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor }`; `readSseStreamAnchor`; `attachmentDelivery`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
1292
- - Attachments: `createSharpThumbnailRenderer` / `predictSharpThumbnailWorkingSetBytes` / `DAILY_REPORT_SHARP_THUMBNAIL_WORKING_SET_MODEL` / `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_BUDGET_BYTES` / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_OUTPUT_MEDIA_TYPES` / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_MAX_OUTPUT_BYTES`.
1369
+ - 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`.
1293
1370
  - SSE and caching: `DailyReportSseReader` / `DAILY_REPORT_SSE_VISIBILITY_REFRESH_MS` / `DAILY_REPORT_SSE_CATCH_UP_BATCH` / `isStreamIdLte` / `SqlResultCache` / `transformJsonArray` / `jsonResponseWithETag` / `generateETag`.
1294
- - Types: `DailyReportServerConfig` / `DailyReportServiceConfig` / `DailyReportTables` / `DailyReportDb` / `DailyReportAuthenticate` / `DailyReportAuthResult` / `DailyReportIdCodec` / `DailyReportRedisProvider` / `StreamEntry` / `ExternalReportFields`, and for attachments `DailyReportAttachmentDelivery` / `DailyReportReadAttachment` / `DailyReportAttachmentError` / `DailyReportAttachmentFailure` / `DailyReportAttachmentThumbnailRenderer` / `DailyReportAttachmentThumbnail` / `DailyReportAttachmentThumbnailSourceFormat` / `DailyReportAttachmentThumbnailOutputMediaType` / `SharpLike`.
1371
+ - Types: `DailyReportServerConfig` / `DailyReportServiceConfig` / `DailyReportTables` / `DailyReportDb` / `DailyReportAuthenticate` / `DailyReportAuthResult` / `DailyReportIdCodec` / `DailyReportRedisProvider` / `DailyReportSseReaderPort` (the shared reader `createDailyReportHandlers` takes) / `StreamEntry` / `ExternalReportFields`,
1372
+ and for attachments `DailyReportAttachmentDelivery` / `DailyReportReadAttachment` / `DailyReportAttachmentError` / `DailyReportAttachmentFailure` / `DailyReportAttachmentThumbnailRenderer` / `DailyReportAttachmentThumbnail` / `DailyReportAttachmentThumbnailSourceFormat` / `DailyReportAttachmentThumbnailOutputMediaType` / `SharpLike`.
1295
1373
 
1296
1374
  MIT