@aiquants/daily-report 0.27.0 → 0.29.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,9 +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.10.0 or later**: both views pass its scroll-bar option `enableArrowButtonTabStops: false`, since 3.10.0 its one signal that the host scrolls by keyboard itself, under which the scroll bar is pointer-only — hidden from assistive technology, out of the Tab order and never taking focus on a press (the views scroll by keyboard themselves; see [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)) —; its stylesheet stops the motion of its own parts under `prefers-reduced-motion: reduce` (3.8.2;
29
+ The peer floor of `@aiquants/virtualscroll` is **3.11.0**: install 3.11.0 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
- 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).
32
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).
33
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.
34
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.
@@ -40,6 +43,7 @@ Every `publish:*` script measures the build that `pnpm run verify` ends with (ve
40
43
  `--write` lowers every entry the build shrank, so a shrink is recorded into the release by construction (no failed publish, no separate baseline commit); a growth is left unrecorded, and `--exact` — besides the budgets, it fails when any measured size differs from its baseline entry in either direction — stops it and prints the `node scripts/check-bundle-size.mjs --write --accept` command that records that build after review.
41
44
  So a release always ships the baseline of its own build: an entry left high after a shrink would loosen every budget derived from it, and one left low would judge the next change against a build nobody shipped. `--exact` cannot be combined with `--write` (exit 2).
42
45
  `pnpm run verify` runs the type checks, Biome (`src/` and `scripts/`), markdownlint, the two count ratchets (docstrings, then the no-fallback guard), the unit tests with per-file coverage floors, the coverage ratchet, the examples, the build and, last, the bundle check.
46
+ The unit tests include the repository's publish leak guard over the markdown the package ships (`src/shipped-markdown-leaks.spec.ts`: every markdown file `package.json` `files` ships — `README.md`, `CHANGELOG.md` and any shipped docs — packed with `package.json` into a tarball and checked by the guard itself, with its own rules), so an internal name in them fails verify instead of stopping a publish; the publish paths still run the guard on the packed tarball, `dist` included.
43
47
  Every source file under `src` (without specs, tests, declarations and test helpers) and every gate script under `scripts` (`scripts/**/*.mjs`) has a committed floor of branch and function coverage in `coverage-floors.json`: Vitest fails a file below its floor, `pnpm run check:coverage` (`scripts/ratchet-coverage.mjs`) fails a file without an entry or an entry without a file, and `pnpm run coverage:ratchet` raises each floor to the measured percentage rounded down after a whole-suite coverage run (a file at 100 % stays at 100; no floor is ever lowered by the script).
44
48
  Each gate script is a two-statement command-line entry around the `main({ packageRoot, argv })` of its module under `scripts/lib/` (the shared exit codes, JSON readers and error descriptions are `scripts/lib/cli.mjs`); the specs run `main` in the test process against temporary packages and keep one subprocess test of each entry, so the scripts' logic is measured by the same floors as `src` (all at 100).
45
49
  A new file enters the ratchet only at 100 % of both metrics: the script records no entry below that, so a file that cannot reach 100 % needs a committed entry with a non-empty `exemption` (the reason, reviewed with the floors), which the script keeps while it raises the floors.
@@ -48,7 +52,7 @@ The guards are the violations of the workspace's docstring and comment language
48
52
  A substitution that the specification or a port contract defines is a reviewed exception of the no-fallback guard instead (`EXCEPTIONS` in `scripts/lib/check-no-fallback.mjs`: the file, the exact expression, the number of `occurrences` it covers and the reason), and its matches are not counted; an exception that matches another number of expressions than it declares fails (exit 1: fewer means the expression was fixed or rewritten, more a new copy that needs its own review), and a malformed list is exit 2.
49
53
  Every TypeScript or JavaScript code block of this README names its source in its info string: an example file, or a `#region` of one, which `src/docs-examples.spec.ts` compares byte for byte (`ts examples/<file>.ts[#<region>]`), or `illustrative` for a fragment that is not type-checked.
50
54
 
51
- **Browser floor**: Chrome 121, Firefox 122, Safari 17.4 (Baseline 2024). The client uses each platform feature below as it is, with no feature detection and no second code path, so on an engine under the floor the listed behaviour fails and nothing else replaces it. Three CSS features of the table are newer than parts of the floor, and the row says what those engines draw:
55
+ **Browser floor**: Chrome 121, Firefox 122, Safari 17.4 (Baseline 2024). The client uses each platform feature below as it is, with no feature detection and no second code path, so on an engine under the floor the listed behaviour fails and nothing else replaces it. Five CSS features of the table are newer than parts of the floor, and the row says what those engines draw:
52
56
 
53
57
  | Feature | Supported from | Used for | Below the floor |
54
58
  | --- | --- | --- | --- |
@@ -58,6 +62,9 @@ Every TypeScript or JavaScript code block of this README names its source in its
58
62
  | CSS `round()` | Chrome 125, Firefox 118, Safari 15.4 | The attachment grid's whole-pixel width `calc(round(down, 100% − 16 (n − 1), n px) + 16 (n − 1))` per column count, which makes every track a whole pixel, the tile frame's whole-pixel height `round(nearest, 100cqi * 320 / 480, 1px)` and the tile's lattice pad `round(up, h, 4px) − h` under its last line (see [Attachment display](#attachment-display)); the views' column origin on the 4 px lattice (see **Origin on the 4 px lattice**) | Chrome 121–124, inside the floor, drop those declarations: the grid fills its container with the fluid tracks (W − 16 (n − 1)) / n (every breakpoint keeps its column count, which no `round()` declaration sets), so tile edges can fall between pixels and the names are fitted less than 1 px narrower than the track; the frame's `aspect-ratio: 480 / 320`, always declared beside its height, sizes the frame at the exact fractional 2t / 3, so the frames of one grid can paint one device pixel apart; a tile stays its frame's fractional height + 48 px tall, so the rows below an image grid can start between device pixels; and the column keeps the `mx-auto` centre |
59
63
  | CSS `calc-size()` | Chrome 129 | The DetailList row bodies' height, their content height rounded up to the 4 px lattice (`calc-size(auto, round(up, size, 4px))`, see **Row slots on the lattice**) | Chrome 121–128, and any engine that does not implement it, drop the declaration: a body keeps its content height, which is on the lattice at a 16 px root and can leave it at other roots, so the DetailList rows below can start between device pixels |
60
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 |
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 |
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 |
61
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 |
62
69
 
63
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)).
@@ -145,12 +152,30 @@ export const loader = async (args) => {
145
152
  return data(r.data, { headers: r.headers })
146
153
  }
