@aiquants/daily-report 0.26.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,8 @@ pnpm add @aiquants/daily-report @aiquants/virtualscroll @aiquants/swipe-overlay
26
26
  ```
27
27
 
28
28
  `@aiquants/virtualscroll` / `@aiquants/swipe-overlay` / `@aiquants/resize-panels` / `react` / `react-dom` / `react-router` / `drizzle-orm` / `zod` are **peer dependencies**. React must be **19 or later** (`react` / `react-dom` `>=19`): the rows register their keyboard focus targets with ref callbacks that return a cleanup.
29
- `@aiquants/virtualscroll` must be **3.9.0 or later**: both views pass its scroll-bar option `enableArrowButtonTabStops: false` (3.8.0), which takes the scroll bar's arrow buttons out of the Tab order (the views scroll by keyboard themselves; see [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)), its stylesheet stops the motion of its own parts under `prefers-reduced-motion: reduce` (3.8.2;
30
- see [Selection and focus appearance](#selection-and-focus-appearance)), and both views re-record their scroll anchor from its `onScrollAdjust` notification and rely on its edge-keeping snap (3.9.0; see **Host selections and list changes** and [View height](#view-height-host-layout)). The label catalog reuses its 7 engine keys (see [Localization](#localization-locale--labels)).
29
+ `@aiquants/virtualscroll` must be **3.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
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).
32
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).
33
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.
@@ -36,7 +36,8 @@ Build / test: `pnpm run build` (tsup → dist) / `pnpm run test` (vitest) / `pnp
36
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.
37
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.
38
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 runs `node scripts/check-bundle-size.mjs --exact` right after `pnpm run verify` (which ends with the build and the bundle check), before the leak check and the version bump: besides the budgets, it fails when any measured size differs from its baseline entry in either direction and prints the `node scripts/check-bundle-size.mjs --write --accept` command that records that build.
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.
40
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).
41
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.
42
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).
@@ -53,8 +54,8 @@ Every TypeScript or JavaScript code block of this README names its source in its
53
54
  | --- | --- | --- | --- |
