@aiquants/daily-report 0.28.0 → 0.30.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,10 +26,12 @@ 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.11.0 or later**: both views pass its scroll-bar option `enableArrowButtonTabStops: false`, since 3.10.0 its one signal that the host scrolls by keyboard itself, under which the scroll bar is pointer-only — hidden from assistive technology, out of the Tab order and never taking focus on a press (the views scroll by keyboard themselves; see [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)) —; its stylesheet stops the motion of its own parts under `prefers-reduced-motion: reduce` (3.8.2;
29
+ The peer floor of `@aiquants/virtualscroll` is **3.11.1**: install 3.11.1 or later.
30
+ 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
31
  see [Selection and focus appearance](#selection-and-focus-appearance)); and both views re-record their scroll anchor from its `onScrollAdjust` notification and rely on its edge-keeping snap (3.9.0; see **Host selections and list changes** and [View height](#view-height-host-layout)). The label catalog reuses its 11 engine keys, the names of the scroll bar and its thumb among them (3.10.0; see [Localization](#localization-locale--labels)).
31
- The test handle's `DailyReportRevealOptions` is its `scrollToIndex` options, which take `align: "nearest"` (3.11.0), and DetailList rows stay at the tree's tops and heights after a batch of re-measurements whose deltas cancel only because its row memo follows the tree revision (3.11.0).
32
- That floor is declared once, in `peer-floors.json` (`{ "<peer name>": "<X.Y.Z>" }`), and every publish path runs `scripts/check-peer-floors.mjs` (`pnpm run check:peer-floors`) right after the leak check: each `publish:*` script before its version bump (so a refusal leaves no bumped version behind), and `prepublishOnly` before every `pnpm publish`, a bare one included (for example a re-run after a `publish:*` whose registry step failed).
32
+ `DailyReportRevealOptions`, the options of the test handle's `revealIndex`, is `@aiquants/virtualscroll`'s `scrollToIndex` options type, which takes `align: "nearest"` since 3.11.0, and DetailList rows stay at the tree's tops and heights after a batch of re-measurements whose deltas cancel only because its row memo follows the tree revision (3.11.0).
33
+ That floor is declared once, in `peer-floors.json` (`{ "<peer name>": "<X.Y.Z>" }`); the documents attribute each feature to its own version and restate the floor only as the declared one (`src/docs-examples.spec.ts` fails when a sentence of this README or of `docs/specs/` that names the peer floor states another version, on every run of the unit tests).
34
+ 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).
33
35
  `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).
34
36
  `@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.
35
37
  The server entry needs **Node.js 20.3 or later** (`engines.node` `>=20.3.0`): the thumbnail stage deadlines combine the generation's signal with a timer through `AbortSignal.any`. `src/server/node-engine-floor.spec.ts` reads the server-side modules' syntax tree and fails when one of them uses a listed runtime API newer than the declared floor.
@@ -62,6 +64,7 @@ Every TypeScript or JavaScript code block of this README names its source in its
62
64
  | `scrollbar-gutter: stable` | Chrome 94, Firefox 97, Safari 18.2 | The side pane's tab panels keep their scroll bar's width whatever their content's height | Safari 17.4–18.1, inside the floor, ignore it; their default scroll bars overlay the content and take no width |
63
65
  | `text-wrap: balance` | Chrome 114, Firefox 121, Safari 17.5 | The short centred labels that can wrap (`WRAPPING_LABEL_CLASS_NAME`: the side pane's selection prompt, an attachment tile's unavailable label) keep their lines about equally long | Safari 17.4, inside the floor, drops the value and wraps them greedily, so a wrapped label can end with a short last line |
64
66
  | `word-break: auto-phrase` | Chrome 119 (not in Firefox or Safari) | The same labels break Japanese at phrase boundaries where the host document's `lang` is `ja` | Firefox and Safari drop the declaration and break Japanese between any two characters (the default), so a narrow label can break inside a word |
67
+ | `overflow-wrap: anywhere` | Chrome 80, Firefox 65, Safari 15.4 | The same labels break inside a phrase or a word that is wider than their box instead of crossing their frame, also as flex items, whose automatic minimum width would otherwise hold them at their longest phrase | An engine without it drops the declaration: a phrase wider than the label's box crosses its frame |
65
68
  | `<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 |
66
69
 
67
70
  File names are cut at code-point boundaries rather than grapheme boundaries, because `Intl.Segmenter` (Firefox 125) is above the floor (see [Attachment display](#attachment-display)).
@@ -140,7 +143,7 @@ export const dailyReportServer = createDailyReportServer({
140
143
  })
141
144
  ```
142
145
 
143
- Route mounts (React Router v7, flexible file convention):
146
+ Route mounts (React Router v7, flexible file convention). Every resource route is a thin mount: the package's handlers decide the request isolation, React Router's single-fetch data requests and `HEAD` themselves, so a route only hands its arguments over:
144
147
 
145
148
  ```ts illustrative
146
149
  // daily_report._index/loader.server.ts
@@ -153,8 +156,21 @@ export const loader = (args) => dailyReportServer.api.loader(args)
153
156
  export const action = (args) => dailyReportServer.api.action(args)
154
157
  // sse.daily_report.$endpoint/route.tsx
155
158
  export const loader = (args) => dailyReportServer.sse.loader(args)
159
+ // daily_report.api.attachment.$token/route.tsx (see Attachments)
160
+ export const loader = (args) => dailyReportServer.attachment.loader(args)
156
161
  ```
157
162
 
163
+ **Streaming routes**: the SSE route and the API route's ids stream (`GET {apiBasePath}/ids-stream`) answer with a body that streams until the client leaves. The package keeps that body from starting, or from being held, for a request nobody reads as a stream, so the two routes need no guard of their own:
164
+
165
+ - **`HEAD`**: both answer the status and headers a `GET` would get, and start no work for the body.
166
+ Everything that decides the status runs as for `GET` — the request isolation, authentication, the internal user, the endpoint, the resume cursor (a malformed ids cursor is still 400, a malformed SSE cursor still the `resync-required` frame) and the viewer's visibility —
167
+ but the SSE route subscribes to nothing and starts no heartbeat and no visibility refresh timer, and the ids stream runs neither its full query nor its first-page query (so a resumed `GET`, which waits for the full query, can still end in a 500 that a `HEAD` does not see: it answers 200).
168
+ React Router runs a resource route's loader for `HEAD` and drops the body without reading or cancelling it, and an adapter need not abort the request's signal once the response is sent, so body work started for a `HEAD` would run until the process ends. A `GET` whose signal is already aborted starts no body work either.
169
+ - **Single fetch**: React Router answers a single-fetch data request (`<path>.data`) by running the route's loader and reading its whole body before it answers, so a `.data` request to either stream would hold every event, or the whole NDJSON, in server memory until the client leaves.
170
+ The package's request isolation refuses them before authentication ([Request isolation](#request-isolation)): on the SSE route every one, and on the API route the ids stream's (`ids-stream.data`, with or without a cursor, and its trailing-slash form `ids-stream/_.data`), `GET` and `HEAD` alike, with a 404 JSON (`{"error":{"message":"Not Found"}}`, `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`) thrown the way a loader answers early.
171
+ A refused request authenticates nothing, subscribes to nothing, starts no timer and runs no query. The JSON endpoints (`report`, `business-date`) and the action keep serving single fetch, which React Router's own fetchers use.
172
+ - **The API route's methods**: authentication comes first (401), then the endpoint — an unknown name is 404, an inherited name such as `toString` included — and then the method: one the endpoint does not answer is 405 with `Allow`, `GET` for the JSON endpoints (their ETag is computed from the body, so a `HEAD` would build the body only to discard it) and `GET, HEAD` for the ids stream.
173
+
158
174
  ### DI ports
159
175
 
160
176
  - `authenticate(request, { failureRedirect })` — Session verification, declared as the overloaded `DailyReportAuthenticate`. With `failureRedirect: string` the implementation must throw a redirect on unauthenticated requests, so a normal return always carries `user` (typed as required — leaving it optional would force callers to write an unreachable `!user` guard). With `failureRedirect: null` it must not redirect and resolves without `user` instead; forward the returned `cookie` on unauthenticated responses too, otherwise a destroyed session lingers in the browser.
@@ -178,6 +194,17 @@ The three helpers of `src/shared/config-errors.ts` (`configValueError`, `configK
178
194
 
179
195
  Every resource route the handlers serve — `api.loader`, `api.action`, `sse.loader` and `attachment.loader` — is wrapped once, where `createDailyReportHandlers` returns it, in the request isolation, which runs first: before authentication, any rate charge, authorization or service call (`isCrossSiteRequest(request, policy)` in `src/server/request-isolation.ts`). `index.loader`, the document route, is not wrapped.
180
196
  The check is an allow-list: it names what a route serves from another origin, so everything else — frames (`iframe`, `frame`), `fencedframe`, `object`, `embed`, a navigation without `Sec-Fetch-Dest`, and any destination a later specification adds — is refused by construction.
197
+ Before that check the same wrapper asks the route's policy about React Router's single-fetch data requests (`isFrameworkDataRequest`: the URL's path ends in `.data`, the test React Router dispatches on; a percent-encoded `%2Edata` and a `.data` in the query are not one). React Router answers such a request by running the route's loader, reading the loader's whole body into memory and re-encoding it, and keeps none of the loader's headers but `Set-Cookie`.
198
+ The policy's `frameworkDataRefusal` receives the loader's arguments and returns the response the wrapper throws (the way a loader answers early), or `null` to serve the request like any other. A refused request reaches no authentication, rate charge, authorization or service call, and it writes no line in the refusal record below (it is not a cross-site refusal). Each route decides by what its body is:
199
+
200
+ | Route (policy) | Framework data requests |
201
+ | --- | --- |
202
+ | `attachment.loader` (`ATTACHMENT_ISOLATION_POLICY`) | Every `<token>.data` — `GET` or `HEAD`, inline, download or thumbnail, from the same origin or from another origin's navigation alike — is refused with the attachment route's 404 (`Attachment not found`). React Router would read the original into memory and drop every protective header; the 404 carries no attachment content, so it is safe after React Router has replaced its headers, and a caller of the loader itself still sees the attachment security headers |
203
+ | `sse.loader` (`SSE_ISOLATION_POLICY`) | Every one is refused with a 404 JSON (`{"error":{"message":"Not Found"}}`, `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`; `dataRequestRefusal`): the body never ends, and `EventSource` requests the route itself |
204
+ | `api.loader` (`API_LOADER_ISOLATION_POLICY`) | Refused with the same 404 when `params.endpoint` names the ids stream (the whole NDJSON would be held); served for the JSON endpoints |
205
+ | `api.action` (`NOT_NAVIGABLE`) | Served (React Router's own fetchers write through single fetch) |
206
+
207
+ All four policies also decide the navigations they serve from another origin and build their own 403 (`crossSiteRejection`), so the route, its refusals and their response builders cannot be paired wrongly.
181
208
 
182
209
  | Request | Answer |
183
210
  | --- | --- |
@@ -191,8 +218,9 @@ The check is an allow-list: it names what a route serves from another origin, so
191
218
  - **The refusal**: on the API and SSE routes, JSON `{"error":{"message":"Cross-site request rejected"}}` with `Cache-Control: no-store` and `X-Content-Type-Options: nosniff` (`crossSiteRequestRejection`); on the attachment route, the same body through the attachment failure builder, so it carries every attachment security header (**Same-origin loads only** in [Attachments](#attachments)). Nothing has authenticated, so no `Set-Cookie` is forwarded.
192
219
  On the attachment route the refusal comes before the query's 400 and the method's 405: a malformed query, a `HEAD` navigation (browsers never navigate with `HEAD`) or a form `POST` from another origin answers 403.
193
220
  - **The refusal record**: each handler factory keeps one bounded record of its refusals (`createCrossSiteRejectionRecorder`; state per factory, not per module), written to the configured `logger`, or by default to a console logger at the `warn` level prefixed `[DailyReportIsolation]`.
194
- The first refusal of a window of `CROSS_SITE_REJECTION_LOG_WINDOW_MS` (60 s) writes one warn line, `cross_site_rejected route=<api|action|sse|attachment> site=<value> mode=<value> dest=<value> method=<method>` (`-` for an absent header; a value outside the specification's shape — lower-case letters and hyphens, at most 32 characters — is written as the JSON string of its first 32 characters, so a sender cannot forge the line's fields); later refusals in the window are only counted.
195
- The first refusal after the window writes `cross_site_rejected_suppressed count=<N>` for the window before (only when N > 0) and opens a new window with its own line, and a clock that moved back before the window's start opens a new window too. Served requests neither open nor close a window, so an active probe or a CSRF attempt shows in the log at once, at most two lines a minute per factory, however fast the refusals come.
221
+ A refusal with no open window writes one warn line at once, `cross_site_rejected route=<api|action|sse|attachment> site=<value> mode=<value> dest=<value> method=<method>` (`-` for an absent header; a value outside the specification's shape — lower-case letters and hyphens, at most 32 characters — is written as the JSON string of its first 32 characters, so a sender cannot forge the line's fields), and opens a window of `CROSS_SITE_REJECTION_LOG_WINDOW_MS` (60 s) that every route of the factory shares; later refusals in the window are only counted, per route.
222
+ The window's own timer ends it (one unref'd timer per window, which never keeps the process alive): when it counted refusals, it writes `cross_site_rejected_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> routes=api:<n>,action:<n>,sse:<n>,attachment:<n>` (N and the per-route counts are the refusals after the first one), and the next refusal opens a new window with its own line.
223
+ No clock comparison decides a window — the wall clock only stamps `since=` — and served requests neither open nor close one. So the record writes at most two lines per window, two a minute per factory however fast the refusals come: an active probe or a CSRF attempt shows in the log with its first refusal at once, and how many followed shows at the window's end, at most 60 s later, also when the refusals stop.
196
224
  - **Why the request side**: the host's session cookie (`SameSite=Lax` in the usual setting) also accompanies requests from other origins of the same site, form submissions (`application/x-www-form-urlencoded`, `multipart/form-data`, `text/plain`) and no-cors requests need no preflight, so CORS does not stop them, and React Router checks CSRF only for document requests and single fetch, not for resource routes.
197
225
  A response policy such as `Cross-Origin-Resource-Policy` or `X-Frame-Options` decides only whether a response may be read or drawn; it does not stop a state change or the authentication, rate and authorization work a request starts. With the check, a page of another origin can neither post to the API (create, update, publish or delete a report, comment, star, mark read), nor open any of these routes in a frame, nor probe attachment tokens: a visible and a hidden attachment get the same 403 at the same cost, and the viewer's rate buckets are not spent.
198
226
  - **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.
@@ -277,8 +305,10 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
277
305
  - **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.
278
306
  - **Same-origin loads only**: a request from another origin's page — an `<img>`, a no-cors `fetch`, a `HEAD` probe, an `<iframe>` or `<frame>` from a sibling subdomain, any request for a thumbnail — is refused with 403 before authentication ([Request isolation](#request-isolation);
279
307
  on a host served from a potentially trustworthy origin such as HTTPS, where the browser sends Fetch Metadata), so it learns nothing about a token: a visible and a hidden attachment get the same 403 at the same cost, with no authentication, rate token, visibility resolution or query. The one exception is a top-level `GET` document navigation to an original (a link to an attachment in another page), which is served after authentication and authorization as usual.
280
- As defence in depth, every attachment response, success or failure — the original's 200 (inline and download, GET and HEAD), the thumbnail's 200 and 304, and every error status the route answers (400, 401, 403, 404, 405, 413, 429, 500, 502 and 503, for GET and HEAD alike, the isolation's 403 included) — carries `Cross-Origin-Resource-Policy: same-origin`, `X-Content-Type-Options: nosniff` and `X-Frame-Options: SAMEORIGIN`, so the browser lets only pages of the host's own origin read it or draw it in a frame (a frame of another origin could otherwise tell a blocked 200 from a drawn 404).
281
- The three headers come from one set (`ATTACHMENT_RESPONSE_SECURITY_HEADERS`) that every path building an attachment response spreads last — the failure builder `attachmentFailureResponse`, which every JSON failure goes through, and the 200 and 304 of both deliveries — so no status can lose them. `Content-Security-Policy: default-src 'none'; sandbox` stays on the two content 200s.
308
+ As defence in depth, every attachment response the route answers itself, success or failure — the original's 200 (inline and download, GET and HEAD), the thumbnail's 200 and 304, and every error status the route answers (400, 401, 403, 404, 405, 413, 429, 500, 502, 503 and 504, for GET and HEAD alike, the isolation's 403 included) —
309
+ carries `Cross-Origin-Resource-Policy: same-origin`, `X-Content-Type-Options: nosniff` and `X-Frame-Options: SAMEORIGIN`, so the browser lets only pages of the host's own origin read it or draw it in a frame (a frame of another origin could otherwise tell a blocked 200 from a drawn 404).
310
+ The three headers come from one set (`ATTACHMENT_RESPONSE_SECURITY_HEADERS`) that every path building an attachment response spreads last — the failure builder `attachmentFailureResponse`, which every JSON failure goes through, and the 200 and 304 of both deliveries — so no status the route answers can lose them. `Content-Security-Policy: default-src 'none'; sandbox` stays on the two content 200s.
311
+ The one request whose answer the route does not control is React Router's single-fetch data request (`<token>.data`): React Router would read the original into memory, re-encode it and keep only `Set-Cookie` of its headers (no `Cache-Control`, Content Security Policy, `Content-Disposition` or any of the three), so the route refuses it before anything runs, with a 404 that carries no attachment content ([Request isolation](#request-isolation)).
282
312
 
283
313
  **Configuration**
284
314
 
@@ -289,7 +319,7 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
289
319
  | `attachmentThumbnailRenderer` | service | not injected | `readAttachment`, `attachmentIdCodec` | Renders the preview (`DailyReportAttachmentThumbnailRenderer`: `{ id, render }`; the package's sharp implementation is `createSharpThumbnailRenderer`). Validated when the service is created. Without it `hasThumbnail` is always `false` and every thumbnail request answers 404 (it never falls back to the original). |
290
320
  | `attachmentMaxBytes` | service | 32 MiB | `readAttachment` | Upper bound for an original, for every delivery. Keep it below the read port's wire limit. Integer ≥ 1. |
291
321
  | `attachmentRateLimitPerMinute` | handlers | 60 | `readAttachment` | Original downloads per user per minute; a token is spent before authorization. Integer ≥ 1. |
292
- | `attachmentConcurrency` | handlers | 4 | `readAttachment` | Simultaneous original reads; the slot is held until the body is sent, and the request fails fast with 503 when none is free. Integer ≥ 1. |
322
+ | `attachmentConcurrency` | handlers | 4 | `readAttachment` | Simultaneous original reads. A slot is held while the original is referenced (below: until its body has been handed over, the client cancels it, or the client stops reading for 60 s), and one viewer holds at most half of the slots, rounded up (⌈n / 2⌉: 2 of the default 4). A request that finds no free slot, or whose viewer already holds that share, fails fast with 503 (`reason=concurrency`, `Retry-After: 5`). Integer ≥ 1. |
293
323
  | `attachmentThumbnailRateLimitPerMinute` | handlers | 120 | `attachmentThumbnailRenderer` | Thumbnail generations per user per minute, in a bucket separate from originals; revalidations (requests with `If-None-Match`) pay from a second bucket of ten times this value. Both admit before authorization, and an empty bucket answers 429 at once; a revalidation that does not end in a 304 also pays a generation token, right after authorization (see **Rate admission** below). Integer ≥ 1. |
294
324
  | `attachmentThumbnailConcurrency` | handlers | 2 | `attachmentThumbnailRenderer` | Simultaneous thumbnail generations (read + render), with a wait queue. Integer ≥ 1. |
295
325
  | `attachmentThumbnailCacheBytes` | handlers | 8 MiB | `attachmentThumbnailRenderer` | Total size of the thumbnail cache. `0` disables caching. Integer ≥ 0. |
@@ -302,7 +332,10 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
302
332
  - **The six numeric settings are validated once, at creation.** Only an omitted key (`undefined`) takes the default. Any other value that is not a safe integer at or above the minimum — `null`, `NaN`, `1.5`, `Infinity`, `-1` or the string `"2"` — throws a `RangeError` that names the public key, for example `[daily-report] attachmentThumbnailCacheBytes must be an integer >= 0; got -1` or `[daily-report] attachmentConcurrency must be an integer >= 1; got "2"`.
303
333
  `createDailyReportService` checks `attachmentMaxBytes`; the handler factory checks the other five (`createDailyReportServer` runs both). The wiring check runs first, so a numeric key given without its port throws the `TypeError`.
304
334
  - **Handler tuning is per process.** Rate buckets, gates and the cache live in the handler factory's closure and are not shared across workers; the effective container-wide limit is the configured value times the number of workers. `createDailyReportServer` forwards the five tuning keys explicitly.
305
- - **Heap estimate per process**: `attachmentConcurrency × attachmentMaxBytes` for originals, plus `attachmentThumbnailConcurrency × (attachmentMaxBytes + the renderer's decode memory)` for thumbnails, plus `attachmentThumbnailCacheBytes`. The package's sharp renderer decodes only a source whose predicted working set is at most `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_BUDGET_BYTES` (200,000,000 bytes, server entry), and the prediction is built to stay above the measured one: read that constant in your own estimate instead of restating it (see **The sharp renderer** below).
335
+ - **An original's body is handed over slice by slice.** A `GET` of an original sends copies of 256 KiB slices of the bytes, one per read of the host's writer (the body queues nothing ahead of the writer), and keeps its concurrency slot exactly as long as it references the original: the slot is released when the last slice has been handed over, when the client cancels the body, or when the writer has not asked for the next slice for 60 s.
336
+ That idle deadline restarts on every read, so it bounds the time to send one slice, not the transfer (a reader slower than about 35 kbit/s, or one that stopped reading, cannot keep a slot and the original); when it passes, the body ends with an error and the rest of the original is dropped. A `HEAD` of an original holds its slot only until its answer is built.
337
+ - **Heap estimate per process**: while transfers progress, originals hold at most `attachmentConcurrency × attachmentMaxBytes` plus the one slice each response has handed to the host's writer, and a response stalled past the idle deadline holds only that one slice (its slot and the original are released); one viewer holds at most ⌈`attachmentConcurrency` / 2⌉ of the slots, so with two slots or more a viewer who stops reading several downloads cannot take every slot from the others.
338
+ Thumbnails add `attachmentThumbnailConcurrency × (attachmentMaxBytes + the renderer's decode memory)`, plus `attachmentThumbnailCacheBytes`. The package's sharp renderer decodes only a source whose predicted working set is at most `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_BUDGET_BYTES` (200,000,000 bytes, server entry), and the prediction is built to stay above the measured one: read that constant in your own estimate instead of restating it (see **The sharp renderer** below).
306
339
 
307
340
  **Read port**
308
341
 
@@ -314,23 +347,41 @@ type DailyReportReadAttachment = (
314
347
  options: { maxBytes: number; head: boolean; principal?: string; signal: AbortSignal },
315
348
  ) => Promise<
316
349
  | { ok: true; bytes: Uint8Array<ArrayBuffer>; contentType?: string | null; size?: number }
317
- | { ok: false; reason: "not_found" | "denied" | "too_large" | "invalid_path" | "unavailable"; code?: string }
350
+ | { ok: false; reason: "not_found" | "denied" | "too_large" | "invalid_path" | "unavailable" | "busy" | "deadline"; code?: string }
318
351
  >
319
352
  ```
320
353
 
321
354
  - Never throw; return a typed failure. `filePath` stays on the server. With `head: true` read metadata only and declare the real `size` (the HEAD `Content-Length` must match GET).
355
+ - **Failure reasons** form one closed union (`DailyReportAttachmentFailure`), and each has the status the handlers answer with; `code` is the raw storage code for the log line and never decides a status. A reason outside the union — which a host outside the type check can return — is a port contract violation on either delivery: 500, logged at `error` as `reason=port_contract code=failure_reason`, never guessed into another status.
356
+
357
+ | Reason | When | Original | Thumbnail |
358
+ | --- | --- | --- | --- |
359
+ | `not_found` | The object does not exist | 404, and the object is marked missing | 404, and the object is marked missing |
360
+ | `denied` | The storage refuses the read the same way for every requester (a path outside its sandbox, a permission the service's own credentials lack) | 404 | 404 |
361
+ | `invalid_path` | A scheme or a path the port does not accept | 404 | 404 |
362
+ | `too_large` | The object is over `maxBytes`; only the object's size may decide it, never a transient refusal | 413 | 404, cached only when the row's recorded size backs it (below) |
363
+ | `unavailable` | The storage cannot be reached, a read ended early, or the package's `signal` aborted the read | 502 | 502 |
364
+ | `busy` | The storage refuses for now (a load limit, such as a per-connection stream limit that refuses at once) | 503 with `Retry-After: 5` | 503 with `Retry-After: 5` |
365
+ | `deadline` | A storage call passed the port's own deadline (a per-call deadline, a stalled stream) | 504 | 504 |
366
+
367
+ `busy`, `deadline` and `unavailable` are transient: a thumbnail generation concludes nothing from them, caches nothing and marks nothing missing. The requests that joined that generation receive the same failure, and the next request reads again.
322
368
  - **`principal` is for attribution and audit only: the read result must not depend on it.** The SQL predicate (`getAttachmentForUser`) is the only authorization boundary; the port reads the `filePath` of a row that has already been authorized. Thumbnail generation relies on this: requests waiting for the same content share one read, made with the first requester's `principal`, and the outcome is cached for every authorized viewer of that attachment.
323
369
  So a host whose storage enforces per-principal ACLs must not inject `attachmentThumbnailRenderer` (one requester's read and its cached outcome would reach authorized viewers outside that ACL), and `denied` is only for a refusal that is the same for every requester (a path outside the sandbox, a permission the service's own credentials lack).
324
370
  - **The `bytes` of `ok: true` are the complete object, at most `maxBytes` long.** A read that ends early must fail (`unavailable`), and an object over `maxBytes` must fail as `too_large` without being read. Because an object can change between its size check and its read, the server checks what a read returns:
325
371
  - bytes over `maxBytes` are a port contract violation on either delivery: 500, logged at `error` as `reason=port_contract code=over_max_bytes`, and nothing is sent, rendered, cached or recorded;
326
- - a thumbnail generation compares what it observed with the size the attachment row declares (`fileSize`, which the content identity names): the length it read, or for a `too_large` only that the object is over `maxBytes`. A row without a size has nothing to compare and is **verified**. When they differ — a truncated read, an object replaced under the same row, or a `too_large` for a row whose known size is within the limit (every eligible row's is) — the outcome is **unverified**:
372
+ - a thumbnail generation compares what it observed with the size the attachment row declares (`fileSize`, which the content identity names): the length it read, or for a `too_large` only that the object is over `maxBytes`.
373
+ A read of a row without a size has nothing to compare, so the bytes read are the answer and the outcome is **verified**; a `too_large` is verified only when the row records a size over `maxBytes`. When they differ — a truncated read, an object replaced under the same row, or a `too_large` for a row whose known size is within the limit (every eligible row's is) — the outcome is **unverified**:
327
374
  that request is still answered (a thumbnail as 200 with `Cache-Control: no-store` and no `ETag`, a failure as the 404), nothing is cached, the response log line ends with `identity=unverified`, and `size_mismatch expected=<n> got=<m>` (`got=><maxBytes>` for a `too_large`) is logged at `warn`. A truncated read that the renderer rejects therefore never becomes a cached 404 for every viewer, and a browser never keeps a preview of the wrong bytes through 304s.
375
+ - a `too_large` for a row without a recorded size has nothing to back it (**unrecorded**: a host that mistakes a transient refusal for `too_large` looks the same): that request is answered with the 404 (`Cache-Control: no-store`, no validator), nothing is cached, the response line ends with `size=unrecorded` (at `info`; no `size_mismatch`, since nothing was mismatched), and the next view asks the port again.
376
+ A view of an oversized file without a recorded size therefore costs one port call (a port that checks the size first answers it from metadata), and no port classification can leave a 404 in the cache for every viewer.
328
377
  - **`signal` aborts when the package no longer needs the result.** An original download passes the request's own `request.signal`. A thumbnail generation passes a signal that aborts when every request waiting for that content has gone (one of them leaving is not enough) or when the read stage's deadline passes (`readMs`, see the time budget below).
329
- - Forward it to your storage calls, combined with your own per-call deadline (for example `AbortSignal.any([options.signal, AbortSignal.timeout(ms)])`). An original download has no package deadline: its signal never aborts while the request is alive, so without your own deadline one hung call holds the handler indefinitely.
378
+ - Forward it to your storage calls, combined with your own per-call deadline (for example `AbortSignal.any([options.signal, AbortSignal.timeout(ms)])`). An original download has no package deadline: its signal never aborts while the request is alive, so without your own deadline one hung call holds the handler indefinitely. A call that only your own deadline stopped is `deadline` (the example below tells the two apart by the deadline's own signal).
330
379
  - Once aborted, settle promptly with a typed failure (normally `unavailable`) instead of throwing. A failure that settles after the abort is the abort's doing, not a verdict about the object: report it as `unavailable`, never `not_found` (`not_found` is a verdict that the object is gone, which the package records as a missing object).
331
380
  - A read that has already been aborted when it settles is discarded whatever its kind, for an original download and a thumbnail generation alike: 503 (`reason=aborted`) and no missing- or present-object record, so an aborted `unavailable` is never logged as a storage outage (502) and a racing `not_found` never marks the object missing.
332
381
  The original download logs `503 attachment=<id> viewer=<id> reason=aborted`; the thumbnail generation also caches nothing and does not render. No disconnected client receives either 503. An original request that is already aborted when it arrives gets the same 503 without reading.
333
- - A thumbnail read still running at its deadline is answered with 502 (`reason=read_timeout`) without waiting for it; nothing is cached and the object is not marked missing. The generation slot stays held until the port actually settles (releasing it earlier would start the next read while this one still holds the original), and a settlement after the deadline is logged as `read_overrun ms=<elapsed>`.
382
+ - A thumbnail read still running at its deadline (`readMs`) is answered with 504 (`reason=read_timeout`) without waiting for it; nothing is cached and the object is not marked missing.
383
+ A storage read past a deadline is 504 whichever deadline passed first — the package's read stage or the port's own (`deadline`) — so the client sees the same status either way and the log line's reason names the deadline.
384
+ The generation slot stays held until the port actually settles (releasing it earlier would start the next read while this one still holds the original), and a settlement after the deadline is logged as `read_overrun ms=<elapsed>`.
334
385
  A read that has still not settled at twice its budget is logged once, at that moment, as `read_stuck ms=<elapsed>`: such a port keeps its slot for the life of the worker, and once every slot is held every thumbnail request waits `queueWaitMs` and answers 503. A port that honours the signal settles early and hands the slot to the next queued generation. Code that calls a `DailyReportReadAttachment` directly (tests, for example) must pass `signal`.
335
386
 
336
387
  Example — a read port over a host object store (the file is type-checked by `pnpm run typecheck:examples` and not shipped):
@@ -341,9 +392,10 @@ Example — a read port over a host object store (the file is type-checked by `p
341
392
  * ホストのオブジェクトストアの上に組む読み取りポートの例 (同梱しない。`pnpm run typecheck:examples` で型検査する)。
342
393
  *
343
394
  * It follows the port contract: typed failures instead of exceptions, the package's `signal` combined with a per-call deadline,
344
- * the real size declared on HEAD, and `unavailable` (never `not_found`) for a failure that settles after an abort.
395
+ * the real size declared on HEAD, `deadline` for a call that only its own deadline stopped, and `unavailable` (never `not_found`)
396
+ * for a failure that settles after an abort.
345
397
  * ポート契約に従う: 例外ではなく種別付きの失敗で返し、パッケージの `signal` を呼び出しごとの締め切りと合成し、HEAD では実サイズを申告し、
346
- * 中断の後に決着した失敗は `not_found` ではなく `unavailable` にする。
398
+ * 自前の締め切りだけが止めた呼び出しは `deadline`、中断の後に決着した失敗は `not_found` ではなく `unavailable` にする。
347
399
  */
348
400
  import type { DailyReportAttachmentError, DailyReportReadAttachment } from "@aiquants/daily-report/server"
349
401
 
@@ -369,7 +421,7 @@ export type ObjectStoreReadSettings = {
369
421
  resolveKey: (filePath: string) => string | null
370
422
  }
371
423
 
372
- /** 中断・締め切り・到達不能で決着した読み取り (実体についての判定ではない)。 */
424
+ /** 中断の後や到達不能で決着した読み取り (実体についての判定ではない)。 */
373
425
  const UNAVAILABLE: DailyReportAttachmentError = Object.freeze({ ok: false, reason: "unavailable" })
374
426
 
375
427
  /**
@@ -382,13 +434,16 @@ const UNAVAILABLE: DailyReportAttachmentError = Object.freeze({ ok: false, reaso
382
434
  */
383
435
  export const createObjectStoreReadAttachment = (store: ObjectStore, settings: ObjectStoreReadSettings): DailyReportReadAttachment => {
384
436
  /**
385
- * Combines the package's signal with the deadline of one storage call.
386
- * パッケージのシグナルと、ストレージ呼び出し 1 回の締め切りを合成する処理。
437
+ * Starts the deadline of one storage call and combines it with the package's signal.
438
+ * ストレージ呼び出し 1 回の締め切りを始め、パッケージのシグナルと合成する処理。
387
439
  *
388
440
  * @param signal Signal from the package. パッケージから渡されたシグナル。
389
- * @returns A signal that aborts on either. どちらでも中断されるシグナル。
441
+ * @returns The call's own deadline, and the signal to pass to the call, which aborts on either. 呼び出し自身の締め切りと、どちらでも中断される呼び出しへ渡すシグナル。
390
442
  */
391
- const callSignal = (signal: AbortSignal): AbortSignal => AbortSignal.any([signal, AbortSignal.timeout(settings.callTimeoutMs)])
443
+ const startCall = (signal: AbortSignal): { deadline: AbortSignal; callSignal: AbortSignal } => {
444
+ const deadline = AbortSignal.timeout(settings.callTimeoutMs)
445
+ return { deadline, callSignal: AbortSignal.any([signal, deadline]) }
446
+ }
392
447
 
393
448
  /**
394
449
  * Reads an attachment (metadata only on HEAD) and settles with the bytes or a typed failure; never throws.
@@ -402,19 +457,22 @@ export const createObjectStoreReadAttachment = (store: ObjectStore, settings: Ob
402
457
  const key = settings.resolveKey(filePath)
403
458
  if (key === null) return { ok: false, reason: "invalid_path" }
404
459
  if (signal.aborted) return UNAVAILABLE
460
+ let call = startCall(signal)
405
461
  try {
406
- const stat = await store.stat(key, callSignal(signal))
462
+ const stat = await store.stat(key, call.callSignal)
407
463
  // 中断の後に届いた「無い」は打ち切りの結果であって実体の判定ではない (not_found はパッケージが実体消失として記録する)
408
464
  if (stat === null) return signal.aborted ? UNAVAILABLE : { ok: false, reason: "not_found" }
409
465
  if (stat.size > maxBytes) return { ok: false, reason: "too_large" }
410
466
  // HEAD の Content-Length は GET と一致しなければならないので、読まずに実サイズを申告する
411
467
  if (head) return { ok: true, bytes: new Uint8Array(0), contentType: stat.contentType, size: stat.size }
412
- const bytes = await store.read(key, callSignal(signal))
468
+ call = startCall(signal)
469
+ const bytes = await store.read(key, call.callSignal)
413
470
  // メタデータを読んだ後に実体が伸びていることがある
414
471
  if (bytes.byteLength > maxBytes) return { ok: false, reason: "too_large" }
415
472
  return { ok: true, bytes, contentType: stat.contentType, size: bytes.byteLength }
416
473
  } catch (error) {
417
- return { ok: false, reason: "unavailable", code: error instanceof Error ? error.name : undefined }
474
+ // パッケージの中断の後の失敗は打ち切りの結果。自前の締め切りだけが過ぎた呼び出しは、締め切りの失敗 (504) として区別する
475
+ return { ok: false, reason: !signal.aborted && call.deadline.aborted ? "deadline" : "unavailable", code: error instanceof Error ? error.name : undefined }
418
476
  }
419
477
  }
420
478
  return readAttachment
@@ -534,7 +592,7 @@ Example — the wiring (the ports are service keys; the tuning keys stay in the
534
592
  * 添付のどのキーも有効になる。結果の `attachment.loader` はホスト自身のバイト列配信ルート (`{apiBasePath}/attachment/{token}`) に
535
593
  * マウントし、サムネイルも同じルートを使う。
536
594
  */
537
- import { createDailyReportServer, type DailyReportIdCodec, type DailyReportServerConfig } from "@aiquants/daily-report/server"
595
+ import { createDailyReportServer, type DailyReportIdCodec, type DailyReportServer, type DailyReportServerConfig } from "@aiquants/daily-report/server"
538
596
  import { createObjectStoreReadAttachment, type ObjectStore, type ObjectStoreReadSettings } from "./read-attachment-port"
539
597
  import { thumbnailRenderer } from "./sharp-thumbnail-renderer"
540
598
 
@@ -556,10 +614,10 @@ export type AttachmentWiring = {
556
614
  * 添付配信とサムネイルを有効にした日報サーバーを作る処理。
557
615
  *
558
616
  * @param wiring Base configuration and the attachment dependencies. 基本設定と添付の依存。
559
- * @returns The server; mount its `attachment.loader` on the attachment route. サーバー (`attachment.loader` を添付のルートへマウントする)。
617
+ * @returns The server (`DailyReportServer`); mount its `attachment.loader` on the attachment route. サーバー (`DailyReportServer`。`attachment.loader` を添付のルートへマウントする)。
560
618
  * @throws {RangeError} When a numeric attachment setting in `base` is not an integer in range. `base` の添付の数値設定が範囲内の整数でないとき。
561
619
  */
562
- export const createDailyReportServerWithAttachments = ({ base, idCodec, storage }: AttachmentWiring): ReturnType<typeof createDailyReportServer> =>
620
+ export const createDailyReportServerWithAttachments = ({ base, idCodec, storage }: AttachmentWiring): DailyReportServer =>
563
621
  createDailyReportServer({
564
622
  ...base,
565
623
  attachmentIdCodec: idCodec,
@@ -575,7 +633,7 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
575
633
  **Thumbnail endpoint (`?thumbnail=tile`)**
576
634
 
577
635
  - **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.
578
- - **Order**: request isolation (403 for a request from another origin's page, a navigation included, [Request isolation](#request-isolation)) → authenticate → query (400) → method → ports → token → viewer → renderer port → rate admission (below; an empty bucket answers 429 without resolving visibility or running the authorization query)
636
+ - **Order**: request isolation (a React Router single-fetch data request, `<token>.data`, is the 404 before it; 403 for a request from another origin's page, a navigation included; [Request isolation](#request-isolation)) → authenticate → query (400) → method → ports → token → viewer → renderer port → rate admission (below; an empty bucket answers 429 without resolving visibility or running the authorization query)
579
637
  → 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.
580
638
  - **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`:
581
639
  - 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.
@@ -584,7 +642,7 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
584
642
  After authorization, a matching ETag answers 304 and takes nothing more (the revalidation bucket has paid for its query). Any other answer — not visible, not eligible or an ETag that does not match — spends a generation token at that point under the generation bucket's rules, or is answered 429 (`reason=rate_limit`) when none is left.
585
643
  Invisible attachments and visible ones with another ETag pay alike, so that 429 does not reveal whether the attachment is visible; only a visible attachment's right ETag answers 304.
586
644
  - Bounds per user and process, by construction: generations and `not_visible` answers at most L from a full bucket, then L per minute; authorization queries at most 11 L in flight at once (L requests holding a generation token, 10 L holding a revalidation token), and from full buckets at one instant, or per minute of refill, at most 10 L answered right after authorization plus 10 L revalidations (2,400 at the default 120).
587
- - First views and revisits never refuse each other: a revisit waiting for authorization holds no generation token, so it cannot make a concurrent first view answer 429, and revisiting thumbnails the browser already holds is a 304 per tile while the revalidation bucket has tokens, also right after new tiles emptied the generation bucket. A run of refusals writes one log line per bucket, not one per request (see **Log levels** below).
645
+ - First views and revisits never refuse each other: a revisit waiting for authorization holds no generation token, so it cannot make a concurrent first view answer 429, and revisiting thumbnails the browser already holds is a 304 per tile while the revalidation bucket has tokens, also right after new tiles emptied the generation bucket. Refusals write at most two log lines per viewer, bucket and 60-s window, not one per request (**429 lines** below).
588
646
  - **Identity**: `JSON.stringify([rendererId, box.maxWidth, box.maxHeight, attachmentId, filePath, fileType, fileSize])`, with the renderer `id`, the box of the requested variant (not its name: the box decides the output bytes) and the current file path, type and size, so an attachment rewritten under the same id, a new rendering pipeline or a different box is regenerated. The cache, generation joining and the ETag all use it; the ETag is the strong SHA-256 of the key, so the path never leaves the server.
589
647
  A generation's outcome carries this identity only when what it read matched the row's size (**verified**; see **Read port** above); an **unverified** outcome gets no validator and is never cached.
590
648
  - **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.
@@ -593,20 +651,21 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
593
651
  **One decode per content key at a time**: a render that its generation no longer waits for — past its deadline, or after every requester left — keeps its slot until it settles, and while it runs, a new generation of the same identity does not queue for a slot: it first waits for that render to settle and its result to be recorded or dropped, then reads the cache, and only then queues at the gate.
594
652
  The wait for the still-running render and the wait for a slot share one `queueWaitMs` budget, and the generation's abort signal ends both; when the budget runs out first, the request is answered 503 `reason=queue` without a second read or decode. A generation that receives its slot also reads the cache again before it reads the original, so a request that waited at the gate behind a still-running render of the same content is answered from that render's result.
595
653
  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.
596
- - **Time budget**: one budget bounds a cache miss from the generation gate onward — the wait for a slot, the read, the render and the transfer — so a request that has reached the gate leaves the server within the sum of those stages, and the client waits exactly that sum before counting a load as failed. Each port stage runs under its own deadline and answers 502 at the deadline even when the port ignores the signal; a deadline that passes after every requester has gone ends as 503 `aborted` instead, since nobody receives it.
597
- Two things are outside the budget. Authentication and authorization run before the gate and are bounded only by the host's own timeouts; their time is taken out of the client's sum as well. And the budget cannot end a port that ignores its signal: after the 502 such a port keeps its generation slot, and the server logs `read_stuck` / `render_stuck` once when it has not settled at twice its stage budget.
654
+ - **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 at that deadline even when the port ignores the signal (the read with 504 `read_timeout`, the render with 502 `render_timeout`); a deadline that passes after every requester has gone ends as 503 `aborted` instead, since nobody receives it.
655
+ 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 deadline's answer 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.
598
656
 
599
657
  | Stage | Budget | At the deadline |
600
658
  | --- | --- | --- |
601
659
  | `queueWaitMs` — waiting for a generation slot | 30 s | 503 `reason=queue` (`Retry-After: 5`) |
602
- | `readMs` — reading the original | 30 s | 502 `reason=read_timeout` |
660
+ | `readMs` — reading the original | 30 s | 504 `reason=read_timeout` |
603
661
  | `renderMs` — decoding, resizing, encoding | 10 s | 502 `reason=render_timeout` |
604
662
  | `transferMarginMs` — sending the response (at most 1 MiB) | 5 s | (the client's margin) |
605
663
  | Client load timeout | 75 s (the sum) | the load counts as one failure |
606
664
 
607
665
  - **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.
608
- It stores only verified outcomes: successful previews and the content-determined failures (signature mismatch, the port's `unsupported`, and the read port's `too_large` for a row whose size is unknown — on a row with a known size, which eligibility keeps within `attachmentMaxBytes`, a `too_large` is always unverified), including those a render delivers after its generation stopped waiting for it, past its deadline or after every requester left (see **Thumbnail renderer port** above).
609
- It never stores unverified outcomes, `failed`, the 502 of a timeout, the 503 of an abort, storage errors (including `not_found`), queue rejections or exceptions.
666
+ It stores only verified outcomes: successful previews and the content-determined failures (signature mismatch and the port's `unsupported`), 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).
667
+ A `too_large` conclusion is cached only when the row's recorded size is over the limit, which eligibility already answers with the not-eligible 404 before any read; so the endpoint never caches a `too_large`: on a row whose known size is within the limit it is unverified, and on a row without a recorded size it is unrecorded (answered with the 404 with `Cache-Control: no-store`, and read again on the next view; see **Read port** above).
668
+ It never stores unverified or unrecorded outcomes, `failed`, the answer of a stage deadline (504 `read_timeout`, 502 `render_timeout`), the 503 of an abort, storage errors and transient refusals (`not_found`, `unavailable`, `busy`, `deadline`), queue rejections or exceptions.
610
669
  - **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);
611
670
  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.
612
671
 
@@ -616,33 +675,34 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
616
675
  | 304 | `If-None-Match` matches (carries `Cache-Control`, `ETag`, `X-Content-Type-Options: nosniff`, `Cross-Origin-Resource-Policy: same-origin`, `X-Frame-Options: SAMEORIGIN`, `Set-Cookie`) |
617
676
  | 400 | A query that names no delivery (unknown or repeated variant such as `?thumbnail=1`, combined with `download`), or a malformed token |
618
677
  | 401 / 403 | Not authenticated / no internal user, or (403, before authentication, the query and the method) a request from another origin's page that the isolation refuses ([Request isolation](#request-isolation): it serves no thumbnail to another origin, not even to a navigation) |
619
- | 404 | Ports not injected, not visible, not eligible, signature mismatch, `unsupported`, `too_large`, `not_found`, `denied`, `invalid_path` — one identical body for all |
678
+ | 404 | Ports not injected, not visible, not eligible, signature mismatch, `unsupported`, `too_large`, `not_found`, `denied`, `invalid_path`, and (before authentication) a React Router single-fetch data request (`<token>.data`) — one identical body for all |
620
679
  | 405 | Any method other than GET (`Allow: GET`) |
621
680
  | 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`) |
622
- | 502 | Storage unreachable (`unavailable`), the port's `failed` (`render_failed`), or a stage deadline (`read_timeout`, `render_timeout`) |
623
- | 503 | Wait queue full or wait timed out (`queue`), or the generation abandoned by every waiting request (`aborted`; no one receives it) (`Retry-After: 5`) |
624
- | 500 | Unexpected exception, including one thrown by a port during generation (logged as `500 attachment=<id> viewer=<id> variant=thumbnail cache=miss reason=port_exception message=<msg>`, never cached), or a port contract violation (`port_contract`: a render result outside the contract, logged with `code=result` for a value that is not an object, `code=failure_reason` for a failure whose reason is neither `unsupported` nor `failed`, or the output check that failed, `code=` `content_type`, `bytes`, `empty`, `too_large`, `signature` or `dimensions`; or a read over `maxBytes`, `code=over_max_bytes`), or a host route that passes no `token` parameter (a route misconfiguration, below) |
681
+ | 502 | Storage unreachable or a read ended early (`unavailable`), the port's `failed` (`render_failed`), or the render stage's deadline (`render_timeout`) |
682
+ | 503 | Wait queue full or wait timed out (`queue`), the storage busy (`busy`), or the generation abandoned by every waiting request (`aborted`; no one receives it) (`Retry-After: 5`); the message is `Thumbnail temporarily unavailable` for every cause |
683
+ | 504 | A storage read past a deadline: the read port's own (`deadline`) or the read stage's `readMs` (`read_timeout`); never cached |
684
+ | 500 | Unexpected exception, including one thrown by a port during generation (logged as `500 attachment=<id> viewer=<id> variant=thumbnail cache=miss reason=port_exception message=<msg>`, never cached), or a port contract violation (`port_contract`: a render result outside the contract, logged with `code=result` for a value that is not an object, `code=failure_reason` for a failure whose reason is neither `unsupported` nor `failed`, and for a read failure whose reason is outside `DailyReportAttachmentFailure`, or the output check that failed, `code=` `content_type`, `bytes`, `empty`, `too_large`, `signature` or `dimensions`; or a read over `maxBytes`, `code=over_max_bytes`), or a host route that passes no `token` parameter (a route misconfiguration, below) |
625
685
 
626
686
  Error responses are JSON (`{ "error": { "message": "..." } }`) with `Cache-Control: no-store` (a 404 or 405 without freshness information may otherwise be cached heuristically, `Set-Cookie` included), carry `X-Content-Type-Options: nosniff`, `Cross-Origin-Resource-Policy: same-origin` and `X-Frame-Options: SAMEORIGIN` like every attachment response (**Same-origin loads only** above), and forward `Set-Cookie` (except the isolation's 403, which comes before authentication).
627
- Log lines carry `attachment=<id> viewer=<id> variant=thumbnail`, plus `cache=hit|miss|revalidated` once the cache stage is reached; the 200 and 404 lines of an unverified outcome end with `identity=unverified`.
687
+ Log lines carry `attachment=<id> viewer=<id> variant=thumbnail`, plus `cache=hit|miss|revalidated` once the cache stage is reached; the 200 and 404 lines of an unverified outcome end with `identity=unverified`, and the 404 line of an unrecorded `too_large` with `size=unrecorded`.
628
688
  Unexpected exceptions outside generation go to the loader's shared catch and are logged as `500 attachment=? viewer=? reason=unexpected message=<msg>` (no `variant`).
629
689
  A route that mounts `attachment.loader` without a `token` route parameter is the host's configuration error, on both deliveries: right after the port check the loader throws `[daily-report] params.token must be passed by the route that mounts attachment.loader; declare a "token" route parameter ({apiBasePath}/attachment/{token})`, which that catch logs and answers 500, without decoding an empty token or querying the database.
630
- Rejections up to the renderer-port check (401 / 400 / 405 / 404 for a missing port / the 403 of a viewer without an internal user) are not logged by the loader; the request isolation's 403 goes to the handlers' bounded refusal record instead (at most two lines a minute, [Request isolation](#request-isolation)).
690
+ Rejections up to the renderer-port check (401 / 400 / 405 / 404 for a missing port / the 403 of a viewer without an internal user) are not logged by the loader; the request isolation's 403 goes to the handlers' bounded refusal record instead (at most two lines per 60-s window, [Request isolation](#request-isolation)), and the isolation's 404 of a `.data` request is not logged.
631
691
  The package never writes the file path itself; a `port_exception` line includes the port's own error message verbatim, so keep paths out of your port's error messages.
632
692
 
633
693
  **Log levels** (both deliveries; the line formats are fixed):
634
694
 
635
695
  | Level | Outcomes |
636
696
  | --- | --- |
637
- | `info` | 200, 304, the 503 nobody receives (`reason=aborted`), 404 `not_eligible`, and the content-determined 404s (`signature`, `unsupported`, `too_large`; cached when verified) |
638
- | `warn` | 404 `not_visible` (a sign of token enumeration), the storage 404s (`not_found`, `denied`, `invalid_path`), 413 (`db_size`, and the port's `too_large` on an original), the first 429 of a bucket's run of refusals and the run's `rate_limit_suppressed` / `revalidation_rate_limit_suppressed` line, the busy 503s (`queue`, `concurrency`), every 502, port settlements after a deadline (`read_overrun`, `render_overrun`), ports still unsettled at twice their stage budget (`read_stuck`, `render_stuck`) and thumbnail reads that do not match the row's size (`size_mismatch`: another length, or a `too_large` on a row whose size is known) |
639
- | `error` | 500 (`port_exception`, `port_contract` including a read over `maxBytes` (`code=over_max_bytes`), the loader's `unexpected`, a route without the `token` parameter among them) and failed `markAttachmentMissing` / `markAttachmentPresent` writes |
697
+ | `info` | 200, 304, the 503 nobody receives (`reason=aborted`), 404 `not_eligible`, and the content-determined 404s (`signature`, `unsupported`, `too_large`; cached when verified, `too_large size=unrecorded` on a row without a recorded size) |
698
+ | `warn` | 404 `not_visible` (a sign of token enumeration), the storage 404s (`not_found`, `denied`, `invalid_path`), 413 (`db_size`, and the port's `too_large` on an original), the first 429 of a viewer's window per bucket and the window's `rate_limit_suppressed` / `revalidation_rate_limit_suppressed` line, the busy 503s (`queue`, `concurrency`, the storage's `busy`), every 502, the 504 of a storage read past a deadline (`deadline`, `read_timeout`), port settlements after a deadline (`read_overrun`, `render_overrun`), ports still unsettled at twice their stage budget (`read_stuck`, `render_stuck`) and thumbnail reads that do not match the row's size (`size_mismatch`: another length, or a `too_large` on a row whose size is known) |
699
+ | `error` | 500 (`port_exception`, `port_contract` including a read over `maxBytes` (`code=over_max_bytes`) and a read failure reason outside the union (`code=failure_reason`), the loader's `unexpected`, a route without the `token` parameter among them) and failed `markAttachmentMissing` / `markAttachmentPresent` writes |
640
700
 
641
701
  `error` is left to failures of the server itself, so an alert on `error` does not fire on user traffic or on upstream storage states.
642
702
 
643
- **429 lines**: each rate bucket of a user (the original's, and the thumbnail's generation and revalidation buckets) writes its `429 … reason=rate_limit` (`reason=revalidation_rate_limit`) line only for the first refusal after an admission.
644
- The later refusals of the run are counted, and the bucket's next admission writes one `rate_limit_suppressed count=<N> viewer=<id>` (`revalidation_rate_limit_suppressed …`) line (with `variant=thumbnail` on the thumbnail path), N being the refusals that had no line of their own; the 429 of a revalidation refused after authorization counts in the generation bucket's run.
645
- A client therefore cannot write log lines at its request rate.
703
+ **429 lines**: each rate bucket (the original's, and the thumbnail's generation and revalidation buckets) records its refusals in windows per viewer of `RATE_LIMIT_LOG_WINDOW_MS` (60 s, the rate window and the 429's `Retry-After`), kept apart from the buckets themselves.
704
+ A viewer's refusal with no open window writes its `429 … reason=rate_limit` (`reason=revalidation_rate_limit`) line at once and opens the window; later refusals in the window are only counted, and the window's own timer writes, when it counted any, one `rate_limit_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> viewer=<id>` (`revalidation_rate_limit_suppressed …`) line (with `variant=thumbnail` on the thumbnail path) and closes the window. The 429 of a revalidation refused after authorization counts in the generation bucket's window.
705
+ A viewer therefore writes at most two lines per bucket and window, whatever its request rate or the refill; the count is written at the window's end, also when the refusals stop, and a bucket the limiter evicts (it keeps 1,024 users) loses none of it.
646
706
 
647
707
  ### Transactions
648
708
 
@@ -699,7 +759,7 @@ The first load of a report that is not cached starts inside the hook's effect, w
699
759
 
700
760
  Mutation failures (save / publish / delete / comment) are surfaced through an error seam: inject `config.onError(info: DailyReportErrorInfo)` to route them into your own toast/notification system, or omit it to use the package's built-in `role="alert"` banner (auto-dismiss + manual close). Failures on a continuation that resolves after a no-reload user switch are suppressed. If you compose `DailyReportActionProvider` yourself instead of using `DailyReportPage`, wrap it in `DailyReportErrorProvider` (both are exported from `@aiquants/daily-report/client`) so `onError` / the banner work.
701
761
 
702
- The report id list is **not** part of the loader data: a module-resident NDJSON stream session (`GET {apiBasePath}/ids-stream`, resilient client with cursor resume + exponential backoff) supplies it.
762
+ The report id list is **not** part of the loader data: a module-resident NDJSON stream session (`GET {apiBasePath}/ids-stream`, the API route's ids stream; resilient client with cursor resume + exponential backoff) supplies it.
703
763
  The client splits the stream into lines as the chunks arrive, examining each decoded character once (a chunk of a 4,000-item line of about 300 KB does not re-read the part of the line already received), and reads a final line without a newline at the end of the stream; a line cut short there is not valid JSON, so it is skipped with one warning and the attempt resumes from its cursor, like any interrupted stream.
704
764
  `createDailyReportClientLoader` warms an existing session (`primeDailyReportIdsStreamSession`) and then bootstraps (`bootstrapDailyReportIdsStreamSession`): when no session exists it is **created at loader time** so the stream fetch runs in parallel with hydration (the dominant cold-load optimization); when one exists, the obfuscated user id is reconciled (a different user destroys and recreates the session before render).
705
765
  If your app overrides `config.apiBasePath`, pass the same value to `createDailyReportClientLoader({ apiBasePath })` — forgetting it costs one wrong-path request on the very first load, self-healed by the page-mount `ensureDailyReportIdsStreamSession`.
@@ -723,8 +783,10 @@ Each view (List and DetailList) is as tall as the space the host's layout leaves
723
783
  Every change is followed, a sub-pixel one included. A root without a box (not rendered, or detached) reads 0 in Chromium, whose first delivery reports 0 for it; an engine that follows the specification's 0 × 0 starting size reports nothing for such a root, and the value stays `null` until the root has a box. A view whose document has no window throws. The value reaches only `viewportSize`: the list column, the panel group and the scroll container write no height.
724
784
  - **G-symmetric frame**: the rows' gutter G is inside the view's box, so the last aligned surface — a List or DetailList row aligned to the bottom, or the List's side pane — sits G = 8 px above the page's bottom edge (where a footer that follows the page starts), the distance from the view-mode toolbar to the first surface at the top.
725
785
  `VirtualScroll` snaps the layer that moves the rows to whole device pixels away from the edge a row is aligned to (the start at position 0 and after an alignment to the top, the end at the maximum position and after an alignment to the bottom; 3.9.0, "Device-pixel snapping" in its README), so an aligned row's surface never comes closer than G to that edge: what the snap adds is less than one device pixel.
726
- It adds nothing — the surface sits exactly G from the edge — when the view's height and the row slots are whole numbers of device pixels: the slots are multiples of 4 px (**Row slots on the lattice**), and a host gives the view a height on the 4 px lattice by sizing its own bars on that lattice (for example a window height that is a multiple of 4 under a header and a footer whose heights are rounded up to 4 px).
727
- That unit is a host contract, exported as `DAILY_REPORT_LAYOUT_LATTICE_PX` (4 CSS px, client entry): it is the package's own lattice unit, not a copy, so a host whose bars are sized from it — or whose own unit is checked to be a multiple of it — stays on the package's lattice.
786
+ It adds nothing — the surface sits exactly G from the edge — when the view's edge and the row slots are whole numbers of device pixels. The slots are multiples of the 4 px lattice (**Row slots on the lattice**), a whole number of device pixels at the ratios that are multiples of 1/4 (1, 1.25, 1.5, 1.75, 2, 3): at the integer ratios a view's end on a whole CSS pixel is on a device pixel too, so the bottom-aligned surface sits exactly G there, and at 1.25, 1.5 and 1.75 it does when the view's end lies on the lattice.
787
+ Anywhere else — a view's end off the lattice at those ratios, or browser zoom such as 0.9, 1.1 or 1.33 — the snap keeps the surface at least G and less than G + 1 device pixel from the end (measured in Chromium: 8.2 px at 1.25 and 8.333 px at 1.5 for a view's end 1 and 3 px off the lattice, 8.091 px at 1.1, 8.052–8.173 px at 1.33, 8.778 px at 0.9).
788
+ The host's part is its bars, not the window: it sizes the bars around the views — a header and a footer — on the lattice unit, exported as `DAILY_REPORT_LAYOUT_LATTICE_PX` (4 CSS px, client entry; the package's own unit, not a copy), so the views' top edge lies on the lattice. It cannot size the window, so the remainder of the window height modulo the unit stays inside the view, whose end lies on the lattice only when the window height is a multiple of the unit.
789
+ A host that computes its styles in script builds its lengths from the value (the `4px` of `height: calc-size(auto, round(up, size, 4px))` for a bar whose content decides its height); a Tailwind host, whose scanner reads class names only as they are written, writes such a class as a literal and ties it to `DAILY_REPORT_LAYOUT_LATTICE_PX` with a unit test that reads both.
728
790
  - **Empty list**: an empty view shows exactly one message, `VirtualScroll`'s own empty state with the engine label `labels.noItems` (en "No items", ja 「項目がありません」), which `VirtualScroll` draws over the top of its content, out of the flow, so an empty view keeps the same box. The views render no message of their own; they only theme the engine's (`VIEW_EMPTY_TEXT_THEME_CLASS_NAME` on `VirtualScroll`'s root: 16 px above and below, slate-500 in light and slate-400 in dark: 4.55:1 and 7.66:1 against the page, where the engine's default grey reaches only 4.17:1 in dark).
729
791
 
730
792
  ### Keyboard, focus and selection (List / DetailList)
@@ -774,9 +836,13 @@ The keys are delegated to each view's **list**: the element that holds the view'
774
836
  A request stays pending only on the paths that send focus to a row that may lie outside the rendered window: the focus owner's report leaving the list and the selected report deleted from the side pane or the mobile overlay (the table below), and the List's return to the card whose overlay closed (its scroll runs in an effect after the commit). Such a request settles when its row registers, however long that takes;
775
837
  the next key, a `pointerdown`, `wheel` or `touchstart` in the view, and the destination leaving the list drop it, and a dropped request never settles later.
776
838
  Focus is only ever taken from nowhere — no element, `body`, or an element inside an `inert` subtree such as the mobile overlay while it slides out (such an element cannot keep focus; `isFocusNowhere` in `src/client/keyboard/dom-node.ts`) — or from inside the view; focus that has meanwhile moved to the side pane, the open overlay or a host element stays there and the request is dropped.
777
- - **A key move writes before focus and commits once.** The row cursor owns the `tabindex` of every registered focus target (0 on the Tab-stop row, −1 on every other row; the row components render none): it writes the attribute when a target registers and when the row's Tab stop changes, and never rewrites a value the element already has.
778
- A move first makes the destination current and the Tab stop while focus is still on the origin (the cursor writes the destination's `tabindex="0"` then, without a React commit); then focus moves; then, once focus has left the origin, the selection and the Tab stop settle on the destination (the cursor writes the origin's `-1`). The rows' states, the host's selection and the scroll commit in one synchronous React commit.
779
- No step writes the `tabindex` of the element that holds focus (Chromium recalculates the document's style and layout on the spot when that happens, to check that the element can still take focus), and none reads layout after a write, so every style recalculation a key forces comes from its one `focus()` call (which recomputes style to check that the element can take focus).
839
+ - **A key move writes at the focus hand-over and commits once.** The row cursor owns the `tabindex` of every registered focus target (0 on the Tab-stop row, −1 on every other row; the row components render none): it writes the attribute when a target registers and when the row's Tab stop changes, and never rewrites a value the element already has.
840
+ A move first makes the destination current and the Tab stop while focus is still on the origin, without a React commit and without writing its `tabindex` yet. The cursor writes the destination's `tabindex="0"` at the hand-over of its focus move: inside the origin's `focusout`, while no element holds focus (before the move when no element held focus, right after it when the move hands nothing over, and before any later change of the rows' states when no focus move comes first).
841
+ Then focus arrives on the destination, and the selection and the Tab stop settle on it (the cursor writes the origin's `-1`). The rows' states, the host's selection and the scroll commit in one synchronous React commit.
842
+ Chromium's `focus()` recalculates the document's style twice whenever focus moves between two elements — right after the origin loses focus and right after the destination gains it — and once more at its entry when a style change is still pending there (a `tabindex` written before the call is one whenever the page's CSS has a `[tabindex]` selector). The hand-over write rides on the first of the two.
843
+ No step writes the `tabindex` of the element that holds focus (Chromium recalculates the document's style and layout on the spot when that happens, to check that the element can still take focus), and none reads layout after a write,
844
+ so a key whose destination is rendered forces exactly the two recalculations of its one `focus()` call and no layout (a destination that the key's own commit renders also has the entry recalculation of the rows that commit inserted): in Chromium 148 with a host `[tabindex]` selector, 8 key moves force 16 recalculations (24 when the destination's `tabindex` is written before the call; 16 either way without such a selector).
845
+ The app's keyboard harness counts the style and layout work done inside keydown tasks and, in a run of its own, records the JavaScript stack of each. The entries without a stack (at 4× CPU from row 200,000 in run `2026-10-05T16-45-08-477Z`: two recalculations and two layouts, DetailList 16.00 ms and 23.00 ms, List 3.76 ms and 2.42 ms) are the frame's own rendering update, which Chromium ran in the same task as the keydown; the keydown handler forces none of them, and each laid out from virtualscroll's items boundary (`.aqvs-items-boundary`), none from the document root.
780
846
  When the destination is not rendered yet, focus is still on the origin as the commit starts: the destination renders in that commit, registers and takes focus, and the commit's layout effect then settles the origin, which keeps the Tab stop until then.
781
847
  The commit renders the destination's and the origin's row frames, the view and the host component that owns the controlled selection (the List's `selectedReportHubId`, the DetailList's `selectedItemId`), and `VirtualScroll` only when the key scrolled. Report rows, card bodies and the row renderer do not render, and no context value changes per key.
782
848
  A row that the commit brings into the rendered window outside the rows the key shows — the rows of the viewport after the scroll, plus one on each side, computed from the same row heights `VirtualScroll` uses (overscan rows, in other words) — mounts its row element alone, with an empty surface that fills its slot and `aria-busy="true"` (a DetailList row without its name and description references, whose elements do not exist yet), and renders its body in a transition right after the commit; a held row that the next key's shown rows reach renders its body inside that key's commit.
@@ -797,9 +863,14 @@ The keys are delegated to each view's **list**: the element that holds the view'
797
863
 
798
864
  Wheel and touch scrolling change nothing. While a row owns focus and its focus target is replaced (the List frame ⇄ card while loading, a control that disappears, the DetailList body turning into an error, loading or edit form and back), remounted, or scrolled back into the rendered window while focus is nowhere (`body`), the row's registered target takes focus again; focus left on a List row frame moves to the card's primary button when the card arrives.
799
865
  Focus is only ever taken from nowhere: focus that the user put elsewhere is never moved.
800
- - **Keyboard focus is revealed**: when an element inside a row receives keyboard focus (`:focus-visible`), the view scrolls the row frame into the visible area by the nearer edge — or, for a row taller than the viewport, the focused element with 8 px around it. Focus from a pointer never scrolls.
801
- - **Keyboard-focus attribute**: while keyboard focus is inside a view, the view root carries `data-daily-report-keyboard-focus` (the List's `[data-testid="daily-report-root"]`, the DetailList's scroll container). Every `focusin` and `keydown` inside the view sets it from the focused element (`:focus-visible`; a key press can turn pointer focus into keyboard focus without moving it), and `pointerdown` inside the view or focus leaving it removes it.
866
+ - **Keyboard focus**: one predicate decides it for the whole view (`isKeyboardFocused` in `src/client/keyboard/keyboard-focus.ts`): the focused element matches `:focus-visible` and the last input of its document was not a pointer press. `:focus-visible` alone is not enough, because browsers match it on a text field (`input`, `textarea`, an editing host) that a click or a tap focused.
867
+ The last input is recorded per document (the element's `ownerDocument`, so a view in an iframe or a pop-out window reads its own), while a view of that document is mounted (`observeInputModality` in `src/client/keyboard/input-modality.ts`, reference-counted across the views):
868
+ a capture-phase `pointerdown` records the pointer, and a capture-phase `keydown` records the keyboard unless the key is a modifier alone (`Shift`, `Control`, `Alt`, `Meta` and the other modifier keys of UI Events), so a modifier held during a pointer gesture does not turn it into keyboard input. A `Tab` pressed outside the view counts too.
869
+ So a click or a tap into any element of a row, a text field included, is never keyboard focus; a key press inside the view (other than a modifier alone) turns the focus into keyboard focus without moving it. The row and card focus outlines follow this predicate through the attribute below, not `:focus-visible` alone, so they never show while the view treats the focus as a pointer's (also where Chromium matches `:focus-visible` after a bare `Shift` or `CapsLock` that follows a pointer press).
870
+ - **Keyboard focus is revealed**: when an element inside a row receives keyboard focus, the view scrolls the row frame into the visible area by the nearer edge — or, for a row taller than the viewport, the focused element with 8 px around it. Focus from a pointer never scrolls.
871
+ - **Keyboard-focus attribute**: while keyboard focus is inside a view, the view root carries `data-daily-report-keyboard-focus` (the List's `[data-testid="daily-report-root"]`, the DetailList's scroll container). Every `focusin` and `keydown` inside the view sets it from the focused element by the same predicate (a key press can turn pointer focus into keyboard focus without moving it), and `pointerdown` inside the view or focus leaving it removes it; a click into a text field of a row therefore leaves it off, and the tap-scroll circle stays pressable.
802
872
  It is a plain DOM attribute toggled with `toggleAttribute`, so arrow keys moving between rows never rewrite it and nothing re-renders. The floating tap-scroll circle hides while it is present, because the circle is drawn over the rows.
873
+ The keyboard-focus outlines of the row frames and the List cards paint only under it (**Selection and focus appearance**), so an outline is drawn only while the circle is hidden and the two never show together; the package's controls (buttons, links, tabs, switches, fields) keep their plain `:focus-visible` outlines.
803
874
  - **Pointer selection**: in the List the card's primary `<button>` selects on `click` (a pointer click, Enter or Space; the click that ends a drag is swallowed by the scroll pane), and ★ / 既読 run only their own action. In the DetailList a click on a row selects it without scrolling, except clicks on controls inside the row (`button`, `a[href]`, `input`, `textarea`, `select`, `label`, `[role="button"]`, `contenteditable`). Without `onSelectItem` the DetailList handles neither clicks nor keys.
804
875
  - **Host selections and list changes**: when the host changes the selection, the selection ring follows it and the DetailList scrolls that row to the top (when the selected report is not in the list yet, as soon as it arrives); focus does not move.
805
876
  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.
@@ -825,13 +896,15 @@ The keys are delegated to each view's **list**: the element that holds the view'
825
896
  The mobile overlay shows the report it opened, so its pane carries the same `data-displayed-report-id` and no `aria-busy`.
826
897
  The article tab scrolls to its end (where the comments are) only after the viewer's own comment has been posted; a comment that arrives over SSE, from another user or another tab, never moves what the viewer is reading.
827
898
  - **Screen readers**: each list references `labels.listKeyboardHelp` (visually hidden) through `aria-describedby` — the DetailList only when it handles keys (with `onSelectItem`); there is no live region, announcements come from focus.
828
- The List card's primary button is named by its content ("YYYY-MM-DD creator"), described first by the row state (`labels.rowState`: read or unread, and starred), then by the markers the card paints and then by its preview, and carries `aria-current="true"` when selected. A DetailList row is named by its report heading (date, author and subject; `aria-labelledby`) and described by the row state and the markers its header paints.
899
+ The List card's primary button is named by its content ("YYYY-MM-DD creator"), described first by its position in the list (`labels.listRowPosition`, a visually hidden text: en "3 of 40", ja 「全 40 件中 3 件目」; the row frame carries `aria-posinset` / `aria-setsize`, which the focused button cannot carry), then by the row state (`labels.rowState`: read or unread, and starred), then by the markers the card paints and then by its preview, and carries `aria-current="true"` when selected.
900
+ The position text reads the row's place from the same context as the row frame, so an insert or a delete before the rendered window re-renders the frames and these texts, never a card's body. A DetailList row is named by its report heading (date, author and subject; `aria-labelledby`) and described by the row state and the markers its header paints.
829
901
  The markers are the attachment marker (`<labels.attachments>: <count>`) and the source badge (its full `name` when the configuration gives one, otherwise its label); a description references only the markers that are painted. A row without content (loading, failed, missing, editing) is named by its ISO business date, with `aria-busy="true"` while loading. At most one element per view carries `aria-current`. ★, 既読, 編集 and 削除 carry `aria-label` equal to their `title` and no `aria-pressed`.
830
902
  - **Document structure**: every report has a heading at `config.headingLevel` (an integer from 2 to 5, default 3; pick the level that continues the host page's outline). A DetailList card starts with a visually hidden heading `<date> <author> <subject>`, which names the row.
831
903
  The side pane and the mobile overlay start with two label / value pairs: `labels.businessDate`, whose value is the report heading (the business date alone), and `labels.author` with the author's name; each label and each value is read once, and the source badge sits beside the pair, outside it. The overlay names its dialog by the heading and the author's value together (`<date> <author>`, the name of the List card).
832
904
  The sections inside a report — customer, subject, content, attendees and comments in the DetailList card; content and comments in the side pane; attachments in both — are headings one level lower.
833
905
  Every label and its value is a pair of its own (`dt` / `dd` inside a `dl`, one `MetadataField` primitive), and no value repeats its label as text (no `区分: 日報`): the DetailList metadata column (author, created at, updated by, updated at, business date, and the visit time, the category and the creation category, each when present) and the side pane's subject, customer and visit time; both attachment lists are named by their section heading.
834
- The DetailList header's chips are one `dl` too: ID, business date, author, visit time, updated at, and the category and the creation category when present, each label once, with the dates as `<time datetime>`; the header's markers and buttons sit outside the list.
906
+ The DetailList header's chips are one `dl` too: ID, business date, author, visit time, updated at, and the category and the creation category when present, each label once; the header's markers and buttons sit outside the list.
907
+ Every ISO date of a DetailList card — the header chips' business date and updated at, the metadata column's created at, updated at and business date — is a `<time datetime>` carrying that date, through one component (`DateText` in `src/client/components/detail-list/date-text.tsx`), so a row never exposes the same date with two semantics; a missing date's `-` stays text.
835
908
  Both tab lists are named by what they switch (`aria-label`): the view-mode tabs by `labels.viewTabList`, the side pane's tabs by `labels.reportTabList` (the report heading names only the business date, which reports of the same day share).
836
909
  - **Editing**: on the viewer's own reports, an Edit button (`labels.edit`, `data-detail-edit-button`, a pencil icon) stands before Delete in the DetailList header and in the side pane's action row. It is the keyboard way into the editor; a double click is the pointer shortcut. Entering the editor this way moves focus to its title field, and Cancel or a successful Publish returns focus to the Edit button (when the button is not rendered, the DetailList row takes focus itself and the side pane focuses its report heading).
837
910
  A draft that opens in the editor by itself does not move focus. The editor is a real `<form>` whose submission is cancelled: Save, Publish and Cancel are `type="button"`, and an implicit submission (Enter in the title) or a `requestSubmit()` neither navigates nor saves nor publishes, so what was typed stays. As an input region the form keeps the list keys out (above).
@@ -857,16 +930,19 @@ Selection and keyboard focus are two separate channels, identical in both views,
857
930
  | State | Channel | Geometry | Light | Dark | Forced colours |
858
931
  | --- | --- | --- | --- | --- | --- |
859
932
  | Selected | box-shadow ring; the surface paints no shadow outside it | 2 px, 0–2 px outside the surface | blue-500 | blue-500 | 2 px `Highlight` outline of the row frame in the ring's band (below) |
860
- | Keyboard focus on a row or card | outline | 2 px at offset 4 (4–6 px outside) | blue-600 | blue-400 | kept (system colour) |
933
+ | Keyboard focus on a row or card (only while the view root carries `data-daily-report-keyboard-focus`) | outline | 2 px at offset 4 (4–6 px outside) | blue-600 | blue-400 | kept (system colour) |
861
934
  | Keyboard focus on a control or link | outline | 2 px at offset 2 | blue-600 | blue-400 | kept |
862
935
  | Pointer focus | none | — | — | — | — |
863
- | Hover (cards) | blue-300 border, `shadow-lg` (a selected card paints no shadow), 2 px lift (motion-safe) | — | — | — | — |
936
+ | Hover (cards) | blue-300 border, `shadow-lg` (a selected card paints no shadow), a lift L of at most 2 px on whole device pixels (motion-safe; **Row gutter G** below) | — | — | — | — |
864
937
 
865
938
  The selected surface sets the shadow colour to transparent (`shadow-transparent`; the selection rules are the only ones that write a shadow colour), and every shadow size the surface can take — the resting `shadow-sm`, the hover `shadow-lg` and the mid-scroll reset `[[data-daily-report-scrolling]_&]:hover:shadow-sm`, whose specificity (0,3,0) beats the selection rules' (0,2,0) — reads its colour from that one variable (`--tw-shadow-color`).
866
939
  Whichever size rule wins the cascade, a selected surface therefore paints nothing outside its ring, at rest, hovered or mid-scroll, and the 2 px separation band below the ring is page colour like the other three edges (any shadow there tints the band: the resting `shadow-sm` shifts its relative luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to 3.12:1 against it).
867
940
 
868
- - **Row gutter G = 8 px** on all four sides of both row frames: ring 2 + separation 2 + outline 2 + hover lift 2. Every term is a CSS pixel quantity and is written in px (`p-[8px]`, the lift `-translate-y-[2px]`), because the ring and the outline do not scale with the root font size and a `rem` term would break the sum at any root other than 16 px; the lengths built on G — the focus reach, the toolbar insets, the frame radius — are px too. A row aligned to either edge of the viewport while hovered, selected and focused is therefore never clipped, at any root font size;
941
+ - **Row gutter G = 8 px** on all four sides of both row frames: G = 8 ≥ ring 2 + separation 2 + outline 2 + hover lift L, with L ≤ 2. Every term is a CSS pixel quantity and is written in px (`p-[8px]`, the lift's values), because the ring and the outline do not scale with the root font size and a `rem` term would break the sum at any root other than 16 px; the lengths built on G — the focus reach, the toolbar insets, the frame radius — are px too. A row aligned to either edge of the viewport while hovered, selected and focused is therefore never clipped, at any root font size;
869
942
  a DetailList row is its measured body plus 2G = 16.
943
+ The lift L is the largest whole number of device pixels within 2 CSS px at the device-pixel ratio dpr, ⌊2·dpr⌋ / dpr: 2 px at 1, 1.5, 2 and 3, 1.6 px (2 device pixels) at 1.25 and 12/7 px (3 device pixels) at 1.75.
944
+ A 2 px lift would be 2.5 and 3.5 device pixels at 1.25 and 1.75, which puts the hovered surface on a half device pixel and blends its 1 px border, the ring and the outline into the next row of device pixels.
945
+ The card sets the value on itself as the custom property `--aqdr-hover-lift` (`2px`, overridden under the media conditions `resolution: 1.25dppx` and `resolution: 1.75dppx`), and the lift reads it (`translate-y-[calc(-1*var(--aqdr-hover-lift))]`); every class is a literal, so a host's Tailwind build and the package's standalone stylesheet compile the same rules.
870
946
  - **List slot P**: the List card's content is sized in rem, so the slot follows the page's root font size R: P(R) = 4 · ⌈(2G + 2 × (1 + 15) (the card's border and padding) + 4 (spare) + 6.75R) / 4⌉ = 4 · ⌈(52 px + 6.75 rem) / 4⌉, the sum rounded up to the 4 px layout lattice (`listRowSlotHeight`, `LAYOUT_LATTICE_PX`), so every row top stays on the lattice.
871
947
  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).
872
948
  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.
@@ -879,10 +955,11 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
879
955
  - **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.
880
956
  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.
881
957
  - **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.
958
+ The focus outline of both surfaces also requires the view root's `data-daily-report-keyboard-focus` (`:where([data-daily-report-keyboard-focus]) <frame>:focus-visible > :where(<surface>)` and `:where([data-daily-report-keyboard-focus]) <card surface>:has(> <primary button>:focus-visible)`): the ancestor sits in `:where()`, so the rules keep their specificity of (0,2,0) and (0,3,0), and the attribute changes only when the input modality does, never per arrow key, so it restyles the surfaces once per change of modality.
882
959
  - **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.
883
960
  - **Every row is a relayout boundary**: the row frames of both views are `contain: size layout style` (one token, `ROW_CONTAINMENT_CLASS_NAME`), so a change inside one row — a held body rendered after the key's commit, a load that completes, the selection and focus indicators — lays out that row alone and restyles no other.
884
961
  A key move that shifts `VirtualScroll`'s rendering window (it mounts and unmounts rows) is laid out from the items wrapper's containing block. In `@aiquants/virtualscroll` 3.9 that block is a flex item, which Chromium does not make a relayout boundary, so such a shift lays out from the document root: in the app's keyboard harness (run `2026-10-03T23-04-07-660Z`, 4× CPU) a key's layout CPU p50 equals its document-rooted layout CPU p50, 3.99 ms in the List and 8.12 ms in the DetailList.
885
- 3.10.0, the peer floor, puts the wrapper in a relayout boundary of its own (`.aqvs-items-boundary`, a zero-height box with `contain: size layout style`; its README "What a scroll step paints"), from which the same shift is laid out inside the list: in virtualscroll's own probe, 60 one-row shifts in a page of 2,231 layout objects ran 60 partial layouts of 218 objects instead of 60 layouts from the document root, with identical pixels.
962
+ 3.10.0 puts the wrapper in a relayout boundary of its own (`.aqvs-items-boundary`, a zero-height box with `contain: size layout style`; its README "What a scroll step paints"), from which the same shift is laid out inside the list: in virtualscroll's own probe, 60 one-row shifts in a page of 2,231 layout objects ran 60 partial layouts of 218 objects instead of 60 layouts from the document root, with identical pixels.
886
963
  In the views the box removes every document-rooted layout of a key's own rendering: in the app's keyboard harness with 3.10.0 no key's rendering lays out from `#document` in any condition, and a key's layout CPU p50 is about 0.2–0.4 ms at 1× CPU on an uncontended host and, at 4× CPU, about 1.1 ms in the List and 6.8–6.9 ms in the DetailList, all of it inside the list (3.99 and 8.12 ms from the document root before). What still lays out from the document root is the List side pane's deferred catch-up to the selection: about once per spaced key, and once per hold when the key is held.
887
964
  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).
888
965
  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.
@@ -897,7 +974,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
897
974
  Outside the rows, the side pane's tab panels are the package's own (`role="tabpanel"`, named by their trigger, hidden and empty while not selected) and read no computed style when they mount, and the scroll bar's business-day bubble reads its date and wheel state from a store of its own (`useSyncExternalStore`), so a change of the visible range or a wheel re-renders only the bubble, never the view or `VirtualScroll`.
898
975
  The bubble sits at a constant offset from the thumb overlay's box, 16 px beside the bar, and follows the thumb's centre by `transform` alone, with no transition, and the thumb itself moves by a translate snapped to device pixels (`@aiquants/virtualscroll` 3.9.0); so a scroll step that keeps the rendering window writes no `top` or `left` and adds no layout from the document root while the bubble shows.
899
976
  - **Focus indicators are the package's own**: every focus indicator the package draws is one of the outlines above, including those of its tabs, buttons, switches, inputs and text areas (`src/client/ui`); none uses the host's `--ring` (measured at 2.43:1 in light and 1.22:1 in dark against the tab list), and no element that carries a focus indicator transitions its colours (in Tailwind 4 `transition-colors` also fades the outline in).
900
- No element is scaled: a scaled element scales its outline with it. The switches are drawn at their real size — a 40 × 24 track whose transparent 4 px border (its style declared) frames a 16 px thumb that moves 16 px — so the 24 px track is its own target and its 2 px outline at offset 2 is drawn at full width. `src/client/ui/focus-indicator.spec.ts` checks the shipped CSS, including that no rule scales an element.
977
+ No element is scaled: a scaled element scales its outline with it. The switches are drawn at their real size — a 40 × 24 track whose transparent 4 px border (its style declared) frames a 16 px thumb that moves 16 px — so the 24 px track is its own target and its 2 px outline at offset 2 is drawn at full width. `src/client/ui/focus-indicator.spec.ts` checks the shipped CSS, including that no rule scales an element and that every outline rule of a row or card surface requires `data-daily-report-keyboard-focus` on an ancestor.
901
978
  - **Focus reach**: a control's outline reaches 4 px outside it (offset 2 + width 2 = 4 = u; a row's or a card's outline stays inside the gutter G), and every box that clips its content and holds focusable content keeps that reach inside its clipping edges, so no outline is ever cut.
902
979
  The rows' gutter holds it in both views; the side pane's tab panels — the pane's scroll box, also inside the mobile overlay — carry `FOCUS_RING_REACH_CLASS_NAME` (`-mx-[4px] px-[4px] pb-[4px] scroll-p-[4px]`, in px like the outline it holds): the negative inline margin and the equal padding move the clipping edges 4 px out on both sides without moving or narrowing the content, `pb-[4px]` keeps the last control's outline, `scroll-p-[4px]` keeps the outline of a control that Tab scrolls to an edge, and the panel's 8 px top padding holds the top.
903
980
  The side pane's header boxes (the date and author column and the tab list, which clip to reserve the scroll-bar gutter; **Side pane** below) move their clipping edges 4 px out on all four sides the same way (`-m-[4px] p-[4px]` in `SIDE_PANE_HEADER_BOX_CLASS_NAME`), so the heading's and the tabs' outlines stay whole.
@@ -936,7 +1013,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
936
1013
  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"`).
937
1014
  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.
938
1015
  Both option sets are module constants, so the view's props to `VirtualScroll` stay equal from render to render.
939
- The floating tap-scroll circle and its visual, the scroll bar's thumb and arrow buttons and the scroll-to-edge buttons are `VirtualScroll`'s own: one rule of its stylesheet (since 3.8.2; the peer floor is 3.10.0) stops every transition and animation of every part it animates under `prefers-reduced-motion: reduce`
1016
+ 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) stops every transition and animation of every part it animates under `prefers-reduced-motion: reduce`
940
1017
  (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.
941
1018
  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`.
942
1019
  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.
@@ -953,9 +1030,13 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
953
1030
  Plain centring puts the column on a half pixel whenever the space beside it is odd (x = 56.5 in a 1,265 px area), and at a fractional ratio each 2 px stroke then blends into its neighbours.
954
1031
  On the lattice, each 2 px stroke of the two-channel indicator — the ring, the separation band and the outline — paints ⌊2 × ratio⌋ full device pixels on the left and right edges (2, 3 and 3 at 1.25, 1.5 and 1.75) with no blended pixel on its inner side, and the 1 px border paints its own colour (measured in Chromium at those ratios, light and dark). An engine without CSS `round()` drops the declaration and keeps the `mx-auto` centre.
955
1032
  The block-axis origin and the view's height are the host's: the views start and end where the host's layout puts them.
956
- A host that wants the same whole-pixel strokes on the top and bottom edges at fractional ratios puts both edges on the lattice too — for example with a header and a footer whose heights are their content rounded up to the lattice unit `DAILY_REPORT_LAYOUT_LATTICE_PX` (`height: calc-size(auto, round(up, size, 4px))`) in a window whose height is a multiple of it, since a bar sized by its font metrics alone ends on a fraction (such as 30.4375 px); the bottom-aligned surface then sits exactly G from the view's end (**G-symmetric frame** in [View height](#view-height-host-layout)).
1033
+ A host that wants the same whole-pixel strokes on the top and bottom edges at the quarter ratios 1.25, 1.5 and 1.75 puts the edges on the lattice too — for example with a header and a footer whose heights are their content rounded up to the lattice unit `DAILY_REPORT_LAYOUT_LATTICE_PX` (`height: calc-size(auto, round(up, size, 4px))`), since a bar sized by its font metrics alone ends on a fraction (such as 30.4375 px).
1034
+ The top edge then lies on the lattice, and the bottom edge too when the window height is a multiple of the unit; the bottom-aligned surface then sits exactly G from the view's end.
1035
+ In any other window, and at other ratios (browser zoom such as 0.9, 1.1 or 1.33), it sits at least G and less than G + 1 device pixel from the end (**G-symmetric frame** in [View height](#view-height-host-layout)).
957
1036
  - **Side pane** (List, desktop layout): the panel group fills the view's root, and the list and the side-pane panels both take its height ([View height](#view-height-host-layout)), so a long report scrolls only inside the pane's article tab and never lengthens the page. The pane sits G inside its panel on all four sides, so its top and bottom edges line up with the first and last cards, and its bottom edge sits exactly G above the page's bottom edge, as far as the toolbar is above the first surface.
958
1037
  The List panel ends G after its scroll bar (`LIST_PANEL_END_GUTTER_CLASS_NAME`, `pr-[8px]`), so a 2G = 16 px channel lies between the scroll bar and the pane, and the resize handle that `@aiquants/resize-panels` centres on the panel boundary (a 10 px hit area around a 6 px grip) covers neither the scroll bar nor the pane's edge.
1038
+ The side-pane panel is at least 200 px wide (`SIDE_PANE_PANEL_MIN_WIDTH_PX`): the frame around its centred selection prompt — 2G = 16 (the pane sits G inside its panel), the card surface's insets 2 × 16 and the placeholder panel's insets 2 × 12 (`PLACEHOLDER_PANEL_INSET_PX`), 72 in all — plus the prompt's longest phrase at the default 16 px root, 「選択してください。」 (9 full-width glyphs of `text-sm`, 9 × 0.875 rem = 126 px), is 198, rounded up to the 4 px lattice.
1039
+ At the default root the ja prompt therefore breaks only between phrases and leaves no one-glyph line; at a larger root its phrases widen and the prompt breaks inside one without crossing its frame (**Line breaks of wrapping labels** below). The List panel keeps its own minimum of 160 px, and the two minimums and the 10 px handle (370 px) fit in the List's two-column minimum of 460 px.
959
1040
  The pane's header and its scrolling body end on one edge: the date and author column and the tab list sit in boxes that reserve the same scroll-bar gutter as the article and relations tab panel (`SIDE_PANE_HEADER_BOX_CLASS_NAME` and the panel both compose `SCROLLBAR_GUTTER_CLASS_NAME`, `scrollbar-thin` with `scrollbar-gutter: stable`, which an `overflow: hidden` box reserves too), so with a classic thin scroll bar, a wider one or an overlay one of no width, the header's end and the body's end share one x, in the desktop pane and in the mobile overlay alike.
960
1041
  - **Tab bars are one segmented control**: the side pane's tabs and the view-mode toolbar share one geometry. The bar has a 12 px corner (`rounded-xl`) and a total inset of 4 px that counts its border (4 px padding without a border, `SEGMENTED_LIST_CLASS_NAME`; 1 px border + 3 px padding with one, `SEGMENTED_BORDERED_LIST_CLASS_NAME`), the same at the top and at the sides.
961
1042
  The tabs are 24 px tall with 12 px labels and an 8 px corner (`rounded-lg`), so both bars are 4 + 24 + 4 = 32 px tall and every tab is concentric with its bar: 12 − 4 = 8. Because xl − lg = 4 equals the inset both in Tailwind's scale and in a host `--radius` scale (lg = `--radius`, xl = `--radius` + 4 px), the pair stays concentric for any host radius; the toolbar's children at its end corners (the create button, the development box) are `rounded-lg` too.
@@ -964,9 +1045,11 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
964
1045
  - **Scroll bars**: both views theme VirtualScroll's scroll bar through its root class (`VIEW_SCROLL_BAR_THEME_CLASS_NAME`): the track is slate-100 / dark slate-900 and the thumb slate-500 (slate-600 / 400 on hover, slate-700 / 300 while dragged), at least 3:1 against the track in both themes (lowest 4.35 / 3.74); the arrow glyphs reach 6.90 / 6.14 in light and 6.78 / 5.56 in dark on their resting and hover backgrounds.
965
1046
  In forced colours the browser replaces every background that is not a system colour with `Canvas`, which would leave the thumb invisible on its track, so the token restates the thumb in system colours with the same selector as each state — `CanvasText` at rest, `Highlight` hovered and dragged, `GrayText` disabled — after the light and dark rules, and gives the track a 1 px inset `CanvasText` outline (an outline: the layout does not change).
966
1047
  These rules win over virtualscroll's own forced-colours rules (`@aiquants/virtualscroll` 3.10.0, its README "Forced colours") the same way the token wins over its default colours, so the views' thumb is drawn by this token in every mode.
967
- - **Line breaks of wrapping labels**: a short centred label that can wrap — the side pane's selection prompt, whose panel narrows to the panel minimum, and an attachment tile's unavailable label, as wide as its track — breaks its lines through one token, `WRAPPING_LABEL_CLASS_NAME`: `text-wrap: balance` keeps the lines about equally long, so no one-glyph last line is left under the others (engines balance only blocks of a few lines, so the token is for labels, not body text),
968
- and `word-break: auto-phrase` breaks Japanese at phrase boundaries where the host document's `lang` is `ja` (ja 「プレビューを / 表示できません」 instead of 「プレビューを表示できませ / ん」 in a 171 px track) and behaves as `normal` in other languages.
969
- The token sets nothing else, and the labels that use it declare no other line-breaking property, so nothing cancels it: `src/client/ui/style-tokens.spec.ts` pins the token's two declarations, the side pane's spec the prompt's classes, and the tile's spec compiles the label's classes and checks that its line-breaking declarations are exactly the token's.
1048
+ - **Line breaks of wrapping labels**: a short centred label that can wrap — the side pane's selection prompt, whose panel narrows to its minimum (**Side pane** above), and an attachment tile's unavailable label, as wide as its track — breaks its lines through one token, `WRAPPING_LABEL_CLASS_NAME` (`text-balance wrap-anywhere [word-break:auto-phrase]`), because a plain break leaves a narrow centred label with a stray glyph or two on its last line, or breaks a Japanese word in the middle:
1049
+ `text-wrap: balance` keeps the lines about equally long, so no one-glyph last line is left under the others (engines balance only blocks of a few lines, so the token is for labels, not body text);
1050
+ `word-break: auto-phrase` breaks Japanese at phrase boundaries where the host document's `lang` is `ja` (ja 「プレビューを / 表示できません」 instead of 「プレビューを表示できませ / ん」 in a 171 px track) and behaves as `normal` in other languages;
1051
+ and `overflow-wrap: anywhere` breaks inside a phrase or a word only when it does not fit on a line by itself, so a label never crosses its frame at any width, root font size or font. It also counts those breaks in the label's min-content width, so a flex item's automatic minimum width no longer holds the label at its longest phrase (the prompt is a flex item, and the tile label's text is the anonymous flex item of its box: the property is inherited, which is how it reaches that item, where a `min-width` on the label's box would not). Where a phrase fits, the lines are the same as without it.
1052
+ The token sets nothing else, and the labels that use it declare no other line-breaking property, so nothing cancels it: `src/client/ui/style-tokens.spec.ts` pins the token's three declarations, the side pane's spec the prompt's classes and the panel minimum derived from the compiled insets and the prompt's last phrase, and the tile's spec compiles the label's classes and checks that its line-breaking declarations are exactly the token's.
970
1053
  - **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).
971
1054
  - **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.
972
1055
  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.
@@ -1026,7 +1109,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
1026
1109
  Under HTTP/2 all tabs of a profile share one connection per origin, and the limit is the server's number of concurrent streams. The value stays 2 because it must be safe wherever HTTP/1.1 remains (development, E2E, TLS-inspecting proxies, deployments without HTTP/2), and it also bounds how many server-side renders one screen starts at once. Module state cannot see other tabs, so the page is the widest unit that can be counted; counting per component or per list would multiply the limit when the side pane and several DetailList cards show thumbnails at once.
1027
1110
  - A frame waiting for a slot keeps being observed: if it leaves view it gives up its place in the queue, and when it comes back it waits the 250 ms again before queuing.
1028
1111
  - A load ends on `load`, on `error`, or after 75 s without either, counted from the moment the slot was granted. 75 s is the sum of the server's time budget, which bounds a request from the generation gate onward, so the client does not give up on a response the server may still produce once the request has reached the gate; authentication and authorization come before the gate and their time is taken out of the same 75 s. The slot is returned then, and a timed-out load has its `src` removed first.
1029
- - A failed or timed-out load is retried once, 5 s later, through the same visible-settle-slot path (an `<img>` cannot see the status code, so a transient 503 / 502 looks the same as a permanent failure). Only when the retry also fails does the frame become `unavailable`.
1112
+ - A failed or timed-out load is retried once, 5 s later, through the same visible-settle-slot path (an `<img>` cannot see the status code, so a transient 503, 502 or 504 looks the same as a permanent failure). Only when the retry also fails does the frame become `unavailable`.
1030
1113
  The 5 s and the 60 s below are the server's own timing, read by both sides from one shared module (`src/shared/attachment-delivery-timing.ts`) because an `<img>` cannot follow a `Retry-After` it receives: 5 s is the `Retry-After` of the server's overload 503, and 60 s is the window of the per-minute rate buckets, the `Retry-After` of their 429.
1031
1114
  - **Page-wide progress**: the page remembers up to 1,024 thumbnail URLs (the least recently used is forgotten first). A URL that loaded before shows its image in the first render of a remounted frame, with no observation, slot or timer — for example after the arrow keys moved away from a report and back.
1032
1115
  Because thumbnails are `private, no-cache`, the browser revalidates such an image with one conditional request (answered 304 without a body when unchanged, after authorization); the revalidation waits for no settle time and takes no slot, and at most one runs per mounted frame. If it fails, the page forgets that the URL loaded, counts one failed attempt, and the frame goes on with the retry wait.
@@ -1100,8 +1183,8 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1100
1183
  ```
1101
1184
 
1102
1185
  - **One surface for all wording.** The catalog covers the 11 engine chrome keys of
1103
- `@aiquants/virtualscroll` plus 83 own keys: field headings, the page title (`title`, passed to
1104
- `renderHeader` and used as the accessible name of both lists), the lists' keyboard help, the row state read to
1186
+ `@aiquants/virtualscroll` plus 84 own keys: field headings, the page title (`title`, passed to
1187
+ `renderHeader` and used as the accessible name of both lists), the lists' keyboard help, the row state and the List card's position read to
1105
1188
  screen readers, tabs, buttons, placeholders, `confirm()` / `alert()` texts, stream badges, the load-error screen
1106
1189
  and the mutation-failure message. The engine part of each catalog is `VIRTUAL_SCROLL_LABEL_CATALOGS[locale]`, reused by
1107
1190
  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.
@@ -1109,17 +1192,17 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1109
1192
  and the 11 engine keys of the resolved labels to their `VirtualScroll`. The engine label object keeps
1110
1193
  its identity while its values are unchanged, so rebuilding `config.labels` on every render does not
1111
1194
  re-render the memoized list subtree.
1112
- - **Formatter keys.** Ten keys take arguments and are functions: `totalCount(count)`,
1195
+ - **Formatter keys.** Eleven keys take arguments and are functions: `totalCount(count)`,
1113
1196
  `debounce(milliseconds)`, `streamProgressCount(loaded)`, `streamProgressRatio(loaded, total, percent)`,
1114
1197
  `streamRetrying(loaded)`, `reportLoadFailed(reportHubId)`, `reportSummaryLoadFailed(reportHubId)`,
1115
1198
  `interviewerWithAffiliation(name, affiliation)`, `operationFailed(operation)` (`operation` is a
1116
1199
  `DailyReportOperation`: `create` / `update` / `publish` / `delete` / `addComment` / `deleteComment` /
1117
- `toggleStar` / `toggleRead`) and `rowState({ isRead, isStarred })`. An override of such a key must be a function too.
1200
+ `toggleStar` / `toggleRead`), `rowState({ isRead, isStarred })` and `listRowPosition(position, total)`. An override of such a key must be a function too.
1118
1201
  - **Digit grouping follows the UI locale, not the runtime.** The built-in formatters group numbers with
1119
1202
  the BCP 47 tag bound to their locale (`en` → `"en-US"`, `ja` → `"ja-JP"`); a host override receives
1120
1203
  the raw number and formats it itself.
1121
1204
  - **Fail-fast**: an unsupported `locale` (`"EN"`, `"ja-JP"`, `"fr"`, `""`, `null`, ...) throws a
1122
- `RangeError` at render. So do an unknown `labels` key (a key error that lists the 94 keys), a string
1205
+ `RangeError` at render. So do an unknown `labels` key (a key error that lists the 95 keys), a string
1123
1206
  key whose value is not a string with non-whitespace content, and a formatter key whose value is not a
1124
1207
  function. An `undefined` value keeps the catalog value.
1125
1208
  - **`config` has a closed key set**: `apiBasePath`, `ssePath`, `draftLabelName`, `locale`, `labels`,
@@ -1156,7 +1239,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1156
1239
  grouping follows `locale` (`en` → `"en-US"`, `ja` → `"ja-JP"`) and dates stay in ISO form. Packages
1157
1240
  that do format dates (for example `@aiquants/period-slider`) name that separate axis `formatLocale`.
1158
1241
 
1159
- Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 94 keys, the 11 engine keys
1242
+ Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 95 keys, the 11 engine keys
1160
1243
  first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReportLabels(locale, labels)`
1161
1244
  (the effective frozen labels — the catalog object itself when `labels` is `undefined`), the resolved
1162
1245
  `locale` / `labels` on `useDailyReportConfig()`, and the types `DailyReportLocale` (= `VirtualScrollLocale`),
@@ -1257,7 +1340,8 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
1257
1340
  | `reportSummaryLoadFailed` | list card load failure | `(12345)` → Failed to load the summary of report 12,345. | `(12345)` → 日報 12,345 の概要読み込みに失敗しました。 |
1258
1341
  | `interviewerWithAffiliation` | interviewer chip | `("Sato", "Acme")` → Sato (Acme) | `("佐藤", "山田商事")` → 佐藤 (山田商事) |
1259
1342
  | `operationFailed` | error banner / `DailyReportErrorInfo.message` | `("update")` → Failed to save the report. Please try again later. | `("update")` → 日報の保存に失敗しました。時間をおいて再度お試しください。 |
1260
- | `rowState` | visually hidden row state, the first description of the List card's primary button and of a DetailList row | `({ isRead: false, isStarred: true })` → Unread, starred | `({ isRead: false, isStarred: true })` → 未読、スター付き |
1343
+ | `rowState` | visually hidden row state, the first description of a DetailList row and the second of the List card's primary button | `({ isRead: false, isStarred: true })` → Unread, starred | `({ isRead: false, isStarred: true })` → 未読、スター付き |
1344
+ | `listRowPosition` | visually hidden position of a List card in the list, the first description of its primary button (`position` is 1-based) | `(3, 1234)` → 3 of 1,234 | `(3, 1234)` → 全 1,234 件中 3 件目 |
1261
1345
 
1262
1346
  ### External Source Badge Configuration (`sourceTypeConfigs`)
1263
1347
 
@@ -1345,7 +1429,9 @@ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000
1345
1429
  | Not authenticated | 200, `retry: 86400000` → `event: auth-required` (`Set-Cookie` forwarded) | terminated; calls `config.onSessionExpired()` once; never reconnects |
1346
1430
  | Authenticated but no internal user | 200, `retry: 86400000` → `event: forbidden` | terminated; no navigation (a reload cannot fix it); visible through `sseStatus` |
1347
1431
  | A request from another origin's page that the isolation refuses ([Request isolation](#request-isolation)) | 403 JSON before authentication (`Cache-Control: no-store`, no `Set-Cookie`), counted in the bounded refusal record | never met: the package's client connects from the page's own origin |
1432
+ | A React Router single-fetch data request (`<path>.data`, `GET` or `HEAD`) | 404 JSON before authentication (`{"error":{"message":"Not Found"}}`, `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, no `Set-Cookie`), with no subscription or timer and no line in the refusal record ([Request isolation](#request-isolation)) | never sent by the client: `EventSource` requests the route itself |
1348
1433
  | Unknown endpoint | 404 | treated as retryable; backoff up to 30 s |
1434
+ | A `HEAD` request | the status and headers a `GET` would get (one of the rows here), with no body work: no subscription, no heartbeat, no visibility refresh timer (**Streaming routes** in [Server wiring](#server-wiring-di)) | never sent by the client |
1349
1435
  | 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 |
1350
1436
  | Malformed cursor | 200, `retry: 86400000` → `event: resync-required` | rereads the ids (server cache bypassed) and reopens from the new anchor |
1351
1437
  | Cursor older than the oldest retained entry (the stream is trimmed with `MAXLEN ~ streamMaxLen`) | 200, `connected` → heartbeat → `retry: 86400000` → `event: resync-required` → end | same as above |
@@ -1393,7 +1479,7 @@ Every entry exports by name (no `export *`), so a module-internal helper never b
1393
1479
  - Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor }`; `readSseStreamAnchor`; `attachmentDelivery`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
1394
1480
  - 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`.
1395
1481
  - SSE and caching: `DailyReportSseReader` / `DAILY_REPORT_SSE_VISIBILITY_REFRESH_MS` / `DAILY_REPORT_SSE_CATCH_UP_BATCH` / `isStreamIdLte` / `SqlResultCache` / `transformJsonArray` / `jsonResponseWithETag` / `generateETag`.
1396
- - Types: `DailyReportServerConfig` / `DailyReportServiceConfig` / `DailyReportTables` / `DailyReportDb` / `DailyReportAuthenticate` / `DailyReportAuthResult` / `DailyReportIdCodec` / `DailyReportRedisProvider` / `DailyReportSseReaderPort` (the shared reader `createDailyReportHandlers` takes) / `StreamEntry` / `ExternalReportFields`,
1482
+ - Types: `DailyReportServerConfig` / `DailyReportServer` (what `createDailyReportServer` returns) / `DailyReportHandlersConfig` / `DailyReportHandlers` (what `createDailyReportHandlers` takes and returns) / `DailyReportServiceConfig` / `DailyReportTables` / `DailyReportDb` / `DailyReportAuthenticate` / `DailyReportAuthResult` / `DailyReportIdCodec` / `DailyReportRedisProvider` / `DailyReportSseReaderPort` (the shared reader `createDailyReportHandlers` takes) / `StreamEntry` / `ExternalReportFields`,
1397
1483
  and for attachments `DailyReportAttachmentDelivery` / `DailyReportReadAttachment` / `DailyReportAttachmentError` / `DailyReportAttachmentFailure` / `DailyReportAttachmentThumbnailRenderer` / `DailyReportAttachmentThumbnail` / `DailyReportAttachmentThumbnailSourceFormat` / `DailyReportAttachmentThumbnailOutputMediaType` / `SharpLike`.
1398
1484
 
1399
1485
  MIT