147
154
  // daily_report.api.$endpoint/route.tsx
148
- export const loader = (args) => dailyReportServer.api.loader(args)
155
+ export const loader = (args) => {
156
+ // only the ids stream: the JSON endpoints answer single fetch as before (Streaming routes, below)
157
+ if (args.params.endpoint === DAILY_REPORT_IDS_STREAM_ENDPOINT) refuseSingleFetchDataRequest(args.request)
158
+ return dailyReportServer.api.loader(args)
159
+ }
149
160
  export const action = (args) => dailyReportServer.api.action(args)
150
161
  // sse.daily_report.$endpoint/route.tsx
151
- export const loader = (args) => dailyReportServer.sse.loader(args)
162
+ export const loader = (args) => {
163
+ refuseSingleFetchDataRequest(args.request)
164
+ return dailyReportServer.sse.loader(args)
165
+ }
166
+ // refuseSingleFetchDataRequest is the host's own guard: it throws a 404 when new URL(request.url).pathname ends in ".data"
152
167
  ```
153
168
 
169
+ **Streaming routes**: the SSE route and the API route's ids stream (`GET {apiBasePath}/ids-stream`; the endpoint's name is exported as `DAILY_REPORT_IDS_STREAM_ENDPOINT` from the server entry) answer with a body that streams until the client leaves.
170
+
171
+ - **`HEAD`**: both answer the status and headers a `GET` would get, and start no work for the body.
172
+ 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 —
173
+ 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).
174
+ 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.
175
+ - **Single fetch**: React Router answers a single-fetch data request (`<path>.data`) by reading the loader's 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. The host refuses those requests in the two routes before it calls the package (above): the SSE route every one, the API route only the ids stream's, whose name it compares with `DAILY_REPORT_IDS_STREAM_ENDPOINT` instead of a copied literal.
176
+ The attachment route needs no such guard: the package refuses `<token>.data` itself ([Request isolation](#request-isolation)).
177
+ - **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.
178
+
154
179
  ### DI ports
155
180
 
156
181
  - `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.