54
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 |
55
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) |
56
- | Container size queries and container query units (`cqi`) | Chrome 105, Firefox 110, Safari 16 | The attachment grid's column count; the tile frame's height and the tile's lattice pad (`100cqi` of the tile root, an inline-size container as wide as its track); the view-mode toolbar's end inset in the single-column List | One attachment column at every width; frames sized by `aspect-ratio` alone and tiles without the pad (below); the toolbar's end edge overhangs the List's scroll bar by 8 px |
57
- | CSS `round()` | Chrome 125, Firefox 118, Safari 15.4 | The tile frame's whole-pixel height `round(nearest, 100cqi * 320 / 480, 1px)` and the tile's lattice pad `round(up, h, 4px) − h` under its last line (see [Attachment display](#attachment-display)); the views' column origin on the 4 px lattice (see **Origin on the 4 px lattice**) | Chrome 121–124, inside the floor, drop those declarations: the frame's `aspect-ratio: 480 / 320`, always declared beside its height, sizes the frame at the exact fractional 2t / 3, so the frames of one grid can paint one device pixel apart; a tile stays its frame's fractional height + 48 px tall, so the rows below an image grid can start between device pixels; and the column keeps the `mx-auto` centre |
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 |
58
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 |
59
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 |
60
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 |
@@ -168,6 +169,20 @@ A rejected value — a wrong type included, also a port or a function given with
168
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`.
169
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.
170
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
+
171
186
  ### Source-type visibility
172
187
 
173
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.
@@ -246,8 +261,8 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
246
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).
247
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.
248
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.
249
- - **Same-origin loads only**: every attachment response, success or failure — the original's 200 (inline and download, GET and HEAD), the thumbnail's 200 and 304, and every error status the loader answers (400, 401, 403, 404, 405, 413, 429, 500, 502 and 503, for GET and HEAD alike) — carries `Cross-Origin-Resource-Policy: same-origin` and `X-Content-Type-Options: nosniff`, so the browser lets only pages of the host's own origin load it. Both headers come from one set that every path building an attachment response spreads last, so no status can lose them.
250
- The session cookie also accompanies requests from other origins of the same site, so a response without the policy would let a same-site page learn, token by token, whether the viewer can see each attachment: an image load succeeds or fails, and a no-cors `fetch` resolves with an opaque response for a response without the policy while the browser blocks one that carries it. With the policy on every status, every cross-origin load fails alike.
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.
251
266
 
252
267
  **Configuration**
253
268
 
@@ -409,11 +424,11 @@ type DailyReportAttachmentThumbnailRenderer = {
409
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)
410
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 ""`).
411
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.
412
- - `signal` aborts when every request waiting for that content has gone or when the render stage's deadline passes (`renderMs`). A renderer that can stop (a remote image service, for example) should forward it and settle with `{ ok: false, reason: "failed" }`. A render cut short by the signal must never answer `unsupported`: a late `unsupported` is recorded as a content-determined outcome (below).
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).
413
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>`).
414
- What such a render has paid for is kept: when it settles after the deadline with a thumbnail that passes the output checks below, or with `unsupported`, and the generation's read matched the row's size (**verified**, see **Read port** above), the outcome is cached before the slot is released. A late result is classified by the same rule as one that settles in time, so only those conclusions are kept: a late `failed`, a late exception, a late output that fails the checks and a late value outside the contract are not cached.
415
- Until such a render settles, a new generation of the same content waits for it instead of reading and decoding the same source again beside it (**Generation** below), so a content key has at most one decode at a time: a slow but legitimate source costs one overrun, and the next view is answered from the cache.
416
- A generation that every waiting request left before its render settled or reached the deadline ends as 503 (`reason=aborted`) and keeps nothing, whatever the render's result.
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.
417
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.
418
433
  - `unsupported` means the content itself cannot be downscaled (deterministic); `failed` means a transient failure (retry may succeed).
419
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.
@@ -458,7 +473,7 @@ Values for implementing the port:
458
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:
459
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.
460
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.
461
- - checks the abort signal before it starts and after the header read, before decoding, and answers `failed` when it is aborted by then. libvips cannot be stopped midway, so a decode that has started returns its own result (the thumbnail, `unsupported` or a resource `failed`) even when the signal aborts meanwhile; the package decides what to keep (a late verified result is cached, see **Thumbnail renderer port** above).
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).
462
477
  - is named `sharp-<sharp version>/vips-<libvips version>/webp-q75-e4/flatten-#ffffff/v1`, from `sharp.versions` at run time.
463
478
 
464
479
  Example — the wiring; the host imports its own sharp (type-checked, not shipped):
@@ -544,7 +559,7 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
544
559
  **Thumbnail endpoint (`?thumbnail=tile`)**
545
560
 
546
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.
547
- - **Order**: authenticate → query (400) → method → ports → token → viewer → renderer port → rate admission (below; an empty bucket answers 429 without resolving visibility or running the authorization query)
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)
548
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.
549
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`:
550
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.
@@ -559,9 +574,9 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
559
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.
560
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.
561
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).
562
- **One decode per content key at a time**: a render that passed its deadline keeps its slot until it settles, and while it runs, a new generation of the same identity does not queue for a slot: it first waits for that render to settle and its result to be recorded or dropped, then reads the cache, and only then queues at the gate.
563
- The wait for the late render and the wait for a slot share one `queueWaitMs` budget, and the generation's abort signal ends both; when the budget runs out first, the request is answered 503 `reason=queue` without a second read or decode. A generation that receives its slot also reads the cache again before it reads the original, so a request that waited at the gate behind an overrunning render of the same content is answered from that render's late result.
564
- The generation has its own abort signal, which fires only when every joined request has gone, and passes it to the gate wait and to both ports. An abandoned generation leaves the wait queue; a port that honours the signal settles early and hands the slot to the next queued generation; and a stage that settles after the abort is discarded before its result is looked at (see **Read port** above).
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.
565
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.
566
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.
567
582
 
@@ -574,7 +589,8 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
574
589
  | Client load timeout | 75 s (the sum) | the load counts as one failure |
575
590
 
576
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.
577
- It stores only verified outcomes: successful previews and the content-determined failures (signature mismatch, the port's `unsupported`, and the read port's `too_large` for a row whose size is unknown — on a row with a known size, which eligibility keeps within `attachmentMaxBytes`, a `too_large` is always unverified), including those a render delivers after its deadline (see **Thumbnail renderer port** above). It never stores unverified outcomes, `failed`, the 502 of a timeout, storage errors (including `not_found`), queue rejections, aborts or exceptions.
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.
578
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);
579
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.
580
596
 
@@ -583,7 +599,7 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
583
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` |
584
600
  | 304 | `If-None-Match` matches (carries `Cache-Control`, `ETag`, `X-Content-Type-Options: nosniff`, `Cross-Origin-Resource-Policy: same-origin`, `Set-Cookie`) |
585
601
  | 400 | A query that names no delivery (unknown or repeated variant such as `?thumbnail=1`, combined with `download`), or a malformed token |
586
- | 401 / 403 | Not authenticated / no internal user |
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)) |
587
603
  | 404 | Ports not injected, not visible, not eligible, signature mismatch, `unsupported`, `too_large`, `not_found`, `denied`, `invalid_path` — one identical body for all |
588
604
  | 405 | Any method other than GET (`Allow: GET`) |
589
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`) |
@@ -721,7 +737,9 @@ The keys are delegated to each view's **list**: the element that holds the view'
721
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).
722
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.
723
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.
724
- The scroll bar's two arrow buttons are not Tab stops in either view (`enableArrowButtonTabStops: false` of `@aiquants/virtualscroll`), because the keys above already scroll the list. The arrows still scroll on a press and while held and keep their names; they sit inside the scroll bar's `role="scrollbar"` element, whose children ARIA 1.2 makes presentational, so whether assistive technology presents them as separate buttons depends on the browser (Chromium does).
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.
725
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.
726
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).
727
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)
@@ -768,11 +786,12 @@ The keys are delegated to each view's **list**: the element that holds the view'
768
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.
769
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).
770
788
  The anchor is recorded after every change of the position, so it describes the committed position, not the one before the last change:
771
- - **Position changes the view makes itself** — key moves (each frame of a held key included), the reveal of keyboard focus, the List's alignment of the card whose mobile overlay closed and its rescale when the slot P changes, the DetailList's selection alignment and its reveal after the viewer's comment, and the DetailList's row re-measurements — all go through one scroller (`ListViewScroller`: `toIndex`, `by` and `resizeRow`) that records the anchor right after the call, without waiting for the next visible-range report.
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.
772
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.
773
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.
774
- The single path is a type: the keyboard navigation and the views receive only a read-only handle (`ListNavigationHandle`, the getters they read), so a view that calls `scrollToIndex`, `scrollBy`, `scrollTo`, `applyWheel` or `updateItemSize` fails the type check, however the call is spelled; only the anchor's scroller and the end-to-end test handle hold the full handle, and `src/client/components/view-scroller.spec.ts` also scans the sources for such a call outside the scroller.
775
- - **Position changes `VirtualScroll` makes on its own** — the layout-shift compensation of a row re-measured above the first visible row, the reconciliation with a new row height after a render, the re-pinning of a pending alignment after a size change, and the second stage of a compensation the pane had clamped — are reported synchronously, once each change is complete, through its `onScrollAdjust` (3.9.0, the peer floor).
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).
776
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.
777
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.
778
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.
@@ -833,16 +852,19 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
833
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).
834
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.
835
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.
836
- - **Row slots on the lattice**: every row slot of both views is a multiple of the 4 px layout lattice u, which is a whole number of device pixels at the ratios 1, 1.25, 1.5, 1.75 and 2 (4, 5, 6, 7 and 8), so every row top sits on a device-pixel boundary wherever the list is scrolled (`VirtualScroll` snaps only the layer that moves the rows; a row starts on a device pixel when the rows above it add up to whole device pixels).
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).
837
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).
838
- A row that has not been measured yet is laid out at an estimate of 352 px (`UNMEASURED_ROW_HEIGHT_ESTIMATE_PX`), also a multiple of 4, so the unmeasured rows above the window move the rows below by whole lattice steps. An image attachment tile is padded to the lattice as well (**Tile** in [Attachment display](#attachment-display)), so the rows below an image grid stay on it.
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.
839
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.
840
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).
841
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.
842
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.
843
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.
844
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.
845
- - **Every row is a relayout boundary**: the row frames of both views are `contain: size layout style` (one token, `ROW_CONTAINMENT_CLASS_NAME`), so a change inside one row — a held body rendered after the key's commit, a load that completes, the selection and focus indicators — lays out that row alone and restyles no other; the layout of a key move stays inside the rows it changes, wherever the list is scrolled.
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).
846
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.
847
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.
848
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).
@@ -894,13 +916,13 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
894
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"`).
895
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.
896
918
  Both option sets are module constants, so the view's props to `VirtualScroll` stay equal from render to render.