@@ -162,6 +187,7 @@ export const loader = (args) => dailyReportServer.sse.loader(args)
162
187
  - `resolveVisibleSourceTypes(request)` — **Optional row-level authorization port.** Returns the `Hub.source_type` values this request may view. Applies uniformly to every server data path: list (ids stream), business-date list, detail, comments, attachment bytes, and SSE. See [Source-type visibility](#source-type-visibility) below.
163
188
  - `enableDevCacheClear` — Gates the dev-only `POST /action` `intent=clearCache` (flush every worker's cache). Default `false` → the handler returns `400` before touching the service. Wire `import.meta.env.DEV` to enable it only in development (any authenticated user could otherwise flush all caches without limit).
164
189
  - `attachmentIdCodec` / `readAttachment` / `attachmentThumbnailRenderer` / `attachmentMaxBytes` — Attachment delivery ports and size limit, owned by the service. See [Attachments](#attachments).
190
+ - `logger` — Optional console-compatible logger (`debug` / `info` / `warn` / `error`) that receives every server log line of the package as it is. Without it each area logs to the console with its own prefix and lowest level, for example `[DailyReportAttachment]` from `info` (see **Log levels** under [Attachments](#attachments)) and `[DailyReportIsolation]` from `warn` (the refusal record of [Request isolation](#request-isolation)).
165
191
  - Primary tuning parameters: `idsTtlMs` (180s) / `businessDateTtlMs` (300s) / `streamKey` / `streamMaxLen` / `loginRedirectPath`, plus the attachment tuning keys listed under [Attachments](#attachments).
166
192
 
167
193
  **Configuration errors** follow one convention on the server and on the client: the message reads `[daily-report] <path> must be <expectation>`, `<path>` being the public key or argument to fix, and ends with `; got <value>` only when it shows a value.
@@ -171,16 +197,29 @@ The three helpers of `src/shared/config-errors.ts` (`configValueError`, `configK
171
197
 
172
198
  ### Request isolation
173
199
 
174
- Every resource route the handlers serve — `api.loader`, `api.action`, `sse.loader` and `attachment.loader` — checks the request's Fetch Metadata first (`isCrossSiteRequest` in `src/server/request-isolation.ts`), before authentication, any rate charge, authorization or service call:
200
+ 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.
201
+ 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.
202
+ 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`.
203
+ The attachment route refuses them (`frameworkDataRefusal` of its policy): a `<token>.data` request — `GET` or `HEAD`, inline, download or thumbnail, from the same origin or from another origin's navigation alike — is answered with the attachment route's 404 (`Attachment not found`, thrown the way a loader answers early), before authentication, any rate charge, authorization or storage read, and it writes no line in the refusal record below (it is not a cross-site refusal).
204
+ The 404 carries no attachment content, which is why it is safe after React Router has replaced its headers; a caller of the loader itself still sees the attachment security headers. The API, the action and SSE serve such requests like any other (`NOT_NAVIGABLE`): the host guards its two streams (**Streaming routes** in [Server wiring](#server-wiring-di)).
175
205
 
176
- | `Sec-Fetch-Site` | Answer |
206
+ | Request | Answer |
177
207
  | --- | --- |
178
- | `same-origin`, `none` (a load the user started: the address bar, a bookmark), or no header (a client that is not a browser; every browser of the floor sends it) | Served: authentication and the route's own checks follow |
179
- | `same-site` (another origin of the same site, such as a sibling subdomain), `cross-site` or any other value | 403 before authentication, unless the request is a top-level navigation: `Sec-Fetch-Mode: navigate`, method `GET` or `HEAD`, and a `Sec-Fetch-Dest` other than `object` and `embed` (a link from another page to an original opens it) |
180
-
181
- - **The refusal**: on the API and SSE routes, JSON `{"error":{"message":"Cross-site request rejected"}}` with `Cache-Control: no-store` and `X-Content-Type-Options: nosniff`; on the attachment route, the same body through the loader's own failure builder, so it carries the attachment headers (**Same-origin loads only** in [Attachments](#attachments)). Nothing has authenticated, so no `Set-Cookie` is forwarded, and no log line is written. A cross-site navigation that is not `GET` or `HEAD` (a form `POST` from another site) is refused too, on the attachment route before its method check.
208
+ | `Sec-Fetch-Site: same-origin` or `none` (a load the user started: the address bar, a bookmark) | Served: authentication and the route's own checks follow |
209
+ | Any other `Sec-Fetch-Site` — `same-site` (another origin of the same site, such as a sibling subdomain), `cross-site` or an unknown value | 403 before authentication, unless the request is a top-level document navigation that the route serves: method `GET`, `Sec-Fetch-Mode: navigate`, `Sec-Fetch-Dest` exactly `document`, and a route policy that accepts it. Only the attachment route accepts one, and only for an original (`inline` or `download`, read by the loader's own strict query parser): a link in another page opens or downloads the original. The API, SSE and thumbnail routes accept no navigation from another origin |
210
+ | No `Sec-Fetch-Site` | A safe method (`GET`, `HEAD`, `OPTIONS`) is served. An unsafe method is decided by `Origin`: no `Origin` is served (not a browser page), an `Origin` whose host is the host the request was addressed to (`new URL(request.url).host`, which the adapter builds from `Host`) is served, and `null`, an unparsable value, another host or another port is 403 |
211
+
212
+ - **Requests without Fetch Metadata**: a browser sends no `Sec-Fetch-*` to an origin that is not potentially trustworthy (plain HTTP other than `localhost`), so such a request comes either from a client that is not a browser or from a page of such an origin. A browser always sends `Origin` with an unsafe method (`null` for an opaque origin), so a sibling page's form `POST` to a plain-HTTP host is refused, while a client that is not a browser (no `Origin`) and the host's own pages are served (the rule of Go's `net/http` `CrossOriginProtection`).
213
+ Safe methods change no state, so a no-cors `GET`, which carries no `Origin`, is served.
214
+ Full isolation therefore needs a potentially trustworthy origin (HTTPS): only there does the browser send the Fetch Metadata that also refuses a sibling page's `<img>` and no-cors probes before authentication.
215
+ - **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.
216
+ 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.
217
+ - **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]`.
218
+ 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.
219
+ 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.
220
+ 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.
182
221
  - **Why the request side**: the host's session cookie (`SameSite=Lax` in the usual setting) also accompanies requests from other origins of the same site, form submissions (`application/x-www-form-urlencoded`, `multipart/form-data`, `text/plain`) and no-cors requests need no preflight, so CORS does not stop them, and React Router checks CSRF only for document requests and single fetch, not for resource routes.
183
- A response policy such as `Cross-Origin-Resource-Policy` decides only whether a response may be read; it does not stop a state change or the authentication, rate and authorization work a request starts. With the check, a page of another origin can neither post to the API (create, update, publish or delete a report, comment, star, mark read) nor probe attachment tokens: a visible and a hidden attachment get the same 403 at the same cost, and the viewer's rate buckets are not spent.
222
+ 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.
184
223
  - **Same origin only**: the package's client calls its endpoints from the page's own origin. A host that serves the API from another origin than the page is refused by this check.
185
224
 
186
225
  ### Source-type visibility
@@ -261,8 +300,12 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
261
300
  - **Parsing is strict.** `thumbnail` must be one known variant name, `download` must be exactly `1`, the two cannot be combined, and neither may repeat. Anything else — `?thumbnail=1`, `?thumbnail=true`, `?download=true`, `?download=yes`, `?thumbnail=tile&download=1` — answers **400** `{"error":{"message":"Invalid attachment request"}}` right after authentication, before any port or database query runs, and writes no log line. Other parameters are ignored, and names are case-sensitive (`?Download=1` is an unknown parameter, so the request stays inline).
262
301
  - **The original's type**: the declared type is the row's `file_type` when it is a valid media type, otherwise the type the read port reported. An inline-safe declared type (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `application/pdf`, `text/plain`) is sent as itself, inline (as an attachment for a download); any other declared type, and a missing one, is sent as `application/octet-stream` with `Content-Disposition: attachment`, because the declared type comes from outside the package and an inline `text/html` or `image/svg+xml` would run script in the host's origin.
263
302
  - **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.
264
- - **Same-origin loads only**: a request from another origin's page that is not a top-level `GET` / `HEAD` navigation — an `<img>`, a no-cors `fetch`, a `HEAD` probe from a sibling subdomain — is refused with 403 before authentication ([Request isolation](#request-isolation)), so it learns nothing about a token: a visible and a hidden attachment get the same 403 at the same cost, with no authentication, rate token, visibility resolution or query. A top-level navigation from another site (a link to an original) is served after authentication and authorization as usual.
265
- As defence in depth, every attachment response, success or failure — the original's 200 (inline and download, GET and HEAD), the thumbnail's 200 and 304, and every error status the loader answers (400, 401, 403, 404, 405, 413, 429, 500, 502 and 503, for GET and HEAD alike) — carries `Cross-Origin-Resource-Policy: same-origin` and `X-Content-Type-Options: nosniff`, so the browser lets only pages of the host's own origin read it. Both headers come from one set that every path building an attachment response spreads last, so no status can lose them.
303
+ - **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);
304
+ 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.
305
+ 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) —
306
+ 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).
307
+ 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.
308
+ 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)).
266
309
 
267
310
  **Configuration**
268
311
 
@@ -298,19 +341,35 @@ type DailyReportReadAttachment = (
298
341
  options: { maxBytes: number; head: boolean; principal?: string; signal: AbortSignal },
299
342
  ) => Promise<
300
343
  | { ok: true; bytes: Uint8Array<ArrayBuffer>; contentType?: string | null; size?: number }
301
- | { ok: false; reason: "not_found" | "denied" | "too_large" | "invalid_path" | "unavailable"; code?: string }
344
+ | { ok: false; reason: "not_found" | "denied" | "too_large" | "invalid_path" | "unavailable" | "busy" | "deadline"; code?: string }
302
345
  >
303
346
  ```
304
347
 
305
348
  - 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).
349
+ - **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.
350
+
351
+ | Reason | When | Original | Thumbnail |
352
+ | --- | --- | --- | --- |
353
+ | `not_found` | The object does not exist | 404, and the object is marked missing | 404, and the object is marked missing |
354
+ | `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 |
355
+ | `invalid_path` | A scheme or a path the port does not accept | 404 | 404 |
356
+ | `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) |
357
+ | `unavailable` | The storage cannot be reached, a read ended early, or the package's `signal` aborted the read | 502 | 502 |
358
+ | `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` |
359
+ | `deadline` | A storage call passed the port's own deadline (a per-call deadline, a stalled stream) | 504 | 504 |
360
+
361
+ `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.
306
362
  - **`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.
307
363
  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).
308
364
  - **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:
309
365
  - 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;
310
- - 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**:
366
+ - 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`.
367
+ 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**:
311
368
  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.
369
+ - 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.
370
+ 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.
312
371
  - **`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).
313
- - 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.
372
+ - 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).
314
373
  - 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).
315
374
  - 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.
316
375
  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.
@@ -325,9 +384,10 @@ Example — a read port over a host object store (the file is type-checked by `p
325
384
  * ホストのオブジェクトストアの上に組む読み取りポートの例 (同梱しない。`pnpm run typecheck:examples` で型検査する)。
326
385
  *
327
386
  * It follows the port contract: typed failures instead of exceptions, the package's `signal` combined with a per-call deadline,
328
- * the real size declared on HEAD, and `unavailable` (never `not_found`) for a failure that settles after an abort.
387
+ * the real size declared on HEAD, `deadline` for a call that only its own deadline stopped, and `unavailable` (never `not_found`)
388
+ * for a failure that settles after an abort.
329
389
  * ポート契約に従う: 例外ではなく種別付きの失敗で返し、パッケージの `signal` を呼び出しごとの締め切りと合成し、HEAD では実サイズを申告し、
330
- * 中断の後に決着した失敗は `not_found` ではなく `unavailable` にする。
390
+ * 自前の締め切りだけが止めた呼び出しは `deadline`、中断の後に決着した失敗は `not_found` ではなく `unavailable` にする。
331
391
  */
332
392
  import type { DailyReportAttachmentError, DailyReportReadAttachment } from "@aiquants/daily-report/server"
333
393
 
@@ -353,7 +413,7 @@ export type ObjectStoreReadSettings = {
353
413
  resolveKey: (filePath: string) => string | null
354
414
  }
355
415
 
356
- /** 中断・締め切り・到達不能で決着した読み取り (実体についての判定ではない)。 */
416
+ /** 中断の後や到達不能で決着した読み取り (実体についての判定ではない)。 */
357
417
  const UNAVAILABLE: DailyReportAttachmentError = Object.freeze({ ok: false, reason: "unavailable" })
358
418
 
359
419
  /**
@@ -366,13 +426,16 @@ const UNAVAILABLE: DailyReportAttachmentError = Object.freeze({ ok: false, reaso
366
426
  */
367
427
  export const createObjectStoreReadAttachment = (store: ObjectStore, settings: ObjectStoreReadSettings): DailyReportReadAttachment => {
368
428
  /**
369
- * Combines the package's signal with the deadline of one storage call.
370
- * パッケージのシグナルと、ストレージ呼び出し 1 回の締め切りを合成する処理。
429
+ * Starts the deadline of one storage call and combines it with the package's signal.
430
+ * ストレージ呼び出し 1 回の締め切りを始め、パッケージのシグナルと合成する処理。
371
431
  *
372
432
  * @param signal Signal from the package. パッケージから渡されたシグナル。
373
- * @returns A signal that aborts on either. どちらでも中断されるシグナル。
433
+ * @returns The call's own deadline, and the signal to pass to the call, which aborts on either. 呼び出し自身の締め切りと、どちらでも中断される呼び出しへ渡すシグナル。
374
434
  */
375
- const callSignal = (signal: AbortSignal): AbortSignal => AbortSignal.any([signal, AbortSignal.timeout(settings.callTimeoutMs)])
435
+ const startCall = (signal: AbortSignal): { deadline: AbortSignal; callSignal: AbortSignal } => {
436
+ const deadline = AbortSignal.timeout(settings.callTimeoutMs)
437
+ return { deadline, callSignal: AbortSignal.any([signal, deadline]) }
438
+ }
376
439
 
377
440
  /**
378
441
  * Reads an attachment (metadata only on HEAD) and settles with the bytes or a typed failure; never throws.
@@ -386,19 +449,22 @@ export const createObjectStoreReadAttachment = (store: ObjectStore, settings: Ob
386
449
  const key = settings.resolveKey(filePath)
387
450
  if (key === null) return { ok: false, reason: "invalid_path" }
388
451
  if (signal.aborted) return UNAVAILABLE
452
+ let call = startCall(signal)
389
453
  try {
390
- const stat = await store.stat(key, callSignal(signal))
454
+ const stat = await store.stat(key, call.callSignal)
391
455
  // 中断の後に届いた「無い」は打ち切りの結果であって実体の判定ではない (not_found はパッケージが実体消失として記録する)
392
456
  if (stat === null) return signal.aborted ? UNAVAILABLE : { ok: false, reason: "not_found" }
393
457
  if (stat.size > maxBytes) return { ok: false, reason: "too_large" }
394
458
  // HEAD の Content-Length は GET と一致しなければならないので、読まずに実サイズを申告する
395
459
  if (head) return { ok: true, bytes: new Uint8Array(0), contentType: stat.contentType, size: stat.size }
396
- const bytes = await store.read(key, callSignal(signal))
460
+ call = startCall(signal)
461
+ const bytes = await store.read(key, call.callSignal)
397
462
  // メタデータを読んだ後に実体が伸びていることがある
398
463
  if (bytes.byteLength > maxBytes) return { ok: false, reason: "too_large" }
399
464
  return { ok: true, bytes, contentType: stat.contentType, size: bytes.byteLength }
400
465
  } catch (error) {
401
- return { ok: false, reason: "unavailable", code: error instanceof Error ? error.name : undefined }
466
+ // パッケージの中断の後の失敗は打ち切りの結果。自前の締め切りだけが過ぎた呼び出しは、締め切りの失敗 (504) として区別する
467
+ return { ok: false, reason: !signal.aborted && call.deadline.aborted ? "deadline" : "unavailable", code: error instanceof Error ? error.name : undefined }
402
468
  }
403
469
  }
404
470
  return readAttachment
@@ -559,7 +625,7 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
559
625
  **Thumbnail endpoint (`?thumbnail=tile`)**
560
626
 
561
627
  - **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.
562
- - **Order**: request isolation (403 for another origin's page, [Request isolation](#request-isolation)) → authenticate → query (400) → method → ports → token → viewer → renderer port → rate admission (below; an empty bucket answers 429 without resolving visibility or running the authorization query)
628
+ - **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)
563
629
  → 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.
564
630
  - **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`:
565
631
  - 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.
@@ -568,7 +634,7 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
568
634
  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.
569
635
  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.
570
636
  - 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).
571
- - 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).
637
+ - 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).
572
638
  - **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.
573
639
  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.
574
640
  - **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.
@@ -589,43 +655,46 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
589
655
  | Client load timeout | 75 s (the sum) | the load counts as one failure |
590
656
 
591
657
  - **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.
592
- It stores only verified outcomes: successful previews and the content-determined failures (signature mismatch, the port's `unsupported`, and the read port's `too_large` for a row whose size is unknown — on a row with a known size, which eligibility keeps within `attachmentMaxBytes`, a `too_large` is always unverified), including those a render delivers after its generation stopped waiting for it, past its deadline or after every requester left (see **Thumbnail renderer port** above).
593
- It never stores unverified outcomes, `failed`, the 502 of a timeout, the 503 of an abort, storage errors (including `not_found`), queue rejections or exceptions.
658
+ 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).
659
+ 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).
660
+ It never stores unverified or unrecorded outcomes, `failed`, the 502 of a timeout, the 503 of an abort, storage errors and transient refusals (`not_found`, `unavailable`, `busy`, `deadline`), queue rejections or exceptions.
594
661
  - **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);
595
662
  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.
596
663
 
597
664
  | Status | When |
598
665
  | --- | --- |
599
666
  | 200 | Preview. Headers: the port's `Content-Type`, `Content-Length`, `Cache-Control: private, no-cache` and `ETag` (an unverified preview: `Cache-Control: no-store` and no `ETag`), `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox`, `X-Frame-Options: SAMEORIGIN`, `Cross-Origin-Resource-Policy: same-origin`, forwarded `Set-Cookie` |
600
- | 304 | `If-None-Match` matches (carries `Cache-Control`, `ETag`, `X-Content-Type-Options: nosniff`, `Cross-Origin-Resource-Policy: same-origin`, `Set-Cookie`) |
667
+ | 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`) |
601
668
  | 400 | A query that names no delivery (unknown or repeated variant such as `?thumbnail=1`, combined with `download`), or a malformed token |
602
- | 401 / 403 | Not authenticated / no internal user, or (403, before authentication) a request from another origin's page that is not a top-level `GET` / `HEAD` navigation ([Request isolation](#request-isolation)) |
603
- | 404 | Ports not injected, not visible, not eligible, signature mismatch, `unsupported`, `too_large`, `not_found`, `denied`, `invalid_path` — one identical body for all |
669
+ | 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) |
670
+ | 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 |
604
671
  | 405 | Any method other than GET (`Allow: GET`) |
605
672
  | 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`) |