897
- The floating tap-scroll circle and its visual, the scroll bar's thumb and arrow buttons and the scroll-to-edge buttons are `VirtualScroll`'s own: one rule of its stylesheet (since 3.8.2; the peer floor is 3.9.0) stops every transition and animation of every part it animates under `prefers-reduced-motion: reduce`
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`
898
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.
899
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`.
900
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.
901
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.
902
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**).
903
- Two lengths follow the container instead, by design: the attachment grid's fluid track width t = (W − 16 (n − 1)) / n (horizontal) and the tile frame's height derived from it, h(t) = round(2t / 3) (see [Attachment display](#attachment-display)).
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)).
904
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.
905
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)).
906
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.
@@ -920,6 +942,8 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
920
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.
921
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`.
922
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.
923
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).
924
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.
925
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.
@@ -930,21 +954,26 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
930
954
 
931
955
  - Attachments with `hasThumbnail: true` are laid out as tiles in a grid; the others as rows. Each group keeps the original order.
932
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`).
933
- - **Grid**: the attachment section is a size container, and the number of columns follows its width W: n(W) = 1 + ⌊(W + 16) / 256⌋ up to 5 (breakpoints at 240, 496, 752 and 1,008 px; 256 = the largest frame width 240 + the 16 px gap).
934
- The grid itself is at most 1,264 px wide, the width at which 5 tracks are exactly 240 px, so a track t is never wider than 240 px (t ≤ 240, equal only at the cap) inside any container, however wide, and at a device pixel ratio up to 2 a thumbnail is never upscaled.
935
- Each track is t(W) = (W − 16 (n − 1)) / n. For example, at 1280 × 800 and at 1920 × 1080 the DetailList card's content column is W = 1,136 (the rows, beside the 8 px scroll bar) − 2 × 8 (G) − 2 × (1 + 15) (the card's border and padding) − 16 − 240 (the gap and the metadata column) = 832 px, which holds 4 tracks of 196 px.
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.
936
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:
937
- n = 3 at any scroll-bar width (W stays between 496 and 752), with t = (736 − 32) / 3 = 234⅔ for overlay scroll bars and (726 − 32) / 3 = 231⅓ beside a 10 px gutter. The 390 px phone overlay's pane is 358 px wide, W = 358 − g: 2 tracks of 171 px (overlay scroll bars) or of 166 px (a 10 px gutter).
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).
938
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.
939
- Every length of the grid — the breakpoints, the cap and the gap — is written in px, because the bounds are CSS-pixel quantities: in rem they would grow with the root font size (a 20 px root would make the tracks 300 px wide, upscaled 1.25× at a device pixel ratio of 2).
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).
940
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.
941
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.
942
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.
943
- Every tile is then ⌈h / 4⌉ · 4 + 48 px tall, so every tile row top and the grid's height stay on the 4 px lattice, and so does what follows the grid (the DetailList rows below an image grid start on device pixels); for the tracks below, t = 196 gives h = 131 and δ = 1, 171 gives 114 and 2, 231⅓ gives 154 and 2, and 234⅔ gives 156 and 0. On Chrome 121–124, which lack `round()`, the margin declaration is dropped with the frame height, and a tile stays its frame's height + 48 px tall.
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.
944
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).
945
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).
946
- One `ResizeObserver` per document watches every grid in it (created from the document's own window, disconnected when its last grid stops) and supplies the track width, computed from the grid's content width with the column formula above, not by measuring tiles.
947
- A notification only reads its records: it keeps each grid's track width and asks for one animation frame when a track width differs from the one last handed out. It never writes the DOM, never commits and never reads layout, so nothing done during a delivery can resize an element that any observer watches — the grid, or a virtual-scroll row around it — and the browser never reports a ResizeObserver loop.
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.
948
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.
949
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.
950
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)
@@ -955,8 +984,8 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
955
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"`).
956
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.
957
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`).
958
- No script measures or writes a frame, so its height is right from the first frame, a width change resizes the frames in the layout that changes the width, and every row of a grid draws its frames with the same number of device pixels at an integer device pixel ratio (a fractional height such as 130.33 px would give rows of 130 and 131; at a fractional ratio such as 1.25 or 1.5 the product can still be fractional).
959
- The rounding keeps the frame within |t / h − 3 / 2| ≤ 0.75 / h, under 0.01 for every track of a grid with two or more columns (t ≥ 112, h ≥ 75); for the widths above, 196 → 131, 234⅔ → 156, 231⅓ → 154, 171 → 114 and 166 → 111 (each tile adds the lattice pad under its last line, **Tile** above). On Chrome 121–124, which lack `round()`, the height declaration is dropped and the aspect ratio alone gives the fractional 2t / 3 (see the browser floor table).
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).
960
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).
961
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.
962
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).
@@ -986,7 +1015,8 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
986
1015
 
987
1016
  | `data-testid` | Element |
988
1017
  | --- | --- |
989
- | `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 |
990
1020
  | `daily-report-attachment-grid` | `<ul>` of tiles (only when at least one attachment has a thumbnail) |
991
1021
  | `daily-report-attachment-rows` | `<ul>` of rows (only when at least one attachment has no thumbnail) |
992
1022
  | `daily-report-attachment-item` | One attachment (tile or row), with `data-attachment-state` = `present` / `absent` / `unknown` |
@@ -1026,7 +1056,8 @@ DOM hooks are `data-*` attributes, test ids and ARIA; class names are styling an
1026
1056
 
1027
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.
1028
1058
 
1029
- `[data-testid="daily-report-list"]` and `[data-testid="daily-report-detail-list"]` carry a read-only `__virtualScroll` accessor for end-to-end tests, with the same shape in both views: the current `VirtualScroll` handle plus `findReportIndex(id)`, `getReportItem(index)` and `getReportIds(limit = 20)` over the view's committed list. It only observes: select through the UI (a click, the keys). Read it on every use; it is `undefined` while the list is not mounted.
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.
1030
1061
 
1031
1062
  ### Localization (`locale` / `labels`)
1032
1063
 
@@ -1044,14 +1075,14 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1044
1075
  />
1045
1076
  ```
1046
1077
 
1047
- - **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
1048
1079
  `@aiquants/virtualscroll` plus 83 own keys: field headings, the page title (`title`, passed to
1049
1080
  `renderHeader` and used as the accessible name of both lists), the lists' keyboard help, the row state read to
1050
1081
  screen readers, tabs, buttons, placeholders, `confirm()` / `alert()` texts, stream badges, the load-error screen
1051
1082
  and the mutation-failure message. The engine part of each catalog is `VIRTUAL_SCROLL_LABEL_CATALOGS[locale]`, reused by
1052
- value, so the scroll arrows and the "No items" text always speak the same language as the rest.
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.
1053
1084
  - **Forwarded to every embedded `VirtualScroll`.** Both the List and the DetailList pass `locale`
1054
- and the 7 engine keys of the resolved labels to their `VirtualScroll`. The engine label object keeps
1085
+ and the 11 engine keys of the resolved labels to their `VirtualScroll`. The engine label object keeps
1055
1086
  its identity while its values are unchanged, so rebuilding `config.labels` on every render does not
1056
1087
  re-render the memoized list subtree.
1057
1088
  - **Formatter keys.** Ten keys take arguments and are functions: `totalCount(count)`,
@@ -1064,7 +1095,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1064
1095
  the BCP 47 tag bound to their locale (`en` → `"en-US"`, `ja` → `"ja-JP"`); a host override receives
1065
1096
  the raw number and formats it itself.
1066
1097
  - **Fail-fast**: an unsupported `locale` (`"EN"`, `"ja-JP"`, `"fr"`, `""`, `null`, ...) throws a
1067
- `RangeError` at render. So do an unknown `labels` key (a key error that lists the 90 keys), a string
1098
+ `RangeError` at render. So do an unknown `labels` key (a key error that lists the 94 keys), a string
1068
1099
  key whose value is not a string with non-whitespace content, and a formatter key whose value is not a
1069
1100
  function. An `undefined` value keeps the catalog value.
1070
1101
  - **`config` has a closed key set**: `apiBasePath`, `ssePath`, `draftLabelName`, `locale`, `labels`,
@@ -1101,7 +1132,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1101
1132
  grouping follows `locale` (`en` → `"en-US"`, `ja` → `"ja-JP"`) and dates stay in ISO form. Packages
1102
1133
  that do format dates (for example `@aiquants/period-slider`) name that separate axis `formatLocale`.
1103
1134
 
1104
- 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
1105
1136
  first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReportLabels(locale, labels)`