606
- | 502 | Storage unreachable (`unavailable`), the port's `failed` (`render_failed`), or a stage deadline (`read_timeout`, `render_timeout`) |
607
- | 503 | Wait queue full or wait timed out (`queue`), or the generation abandoned by every waiting request (`aborted`; no one receives it) (`Retry-After: 5`) |
608
- | 500 | Unexpected exception, including one thrown by a port during generation (logged as `500 attachment=<id> viewer=<id> variant=thumbnail cache=miss reason=port_exception message=<msg>`, never cached), or a port contract violation (`port_contract`: a render result outside the contract, logged with `code=result` for a value that is not an object, `code=failure_reason` for a failure whose reason is neither `unsupported` nor `failed`, or the output check that failed, `code=` `content_type`, `bytes`, `empty`, `too_large`, `signature` or `dimensions`; or a read over `maxBytes`, `code=over_max_bytes`), or a host route that passes no `token` parameter (a route misconfiguration, below) |
673
+ | 502 | Storage unreachable or a read ended early (`unavailable`), the port's `failed` (`render_failed`), or a stage deadline (`read_timeout`, `render_timeout`) |
674
+ | 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 |
675
+ | 504 | A storage call past the read port's own deadline (`deadline`); never cached |
676
+ | 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) |
609
677
 
610
- Error responses are JSON (`{ "error": { "message": "..." } }`) with `Cache-Control: no-store` (a 404 or 405 without freshness information may otherwise be cached heuristically, `Set-Cookie` included), carry `X-Content-Type-Options: nosniff` and `Cross-Origin-Resource-Policy: same-origin` like every attachment response (**Same-origin loads only** above), and forward `Set-Cookie`. Log lines carry `attachment=<id> viewer=<id> variant=thumbnail`, plus `cache=hit|miss|revalidated` once the cache stage is reached; the 200 and 404 lines of an unverified outcome end with `identity=unverified`.
678
+ 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).
679
+ 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`.
611
680
  Unexpected exceptions outside generation go to the loader's shared catch and are logged as `500 attachment=? viewer=? reason=unexpected message=<msg>` (no `variant`).
612
681
  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.
613
- Rejections up to the renderer-port check (401 / 400 / 405 / 404 for a missing port / 403) are not logged.
682
+ 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.
614
683
  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.
615
684
 
616
685
  **Log levels** (both deliveries; the line formats are fixed):
617
686
 
618
687
  | Level | Outcomes |
619
688
  | --- | --- |
620
- | `info` | 200, 304, the 503 nobody receives (`reason=aborted`), 404 `not_eligible`, and the content-determined 404s (`signature`, `unsupported`, `too_large`; cached when verified) |
621
- | `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) |
622
- | `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 |
689
+ | `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) |
690
+ | `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 storage deadline's 504 (`deadline`), 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) |
691
+ | `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 |
623
692
 
624
693
  `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.
625
694
 
626
- **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.
627
- 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.
628
- A client therefore cannot write log lines at its request rate.
695
+ **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.
696
+ 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.
697
+ 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.
629
698
 
630
699
  ### Transactions
631
700
 
@@ -682,7 +751,8 @@ The first load of a report that is not cached starts inside the hook's effect, w
682
751
 
683
752
  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.
684
753
 
685
- 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.
754
+ The report id list is **not** part of the loader data: a module-resident NDJSON stream session (`GET {apiBasePath}/ids-stream`, the endpoint `DAILY_REPORT_IDS_STREAM_ENDPOINT` of the server entry; resilient client with cursor resume + exponential backoff) supplies it.
755
+ 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.
686
756
  `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).