1106
1137
  (the effective frozen labels — the catalog object itself when `labels` is `undefined`), the resolved
1107
1138
  `locale` / `labels` on `useDailyReportConfig()`, and the types `DailyReportLocale` (= `VirtualScrollLocale`),
@@ -1113,6 +1144,10 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
1113
1144
  | `scrollDown` | engine: vertical ScrollBar down arrow (`aria-label`) | Scroll down | 下へスクロール |
1114
1145
  | `scrollLeft` | engine: horizontal arrow (not rendered by this package) | Scroll left | 左へスクロール |
1115
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 | 横スクロール位置 |
1116
1151
  | `scrollToTop` | engine: top pill (not enabled by this package) | Top | 先頭へ |
1117
1152
  | `scrollToBottom` | engine: bottom pill (not enabled by this package) | Bottom | 末尾へ |
1118
1153
  | `noItems` | engine: the one message of an empty List or DetailList, over the top of the list | No items | 項目がありません |
@@ -1149,7 +1184,7 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
1149
1184
  | `createReport` | create button | New report | 日報作成 |
1150
1185
  | `createReportTitle` | create button tooltip | Create a daily report | 日報を作成 |
1151
1186
  | `devControls` | dev toolbox tooltip (`showDevControls`) | Development-only controls | 開発環境限定コントロール |