687
757
  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`.
688
758
  `DailyReportPage` subscribes via `useDailyReportIdsStream`, rendering the list as soon as the first chunk arrives (on cache misses the server races a fast `TOP 200` first page against the cached full query, so first paint does not wait for the full id scan).
@@ -705,7 +775,10 @@ Each view (List and DetailList) is as tall as the space the host's layout leaves
705
775
  Every change is followed, a sub-pixel one included. A root without a box (not rendered, or detached) reads 0 in Chromium, whose first delivery reports 0 for it; an engine that follows the specification's 0 × 0 starting size reports nothing for such a root, and the value stays `null` until the root has a box. A view whose document has no window throws. The value reaches only `viewportSize`: the list column, the panel group and the scroll container write no height.
706
776
  - **G-symmetric frame**: the rows' gutter G is inside the view's box, so the last aligned surface — a List or DetailList row aligned to the bottom, or the List's side pane — sits G = 8 px above the page's bottom edge (where a footer that follows the page starts), the distance from the view-mode toolbar to the first surface at the top.
707
777
  `VirtualScroll` snaps the layer that moves the rows to whole device pixels away from the edge a row is aligned to (the start at position 0 and after an alignment to the top, the end at the maximum position and after an alignment to the bottom; 3.9.0, "Device-pixel snapping" in its README), so an aligned row's surface never comes closer than G to that edge: what the snap adds is less than one device pixel.
708
- It adds nothing — the surface sits exactly G from the edge — when the view's height and the row slots are whole numbers of device pixels: the slots are multiples of 4 px (**Row slots on the lattice**), and a host gives the view a height on the 4 px lattice by sizing its own bars on that lattice (for example a window height that is a multiple of 4 under a header and a footer whose heights are rounded up to 4 px).
778
+ 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.
779
+ 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).
780
+ 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.
781
+ 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.
709
782
  - **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).
710
783
 
711
784
  ### Keyboard, focus and selection (List / DetailList)
@@ -758,6 +831,8 @@ The keys are delegated to each view's **list**: the element that holds the view'
758
831
  - **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.
759
832
  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.
760
833
  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).
834
+ 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 (run `2026-10-05T16-45-08-477Z`, keys held): every recalculation with a stack comes from that `focus()` call, made by the key's focus request before the commit — the DetailList from row 110 at 1× CPU ran 63 recalculations in 21 of its 22 keydown tasks (8.48 ms) and no layout.
835
+ At 4× CPU from row 200,000 the keydown tasks also ran two recalculations and two layouts without a stack (DetailList 16.00 ms and 23.00 ms, List 3.76 ms and 2.42 ms), each laid out from virtualscroll's items boundary (`.aqvs-items-boundary`), none from the document root.
761
836
  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.
762
837
  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.
763
838
  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.
@@ -778,8 +853,12 @@ The keys are delegated to each view's **list**: the element that holds the view'
778
853
 
779
854
  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.
780
855
  Focus is only ever taken from nowhere: focus that the user put elsewhere is never moved.
781
- - **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.
782
- - **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.
856
+ - **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.
857
+ 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):
858
+ 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.
859
+ 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. Chromium also matches `:focus-visible` after a bare `Shift` or `CapsLock`, so there the focus outline can show while the view still treats the focus as a pointer's.
860
+ - **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.
861
+ - **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.
783
862
  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.
784
863
  - **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.
785
864
  - **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.
@@ -812,7 +891,8 @@ The keys are delegated to each view's **list**: the element that holds the view'
812
891
  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).
813
892
  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.
814
893
  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.
815
- 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.
894
+ 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.
895
+ 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.
816
896
  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).
817
897
  - **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).
818
898
  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).
@@ -863,7 +943,8 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
863
943
  - **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.
864
944
  - **Every row is a relayout boundary**: the row frames of both views are `contain: size layout style` (one token, `ROW_CONTAINMENT_CLASS_NAME`), so a change inside one row — a held body rendered after the key's commit, a load that completes, the selection and focus indicators — lays out that row alone and restyles no other.
865
945
  A key move that shifts `VirtualScroll`'s rendering window (it mounts and unmounts rows) is laid out from the items wrapper's containing block. In `@aiquants/virtualscroll` 3.9 that block is a flex item, which Chromium does not make a relayout boundary, so such a shift lays out from the document root: in the app's keyboard harness (run `2026-10-03T23-04-07-660Z`, 4× CPU) a key's layout CPU p50 equals its document-rooted layout CPU p50, 3.99 ms in the List and 8.12 ms in the DetailList.
866
- 3.10.0, the peer floor, puts the wrapper in a relayout boundary of its own (`.aqvs-items-boundary`, a zero-height box with `contain: size layout style`; its README "What a scroll step paints"), from which the same shift is laid out inside the list: in virtualscroll's own probe, 60 one-row shifts in a page of 2,231 layout objects ran 60 partial layouts of 218 objects instead of 60 layouts from the document root, with identical pixels. The views' numbers with the box come from the app's keyboard harness.
946
+ 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.
947
+ 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.
867
948
  Inside the box the rows' overflow is ink overflow, so a scroll the browser makes on its own to reveal an overscan row (a find-in-page match there) cannot move the list and moves the nearest outer scroller instead when the row's box lies outside its view; the views' own reveals do not depend on it (one Tab stop per view, rows focused with `preventScroll`, keyboard focus revealed by the scroller).
868
949
  Size containment makes the frame's size independent of its content: its width is the row box `VirtualScroll` lays out, and its height is the slot P the List writes on the frame or, in the DetailList, that row box itself (`h-full`), whose height `VirtualScroll` takes from the measured row.
869
950
  The DetailList therefore measures each row's body — the frame's direct child, as tall as its content rounded up to the 4 px lattice (**Row slots on the lattice**) — and adds 2G, handing the height to `VirtualScroll` through the scroller's `resizeRow` (see **Host selections and list changes**); measuring the frame would read back the box it fills. A held body is not measured, and a body that is replaced (another load state, a released hold) is observed in its place.
@@ -916,7 +997,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
916
997
  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"`).
917
998
  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.
918
999
  Both option sets are module constants, so the view's props to `VirtualScroll` stay equal from render to render.
919
- The floating tap-scroll circle and its visual, the scroll bar's thumb and arrow buttons and the scroll-to-edge buttons are `VirtualScroll`'s own: one rule of its stylesheet (since 3.8.2; the peer floor is 3.10.0) stops every transition and animation of every part it animates under `prefers-reduced-motion: reduce`
1000
+ 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`
920
1001
  (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.
921
1002
  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`.
922
1003
  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.
@@ -933,9 +1014,13 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
933
1014
  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.
934
1015
  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.
935
1016
  The block-axis origin and the view's height are the host's: the views start and end where the host's layout puts them.
936
- A host that wants the same whole-pixel strokes on the top and bottom edges at fractional ratios puts both edges on the lattice too — for example with a header and a footer whose heights are their content rounded up to 4 px (`height: calc-size(auto, round(up, size, 4px))`) in a window whose height is a multiple of 4, since a bar sized by its font metrics alone ends on a fraction (such as 30.4375 px); the bottom-aligned surface then sits exactly G from the view's end (**G-symmetric frame** in [View height](#view-height-host-layout)).
1017
+ 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).
1018
+ 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.
1019
+ 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)).
937
1020
  - **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.
938
1021
  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.
1022
+ 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.
1023
+ 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.
939
1024
  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.
940
1025
  - **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.
941
1026
  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.
@@ -944,6 +1029,11 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
944
1029
  - **Scroll bars**: both views theme VirtualScroll's scroll bar through its root class (`VIEW_SCROLL_BAR_THEME_CLASS_NAME`): the track is slate-100 / dark slate-900 and the thumb slate-500 (slate-600 / 400 on hover, slate-700 / 300 while dragged), at least 3:1 against the track in both themes (lowest 4.35 / 3.74); the arrow glyphs reach 6.90 / 6.14 in light and 6.78 / 5.56 in dark on their resting and hover backgrounds.
945
1030
  In forced colours the browser replaces every background that is not a system colour with `Canvas`, which would leave the thumb invisible on its track, so the token restates the thumb in system colours with the same selector as each state — `CanvasText` at rest, `Highlight` hovered and dragged, `GrayText` disabled — after the light and dark rules, and gives the track a 1 px inset `CanvasText` outline (an outline: the layout does not change).
946
1031
  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.
1032
+ - **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:
1033
+ `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);
1034
+ `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;
1035
+ 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.
1036
+ 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.
947
1037
  - **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).
948
1038
  - **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.
949
1039
  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.