1152
- | `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 リアルタイム同期切替 |
1153
1188
  | `devReload` | dev toolbox: reload tooltip | Reload data | データ再取得 (リロード) |
1154
1189
  | `devClearCache` | dev toolbox: clear-cache tooltip | Clear the cache and reload | キャッシュ破棄 & 再取得 |
1155
1190
  | `autoRead` | auto-read switch label | Auto-read | 自動既読 |
@@ -1272,19 +1307,20 @@ Every breaking change and its migration is listed per version in `CHANGELOG.md`,
1272
1307
  ## Realtime Architecture
1273
1308
 
1274
1309
  1. Mutation services publish to Redis Stream (`daily-report:sse-stream`) in sequence: `invalidate -> epoch increment -> SSE publish`.
1275
- 2. `DailyReportSseReader` uses a single blocking `xRead` loop to fan-out events to all SSE connections (eliminating per-connection Redis TCP connections).
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.
1276
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).
1277
- 4. On the client, `useDailyReportSseConnection` runs on `useReopeningEventSource` (`@aiquants/sse/react`): full-jitter backoff from 2 s to 30 s, the failure count reset only after 30 s of healthy open, one native browser reconnect (which carries the cursor in the `Last-Event-ID` header), and a stale watch of 45 s (three heartbeat periods).
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).
1278
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).
1279
1314
 
1280
1315
  ### SSE wire contract
1281
1316
 
1282
- Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000` before one named terminal event, so a client that does not listen for the event (an older bundle) reconnects at most once a day instead of in a loop. A non-200 response is reserved for a misconfigured endpoint.
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).
1283
1318
 
1284
1319
  | Server-side situation | Response | Client (`useDailyReportSseConnection`) |
1285
1320
  | --- | --- | --- |
1286
1321
  | Not authenticated | 200, `retry: 86400000` → `event: auth-required` (`Set-Cookie` forwarded) | terminated; calls `config.onSessionExpired()` once; never reconnects |
1287
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 |
1288
1324
  | Unknown endpoint | 404 | treated as retryable; backoff up to 30 s |
1289
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 |
1290
1326
  | Malformed cursor | 200, `retry: 86400000` → `event: resync-required` | rereads the ids (server cache bypassed) and reopens from the new anchor |
@@ -1332,6 +1368,7 @@ Every entry exports by name (no `export *`), so a module-internal helper never b
1332
1368
  - Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor }`; `readSseStreamAnchor`; `attachmentDelivery`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
1333
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`.
1334
1370
  - SSE and caching: `DailyReportSseReader` / `DAILY_REPORT_SSE_VISIBILITY_REFRESH_MS` / `DAILY_REPORT_SSE_CATCH_UP_BATCH` / `isStreamIdLte` / `SqlResultCache` / `transformJsonArray` / `jsonResponseWithETag` / `generateETag`.
1335
- - Types: `DailyReportServerConfig` / `DailyReportServiceConfig` / `DailyReportTables` / `DailyReportDb` / `DailyReportAuthenticate` / `DailyReportAuthResult` / `DailyReportIdCodec` / `DailyReportRedisProvider` / `StreamEntry` / `ExternalReportFields`, and for attachments `DailyReportAttachmentDelivery` / `DailyReportReadAttachment` / `DailyReportAttachmentError` / `DailyReportAttachmentFailure` / `DailyReportAttachmentThumbnailRenderer` / `DailyReportAttachmentThumbnail` / `DailyReportAttachmentThumbnailSourceFormat` / `DailyReportAttachmentThumbnailOutputMediaType` / `SharpLike`.
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`.
1336
1373
 
1337
1374
  MIT
package/dist/client.d.mts CHANGED
@@ -137,6 +137,10 @@ declare const DAILY_REPORT_LABEL_KEYS: readonly [
137
137
  "scrollDown",
138
138
  "scrollLeft",
139
139
  "scrollRight",
140
+ "verticalScrollBar",
141
+ "horizontalScrollBar",
142
+ "verticalScrollThumb",
143
+ "horizontalScrollThumb",
140
144
  "scrollToTop",
141
145
  "scrollToBottom",
142
146
  "noItems",
package/dist/client.d.ts CHANGED
@@ -137,6 +137,10 @@ declare const DAILY_REPORT_LABEL_KEYS: readonly [
137
137
  "scrollDown",
138
138
  "scrollLeft",
139
139
  "scrollRight",
140
+ "verticalScrollBar",
141
+ "horizontalScrollBar",
142
+ "verticalScrollThumb",
143
+ "horizontalScrollThumb",
140
144
  "scrollToTop",
141
145
  "scrollToBottom",
142
146
  "noItems",