@@ -993,7 +1083,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
993
1083
  The frame is a relayout boundary: its strict containment (size, layout, paint, style) changes neither its size, which comes from its own style alone (the container's full width and the height above, or the aspect ratio), nor what is painted, since paint containment clips at the frame's edge and the skin lies inside it;
994
1084
  and as a positioned box that is not a flex or grid item (its parent is a block wrapper inside the tile link), Chromium lays out what changes inside the frame — the image's intrinsic size arriving on load, a style change of the skin — inside the frame alone, not from the document root.
995
1085
  Only a change inside the frame stops there: a boundary whose own computed style changes is laid out by its parent, from the document root. So the frame's own style never changes after mount — its classes are its box alone (`relative block w-full`: no variant, no paint, no animation) and its inline style is `ATTACHMENT_TILE_FRAME_STYLE` — and every state is drawn inside it, by the skin and the image, which read the frame's attributes.
996
- - **`data-thumbnail-state`** on the frame is `pending` (waiting or loading), `loaded` (the image fades in) or `unavailable` (an icon and `labels.attachmentThumbnailUnavailable` inside the skin; the icon reaches 4.35:1 / 5.56:1 and the label 6.90:1 / 5.58:1 in the light / dark theme). The loader's internal phases are not exposed.
1086
+ - **`data-thumbnail-state`** on the frame is `pending` (waiting or loading), `loaded` (the image fades in) or `unavailable` (an icon and `labels.attachmentThumbnailUnavailable` inside the skin; the icon reaches 4.35:1 / 5.56:1 and the label 6.90:1 / 5.58:1 in the light / dark theme, and the label breaks its lines by **Line breaks of wrapping labels** in [Selection and focus appearance](#selection-and-focus-appearance)). The loader's internal phases are not exposed.
997
1087
  - **The waiting pulse runs only where the tile can be seen**: a `pending` frame's skin pulses (under `prefers-reduced-motion: no-preference`) only while the frame also carries the boolean attribute `data-thumbnail-active`. The frame's load controller decides it (`showActivity`) and the attribute is toggled on the frame element, without a React render, so visibility changes and key moves add no commit; only the skin reads it, so a toggle restyles the skin, never the frame (see **Frame**):
998
1088
  an observed frame (waiting for its 250 ms or for a slot, below) carries the attribute exactly while it is visible, and before its first visibility record it carries none; a granted load sets it and stops observing, so the frame keeps it through the load and, after a failed attempt, through the retry wait — both bounded — until observation starts again (the attribute is removed then) or the frame is released (unmount, a URL change, hiding under `<Activity>`). Once the image has loaded the state is no longer `pending`, so a remaining attribute pulses nothing.
999
1089
  A frame rendered but never seen — a row in the overscan — therefore never pulses: an animation whose element is not visible is not composited, and Chromium would restyle it on the main thread on every frame of an otherwise idle page. A frame that starts from the page's memory (a loaded URL, a remembered failure) is not observed and is only told to hide its activity when it is released.
@@ -1056,7 +1146,8 @@ DOM hooks are `data-*` attributes, test ids and ARIA; class names are styling an
1056
1146
 
1057
1147
  Only the package writes `data-daily-report-keyboard-focus` and `data-daily-report-scrolling`. Hosts may select on them — in tests, or in CSS to adapt their own content inside a view — but never set them. When the pane content matters, wait for `data-displayed-report-id`, not `data-report-id`: during a key's render the frame already names the new selection while the content still shows the previous report.
1058
1148
 
1059
- `[data-testid="daily-report-list"]` and `[data-testid="daily-report-detail-list"]` carry a `__virtualScroll` accessor for end-to-end tests, with the same shape in both views. It is the read-only part of the current `VirtualScroll` handle — `getViewportSize()`, `getScrollPosition()`, `getScrollAnchor()`, `getRange()` and `getFenwickSize()` — plus `findReportIndex(id)`, `getReportItem(index)` and `getReportIds(limit = 20)` over the view's committed list, and one way to move the position, `revealIndex(index, { align, offset })`.
1149
+ `[data-testid="daily-report-list"]` and `[data-testid="daily-report-detail-list"]` carry a `__virtualScroll` accessor for end-to-end tests, with the same shape in both views. It is the read-only part of the current `VirtualScroll` handle — `getViewportSize()`, `getScrollPosition()`, `getScrollAnchor()`, `getRange()` and `getFenwickSize()` — plus `findReportIndex(id)`, `getReportItem(index)` and `getReportIds(limit = 20)` over the view's committed list, and one way to move the position, `revealIndex(index, options)`.
1150
+ The client entry exports the contract as types, so a host's tests import it instead of restating it (type-only, no bundle bytes): `DailyReportViewTestHandle` is the accessor, `DailyReportViewTestReadHandle` its read-only part, and `DailyReportRevealOptions` the options of `revealIndex`, which are the second argument of `VirtualScroll`'s `scrollToIndex` (its alignment and offset, as the peer defines them).
1060
1151
  `revealIndex` goes through the view's scroller (the `VirtualScroll` alignment of `scrollToIndex`, then the anchor's record, like a key move; see **Host selections and list changes**), so a list change right after it keeps the revealed report in place. The accessor has none of the handle's functions that move the position (`scrollToIndex`, `scrollBy`, `scrollTo`, `applyWheel`, `updateItemSize`) and no selection backdoor: select through the UI (a click, the keys). Read it on every use; it is `undefined` while the list is not mounted.
1061
1152
 
1062
1153
  ### Localization (`locale` / `labels`)
@@ -1320,8 +1411,9 @@ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000
1320
1411
  | --- | --- | --- |
1321
1412
  | Not authenticated | 200, `retry: 86400000` → `event: auth-required` (`Set-Cookie` forwarded) | terminated; calls `config.onSessionExpired()` once; never reconnects |
1322
1413
  | Authenticated but no internal user | 200, `retry: 86400000` → `event: forbidden` | terminated; no navigation (a reload cannot fix it); visible through `sseStatus` |
1323
- | A request from another origin's page ([Request isolation](#request-isolation)) | 403 JSON before authentication (`Cache-Control: no-store`, no `Set-Cookie`) | never met: the package's client connects from the page's own origin |
1414
+ | 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 |
1324
1415
  | Unknown endpoint | 404 | treated as retryable; backoff up to 30 s |
1416
+ | 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 |
1325
1417
  | 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 |
1326
1418
  | Malformed cursor | 200, `retry: 86400000` → `event: resync-required` | rereads the ids (server cache bypassed) and reopens from the new anchor |
1327
1419
  | 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 |
@@ -1362,12 +1454,14 @@ Every entry exports by name (no `export *`), so a module-internal helper never b
1362
1454
  - Components: `DailyReportPage` / `DailyReportResolvedContent` / `DailyReportList` / `DailyReportDetailList` / `DailyReportAttachmentIndicator`, the providers `DailyReportConfigProvider` / `DailyReportActionProvider` / `DailyReportErrorProvider`.
1363
1455
  - Hooks: `useDailyReportConfig` / `useDailyReportActionContext` (`sseStatus`) / `useDailyReportDetail` / `useDailyReportPrefetch` / `useDailyReportComments` / `useDailyReportSseConnection` (returns `DailyReportSseConnectionStatus`) / `useDailyReportIdsStream` (state carries `streamAnchor`).
1364
1456
  - The ids stream: `DailyReportIdsStreamClient` (`resync()`) / `DailyReportIdsStreamStatus` / `primeDailyReportIdsStreamSession` / `bootstrapDailyReportIdsStreamSession` / `ensureDailyReportIdsStreamSession` / `resyncDailyReportIdsStream`; the route helpers `createDailyReportClientLoader` / `dailyReportShouldRevalidate`.
1365
- - Configuration and wording: `defaultDailyReportClientConfig` / `resolveDailyReportLabels` / `DAILY_REPORT_LABEL_CATALOGS` / `DAILY_REPORT_LABEL_KEYS` / `resolveSourceTypeConfig`. No layout value is public: the row geometry follows the host's root font size (see **List slot P**), so rows are located by `[data-daily-report-row]` or the test handle.
1366
- - Types: `DailyReportClientConfigInput` / `DailyReportClientConfigDefaults` / `SourceTypeConfig` / `DailyReportLocale` / `DailyReportLabels` / `DailyReportLabelOverrides` / `DailyReportOperation` / `DailyReportErrorInfo` / `DailyReportSseConnectionStatus`.
1457
+ - Configuration and wording: `defaultDailyReportClientConfig` / `resolveDailyReportLabels` / `DAILY_REPORT_LABEL_CATALOGS` / `DAILY_REPORT_LABEL_KEYS` / `resolveSourceTypeConfig`.
1458
+ - Layout: `DAILY_REPORT_LAYOUT_LATTICE_PX`, the 4 px lattice unit a host sizes its bars on (see **G-symmetric frame**); it is the only public layout value. The row geometry is not public: it follows the host's root font size (see **List slot P**), so rows are located by `[data-daily-report-row]` or the test handle.
1459
+ - Types: `DailyReportClientConfigInput` / `DailyReportClientConfigDefaults` / `SourceTypeConfig` / `DailyReportLocale` / `DailyReportLabels` / `DailyReportLabelOverrides` / `DailyReportOperation` / `DailyReportErrorInfo` / `DailyReportSseConnectionStatus`, and the end-to-end test handle's contract `DailyReportViewTestHandle` / `DailyReportViewTestReadHandle` / `DailyReportRevealOptions` (see [Test hooks](#test-hooks)).
1367
1460
  - **server** (`@aiquants/daily-report/server`, Node.js):
1368
1461
  - Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor }`; `readSseStreamAnchor`; `attachmentDelivery`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
1369
1462
  - 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`.
1370
1463
  - SSE and caching: `DailyReportSseReader` / `DAILY_REPORT_SSE_VISIBILITY_REFRESH_MS` / `DAILY_REPORT_SSE_CATCH_UP_BATCH` / `isStreamIdLte` / `SqlResultCache` / `transformJsonArray` / `jsonResponseWithETag` / `generateETag`.
1464
+ - Routes: `DAILY_REPORT_IDS_STREAM_ENDPOINT` (`"ids-stream"`), the name of the API endpoint that streams the ids NDJSON, which the client's URL and the server's endpoint table both use; a host compares a route's `endpoint` parameter with it to single out that stream (**Streaming routes** in [Server wiring](#server-wiring-di)).
1371
1465
  - Types: `DailyReportServerConfig` / `DailyReportServiceConfig` / `DailyReportTables` / `DailyReportDb` / `DailyReportAuthenticate` / `DailyReportAuthResult` / `DailyReportIdCodec` / `DailyReportRedisProvider` / `DailyReportSseReaderPort` (the shared reader `createDailyReportHandlers` takes) / `StreamEntry` / `ExternalReportFields`,
1372
1466
  and for attachments `DailyReportAttachmentDelivery` / `DailyReportReadAttachment` / `DailyReportAttachmentError` / `DailyReportAttachmentFailure` / `DailyReportAttachmentThumbnailRenderer` / `DailyReportAttachmentThumbnail` / `DailyReportAttachmentThumbnailSourceFormat` / `DailyReportAttachmentThumbnailOutputMediaType` / `SharpLike`.
1373
1467