@aiquants/daily-report 0.29.0 → 0.31.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,7 +26,7 @@ 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
- The peer floor of `@aiquants/virtualscroll` is **3.11.0**: install 3.11.0 or later.
29
+ The peer floor of `@aiquants/virtualscroll` is **3.11.4**: install 3.11.4 or later.
30
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;
31
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)).
32
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).
@@ -143,7 +143,7 @@ export const dailyReportServer = createDailyReportServer({
143
143
  })
144
144
  ```
145
145
 
146
- Route mounts (React Router v7, flexible file convention):
146
+ Route mounts (React Router v7, flexible file convention). Every resource route is a thin mount: the package's handlers decide the request isolation, React Router's single-fetch data requests and `HEAD` themselves, so a route only hands its arguments over:
147
147
 
148
148
  ```ts illustrative
149
149
  // daily_report._index/loader.server.ts
@@ -152,29 +152,36 @@ export const loader = async (args) => {
152
152
  return data(r.data, { headers: r.headers })
153
153
  }
154
154
  // daily_report.api.$endpoint/route.tsx
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
- }
155
+ export const loader = (args) => dailyReportServer.api.loader(args)
160
156
  export const action = (args) => dailyReportServer.api.action(args)
161
157
  // sse.daily_report.$endpoint/route.tsx
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"
158
+ export const loader = (args) => dailyReportServer.sse.loader(args)
159
+ // daily_report.api.attachment.$token/route.tsx (see Attachments)
160
+ export const loader = (args) => dailyReportServer.attachment.loader(args)
167
161
  ```
168
162
 
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.
163
+ **Streaming routes**: the SSE route and the API route's ids stream (`GET {apiBasePath}/ids-stream`) answer with a body that streams until the client leaves. The package keeps that body from starting, or from being held, for a request nobody reads as a stream, so the two routes need no guard of their own:
170
164
 
171
165
  - **`HEAD`**: both answer the status and headers a `GET` would get, and start no work for the body.
172
166
  Everything that decides the status runs as for `GET` — the request isolation, authentication, the internal user, the endpoint, the resume cursor (a malformed ids cursor is still 400, a malformed SSE cursor still the `resync-required` frame) and the viewer's visibility —
173
167
  but the SSE route subscribes to nothing and starts no heartbeat and no visibility refresh timer, and the ids stream runs neither its full query nor its first-page query (so a resumed `GET`, which waits for the full query, can still end in a 500 that a `HEAD` does not see: it answers 200).
174
168
  React Router runs a resource route's loader for `HEAD` and drops the body without reading or cancelling it, and an adapter need not abort the request's signal once the response is sent, so body work started for a `HEAD` would run until the process ends. A `GET` whose signal is already aborted starts no body work either.
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.
169
+ - **Single fetch**: React Router answers a single-fetch data request (`<path>.data`) by running the route's loader and reading its whole body before it answers, so a `.data` request to either stream would hold every event, or the whole NDJSON, in server memory until the client leaves.
170
+ The package's request isolation refuses them before authentication ([Request isolation](#request-isolation)): on the SSE route every one, and on the API route those of every endpoint whose body streams — the ids stream's (`ids-stream.data`, with or without a cursor, and its trailing-slash form `ids-stream/_.data`) —, `GET` and `HEAD` alike, with a 404 JSON (`{"error":{"message":"Not Found"}}`, `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`) thrown the way a loader answers early.
171
+ A refused request authenticates nothing, subscribes to nothing, starts no timer and runs no query. The JSON endpoints (`report`, `business-date`) and the action keep serving single fetch, which React Router's own fetchers use.
172
+ - **The API route's methods**: authentication comes first (401), then the endpoint — an unknown name is 404, an inherited name such as `toString` included — and then the method: one the endpoint does not answer is 405 with `Allow`.
173
+ The endpoint table (`API_ENDPOINT_BODY_KINDS` in `src/server/api-endpoint.ts`) states once what kind of body each endpoint answers, and the methods follow from it (`API_ENDPOINT_METHODS`): `GET` for a JSON body (`report` and `business-date`, whose ETag is computed from the body, so a `HEAD` would build the body only to discard it) and `GET, HEAD` for a streaming body (the ids stream). The API route's refusal of data requests above follows from the same table.
174
+
175
+ **Request values**: the API route and the action read every business date, id and time stamp of a request with one strict parser each, after authentication and before any query or service call, and answer a value they do not accept with 400:
176
+
177
+ - **Business dates** (`parseBusinessDateKey`): `YYYY-MM-DD`, or `YYYY/MM/DD` with the same separator twice (read as the same day), naming a day of the calendar from 0001-01-01 to 9999-12-31, the range of the database's `date` column.
178
+ Anything else — surrounding whitespace, a time stamp (`2026-10-06T00:00:00.000Z`), a date written as text, a day off the calendar (`2026-02-30`, `2026-13-40`, month 0), the year 0 — is refused: the `business-date` endpoint answers `{"error":{"message":"Invalid business date"}}` (also when `businessDate` is missing), and the action `{"error":"Invalid businessDate"}` for every intent that sends one, `create` included.
179
+ A created report's business date is stored as that day's UTC midnight and echoed as `YYYY-MM-DD` in the report's `date`, whatever the server's time zone.
180
+ - **Ids** (`parseCanonicalPositiveId`): `reportHubId` (the `report` endpoint, and every intent but `create` and `clearCache`) and `commentId` (`deleteComment`) are only the canonical decimal form of a positive safe integer: digits without a leading zero, from 1 to 2^53 − 1.
181
+ `1e3`, `12abc`, ` 7 `, `0x10`, `1.9`, `-3`, `0` and `9007199254740993` (2^53 + 1, which would round to its neighbour and could name another row) are `Invalid reportHubId` / `Invalid commentId`.
182
+ The ids stream's resume cursor follows the same rule, and its business date must be the canonical `YYYY-MM-DD` the server wrote (a malformed cursor is 400 `Invalid cursor`); an attachment token that decodes to anything but a positive safe integer is 400 `Invalid attachment token`.
183
+ - **The action's form**: the action parses the whole form before it resolves the internal user, the viewer's visibility or any intent. A body that is no form (no form media type, a broken multipart body, a body cut short) is `{"error":"Invalid form"}` without an error log line, and a file where a text field is expected is an invalid value, not a missing one.
184
+ The checks run in this order: the business date, the operation timestamp (`operationTimestamp`: the canonical decimal form of a non-negative safe integer, else `Invalid operationTimestamp`), then — for every intent but `clearCache`, which reads nothing more — `clientTempId` (`clientTempId required`), for `create` the business date's presence (`businessDate required`; `create` never reads `reportHubId`), `reportHubId`, `commentId`, and the intent's name (`Invalid intent`). A star or read toggle echoes the timestamp as it was sent, `null` when none was sent.
178
185
 
179
186
  ### DI ports
180
187
 
@@ -185,7 +192,7 @@ export const loader = (args) => {
185
192
  - `externalSources[]` — When `Hub.source_type` matches, performs a `LEFT JOIN` on `Hub.source_id_num = idColumn` and converts fields via `mapRow`.
186
193
  - `draftLabelName` / `draftLabelNames` — Database label names representing draft states (single string or array of candidates like `["Draft", "Work in Progress"]`). Used for server-side cross-user visibility filtering (hiding drafts from other users) and `isDraft` evaluation.
187
194
  - `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.
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).
195
+ - `enableDevCacheClear` — Gates the dev-only `POST /action` `intent=clearCache` (flush every worker's cache). Default `false` → the action answers `400` (`Invalid intent`) and never calls `service.clearCache()`. Wire `import.meta.env.DEV` to enable it only in development (any authenticated user could otherwise flush all caches without limit).
189
196
  - `attachmentIdCodec` / `readAttachment` / `attachmentThumbnailRenderer` / `attachmentMaxBytes` — Attachment delivery ports and size limit, owned by the service. See [Attachments](#attachments).
190
197
  - `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)).
191
198
  - Primary tuning parameters: `idsTtlMs` (180s) / `businessDateTtlMs` (300s) / `streamKey` / `streamMaxLen` / `loginRedirectPath`, plus the attachment tuning keys listed under [Attachments](#attachments).
@@ -200,8 +207,18 @@ The three helpers of `src/shared/config-errors.ts` (`configValueError`, `configK
200
207
  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
208
  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
209
  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)).
210
+ The policy's `frameworkDataRefusal` receives the loader's arguments and returns the response the wrapper throws (the way a loader answers early), or `null` to serve the request like any other. A refused request reaches no authentication, rate charge, authorization or service call, and it writes no line in the refusal record below (it is not a cross-site refusal). Each route decides by what its body is:
211
+
212
+ | Route (policy) | Framework data requests |
213
+ | --- | --- |
214
+ | `attachment.loader` (`ATTACHMENT_ISOLATION_POLICY`) | Every `<token>.data` — `GET` or `HEAD`, inline, download or thumbnail, from the same origin or from another origin's navigation alike — is refused with the attachment route's 404 (`Attachment not found`). React Router would read the original into memory and drop every protective header; the 404 carries no attachment content, so it is safe after React Router has replaced its headers, and a caller of the loader itself still sees the attachment security headers |
215
+ | `sse.loader` (`SSE_ISOLATION_POLICY`) | Every one is refused with a 404 JSON (`{"error":{"message":"Not Found"}}`, `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`; `dataRequestRefusal`): the body never ends, and `EventSource` requests the route itself |
216
+ | `api.loader` (`API_LOADER_ISOLATION_POLICY`) | Refused with the same 404 when `params.endpoint` names an endpoint whose body streams in the endpoint table (`API_ENDPOINT_BODY_KINDS`: the ids stream, whose whole NDJSON would be held); served for the JSON endpoints, and for a name that is no endpoint (the route's own 404) |
217
+ | `api.action` (`ACTION_ISOLATION_POLICY`) | Served (React Router's own fetchers write through single fetch) |
218
+
219
+ Each policy is one frozen `RequestIsolationPolicy` that carries everything the wrapper decides for its route: the name its refusals are recorded under (`route`: `api`, `action`, `sse` or `attachment`), the navigations it serves from another origin (`navigable`), its answer to a data request (`frameworkDataRefusal`) and its 403 (`crossSiteRejection`).
220
+ The wrapper receives only the policy and the handler (`isolated(policy, handler)` in `src/server/handlers.ts`), so a route's record name, its refusals and their response builders come from one object and cannot be paired wrongly. `NOT_NAVIGABLE`, the base of the API, action and SSE policies, has no `route` (`Omit<RequestIsolationPolicy, "route">`), so a policy built on it type-checks only once it names its route.
221
+ The two API policies live in `src/server/handlers.ts`, the API loader's refusal derived from the endpoint table, so an endpoint added to the table with a streaming body is refused from the start; the SSE policy lives in `src/server/sse-delivery.ts` and the attachment policy in `src/server/attachment-delivery/loader.ts`.
205
222
 
206
223
  | Request | Answer |
207
224
  | --- | --- |
@@ -299,7 +316,7 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
299
316
  - **Variants** form the closed, frozen list `DAILY_REPORT_ATTACHMENT_THUMBNAIL_VARIANTS` (root entry). It has one variant, `tile`: the box 480 × 320 a preview fits in, twice the largest tile frame of 240 × 160 CSS px. `isDailyReportAttachmentThumbnailVariant(value)` accepts the list's own keys only, exactly (`Tile`, `" tile"` and `toString` are not variants).
300
317
  - **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).
301
318
  - **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.
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.
319
+ - **Methods**: the original (inline and download) answers `GET` and `HEAD` (a HEAD reads metadata only, and its `Content-Length` is the size the read port declares, the length a GET sends; a HEAD whose port declares no size sends none); 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.
303
320
  - **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
321
  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
322
  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) —
@@ -316,7 +333,7 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
316
333
  | `attachmentThumbnailRenderer` | service | not injected | `readAttachment`, `attachmentIdCodec` | Renders the preview (`DailyReportAttachmentThumbnailRenderer`: `{ id, render }`; the package's sharp implementation is `createSharpThumbnailRenderer`). Validated when the service is created. Without it `hasThumbnail` is always `false` and every thumbnail request answers 404 (it never falls back to the original). |
317
334
  | `attachmentMaxBytes` | service | 32 MiB | `readAttachment` | Upper bound for an original, for every delivery. Keep it below the read port's wire limit. Integer ≥ 1. |
318
335
  | `attachmentRateLimitPerMinute` | handlers | 60 | `readAttachment` | Original downloads per user per minute; a token is spent before authorization. Integer ≥ 1. |
319
- | `attachmentConcurrency` | handlers | 4 | `readAttachment` | Simultaneous original reads; the slot is held until the body is sent, and the request fails fast with 503 when none is free. Integer ≥ 1. |
336
+ | `attachmentConcurrency` | handlers | 4 | `readAttachment` | Simultaneous original reads. A slot is held while the original is referenced (below: until its body has been handed over, the client cancels it, or the client stops reading for 60 s), and one viewer holds at most half of the slots, rounded up (⌈n / 2⌉: 2 of the default 4). A request that finds no free slot, or whose viewer already holds that share, fails fast with 503 (`reason=concurrency`, `Retry-After: 5`). Integer ≥ 1. |
320
337
  | `attachmentThumbnailRateLimitPerMinute` | handlers | 120 | `attachmentThumbnailRenderer` | Thumbnail generations per user per minute, in a bucket separate from originals; revalidations (requests with `If-None-Match`) pay from a second bucket of ten times this value. Both admit before authorization, and an empty bucket answers 429 at once; a revalidation that does not end in a 304 also pays a generation token, right after authorization (see **Rate admission** below). Integer ≥ 1. |
321
338
  | `attachmentThumbnailConcurrency` | handlers | 2 | `attachmentThumbnailRenderer` | Simultaneous thumbnail generations (read + render), with a wait queue. Integer ≥ 1. |
322
339
  | `attachmentThumbnailCacheBytes` | handlers | 8 MiB | `attachmentThumbnailRenderer` | Total size of the thumbnail cache. `0` disables caching. Integer ≥ 0. |
@@ -329,7 +346,10 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
329
346
  - **The six numeric settings are validated once, at creation.** Only an omitted key (`undefined`) takes the default. Any other value that is not a safe integer at or above the minimum — `null`, `NaN`, `1.5`, `Infinity`, `-1` or the string `"2"` — throws a `RangeError` that names the public key, for example `[daily-report] attachmentThumbnailCacheBytes must be an integer >= 0; got -1` or `[daily-report] attachmentConcurrency must be an integer >= 1; got "2"`.
330
347
  `createDailyReportService` checks `attachmentMaxBytes`; the handler factory checks the other five (`createDailyReportServer` runs both). The wiring check runs first, so a numeric key given without its port throws the `TypeError`.
331
348
  - **Handler tuning is per process.** Rate buckets, gates and the cache live in the handler factory's closure and are not shared across workers; the effective container-wide limit is the configured value times the number of workers. `createDailyReportServer` forwards the five tuning keys explicitly.
332
- - **Heap estimate per process**: `attachmentConcurrency × attachmentMaxBytes` for originals, plus `attachmentThumbnailConcurrency × (attachmentMaxBytes + the renderer's decode memory)` for thumbnails, plus `attachmentThumbnailCacheBytes`. The package's sharp renderer decodes only a source whose predicted working set is at most `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_BUDGET_BYTES` (200,000,000 bytes, server entry), and the prediction is built to stay above the measured one: read that constant in your own estimate instead of restating it (see **The sharp renderer** below).
349
+ - **An original's body is handed over slice by slice.** A `GET` of an original sends copies of 256 KiB slices of the bytes, one per read of the host's writer (the body queues nothing ahead of the writer), and keeps its concurrency slot exactly as long as it references the original: the slot is released when the last slice has been handed over, when the client cancels the body, or when the writer has not asked for the next slice for 60 s.
350
+ That idle deadline restarts on every read, so it bounds the time to send one slice, not the transfer (a reader slower than about 35 kbit/s, or one that stopped reading, cannot keep a slot and the original); when it passes, the body ends with an error and the rest of the original is dropped. A `HEAD` of an original holds its slot only until its answer is built.
351
+ - **Heap estimate per process**: while transfers progress, originals hold at most `attachmentConcurrency × attachmentMaxBytes` plus the one slice each response has handed to the host's writer, and a response stalled past the idle deadline holds only that one slice (its slot and the original are released); one viewer holds at most ⌈`attachmentConcurrency` / 2⌉ of the slots, so with two slots or more a viewer who stops reading several downloads cannot take every slot from the others.
352
+ Thumbnails add `attachmentThumbnailConcurrency × (attachmentMaxBytes + the renderer's decode memory)`, plus `attachmentThumbnailCacheBytes`. The package's sharp renderer decodes only a source whose predicted working set is at most `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_BUDGET_BYTES` (200,000,000 bytes, server entry), and the prediction is built to stay above the measured one: read that constant in your own estimate instead of restating it (see **The sharp renderer** below).
333
353
 
334
354
  **Read port**
335
355
 
@@ -345,7 +365,9 @@ type DailyReportReadAttachment = (
345
365
  >
346
366
  ```
347
367
 
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).
368
+ - Never throw; return a typed failure. `filePath` stays on the server.
369
+ - **`size` is the size of the whole object.** With `head: true` read metadata only and declare it: it is the HEAD's `Content-Length`, which must match a GET's (RFC 9110 §9.3.2), and a HEAD whose read declares no `size` answers without `Content-Length` rather than claim 0 for an object that has a body.
370
+ A GET's `Content-Length` is the length of the `bytes` it sends, so a read that declares `size` declares exactly `bytes.byteLength`: any other value — a port that measures the size in a separate call can see another version of the object — is a port contract violation, answered 500 and logged at `error` as `reason=port_contract code=size_mismatch`, with nothing sent and no missing- or present-object record.
349
371
  - **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
372
 
351
373
  | Reason | When | Original | Thumbnail |
@@ -373,7 +395,9 @@ type DailyReportReadAttachment = (
373
395
  - 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).
374
396
  - 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.
375
397
  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.
376
- - A thumbnail read still running at its deadline is answered with 502 (`reason=read_timeout`) without waiting for it; nothing is cached and the object is not marked missing. The generation slot stays held until the port actually settles (releasing it earlier would start the next read while this one still holds the original), and a settlement after the deadline is logged as `read_overrun ms=<elapsed>`.
398
+ - A thumbnail read still running at its deadline (`readMs`) is answered with 504 (`reason=read_timeout`) without waiting for it; nothing is cached and the object is not marked missing.
399
+ A storage read past a deadline is 504 whichever deadline passed first — the package's read stage or the port's own (`deadline`) — so the client sees the same status either way and the log line's reason names the deadline.
400
+ The generation slot stays held until the port actually settles (releasing it earlier would start the next read while this one still holds the original), and a settlement after the deadline is logged as `read_overrun ms=<elapsed>`.
377
401
  A read that has still not settled at twice its budget is logged once, at that moment, as `read_stuck ms=<elapsed>`: such a port keeps its slot for the life of the worker, and once every slot is held every thumbnail request waits `queueWaitMs` and answers 503. A port that honours the signal settles early and hands the slot to the next queued generation. Code that calls a `DailyReportReadAttachment` directly (tests, for example) must pass `signal`.
378
402
 
379
403
  Example — a read port over a host object store (the file is type-checked by `pnpm run typecheck:examples` and not shipped):
@@ -584,7 +608,7 @@ Example — the wiring (the ports are service keys; the tuning keys stay in the
584
608
  * 添付のどのキーも有効になる。結果の `attachment.loader` はホスト自身のバイト列配信ルート (`{apiBasePath}/attachment/{token}`) に
585
609
  * マウントし、サムネイルも同じルートを使う。
586
610
  */
587
- import { createDailyReportServer, type DailyReportIdCodec, type DailyReportServerConfig } from "@aiquants/daily-report/server"
611
+ import { createDailyReportServer, type DailyReportIdCodec, type DailyReportServer, type DailyReportServerConfig } from "@aiquants/daily-report/server"
588
612
  import { createObjectStoreReadAttachment, type ObjectStore, type ObjectStoreReadSettings } from "./read-attachment-port"
589
613
  import { thumbnailRenderer } from "./sharp-thumbnail-renderer"
590
614
 
@@ -606,10 +630,10 @@ export type AttachmentWiring = {
606
630
  * 添付配信とサムネイルを有効にした日報サーバーを作る処理。
607
631
  *
608
632
  * @param wiring Base configuration and the attachment dependencies. 基本設定と添付の依存。
609
- * @returns The server; mount its `attachment.loader` on the attachment route. サーバー (`attachment.loader` を添付のルートへマウントする)。
633
+ * @returns The server (`DailyReportServer`); mount its `attachment.loader` on the attachment route. サーバー (`DailyReportServer`。`attachment.loader` を添付のルートへマウントする)。
610
634
  * @throws {RangeError} When a numeric attachment setting in `base` is not an integer in range. `base` の添付の数値設定が範囲内の整数でないとき。
611
635
  */
612
- export const createDailyReportServerWithAttachments = ({ base, idCodec, storage }: AttachmentWiring): ReturnType<typeof createDailyReportServer> =>
636
+ export const createDailyReportServerWithAttachments = ({ base, idCodec, storage }: AttachmentWiring): DailyReportServer =>
613
637
  createDailyReportServer({
614
638
  ...base,
615
639
  attachmentIdCodec: idCodec,
@@ -643,13 +667,13 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
643
667
  **One decode per content key at a time**: a render that its generation no longer waits for — past its deadline, or after every requester left — keeps its slot until it settles, and while it runs, a new generation of the same identity does not queue for a slot: it first waits for that render to settle and its result to be recorded or dropped, then reads the cache, and only then queues at the gate.
644
668
  The wait for the still-running render and the wait for a slot share one `queueWaitMs` budget, and the generation's abort signal ends both; when the budget runs out first, the request is answered 503 `reason=queue` without a second read or decode. A generation that receives its slot also reads the cache again before it reads the original, so a request that waited at the gate behind a still-running render of the same content is answered from that render's result.
645
669
  The generation has its own abort signal, which fires only when every joined request has gone, and passes it to the gate wait and to both ports. An abandoned generation leaves the wait queue, and a port that honours the signal settles early and hands the slot to the next queued generation. A read that settles after the abort is discarded before its result is looked at (see **Read port** above) and nothing is rendered; a render that is running at the abort is kept as above.
646
- - **Time budget**: one budget bounds a cache miss from the generation gate onward — the wait for a slot, the read, the render and the transfer — so a request that has reached the gate leaves the server within the sum of those stages, and the client waits exactly that sum before counting a load as failed. Each port stage runs under its own deadline and answers 502 at the deadline even when the port ignores the signal; a deadline that passes after every requester has gone ends as 503 `aborted` instead, since nobody receives it.
647
- Two things are outside the budget. Authentication and authorization run before the gate and are bounded only by the host's own timeouts; their time is taken out of the client's sum as well. And the budget cannot end a port that ignores its signal: after the 502 such a port keeps its generation slot, and the server logs `read_stuck` / `render_stuck` once when it has not settled at twice its stage budget.
670
+ - **Time budget**: one budget bounds a cache miss from the generation gate onward — the wait for a slot, the read, the render and the transfer — so a request that has reached the gate leaves the server within the sum of those stages, and the client waits exactly that sum before counting a load as failed. Each port stage runs under its own deadline and answers at that deadline even when the port ignores the signal (the read with 504 `read_timeout`, the render with 502 `render_timeout`); a deadline that passes after every requester has gone ends as 503 `aborted` instead, since nobody receives it.
671
+ Two things are outside the budget. Authentication and authorization run before the gate and are bounded only by the host's own timeouts; their time is taken out of the client's sum as well. And the budget cannot end a port that ignores its signal: after the deadline's answer such a port keeps its generation slot, and the server logs `read_stuck` / `render_stuck` once when it has not settled at twice its stage budget.
648
672
 
649
673
  | Stage | Budget | At the deadline |
650
674
  | --- | --- | --- |
651
675
  | `queueWaitMs` — waiting for a generation slot | 30 s | 503 `reason=queue` (`Retry-After: 5`) |
652
- | `readMs` — reading the original | 30 s | 502 `reason=read_timeout` |
676
+ | `readMs` — reading the original | 30 s | 504 `reason=read_timeout` |
653
677
  | `renderMs` — decoding, resizing, encoding | 10 s | 502 `reason=render_timeout` |
654
678
  | `transferMarginMs` — sending the response (at most 1 MiB) | 5 s | (the client's margin) |
655
679
  | Client load timeout | 75 s (the sum) | the load counts as one failure |
@@ -657,7 +681,7 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
657
681
  - **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.
658
682
  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
683
  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.
684
+ It never stores unverified or unrecorded outcomes, `failed`, the answer of a stage deadline (504 `read_timeout`, 502 `render_timeout`), the 503 of an abort, storage errors and transient refusals (`not_found`, `unavailable`, `busy`, `deadline`), queue rejections or exceptions.
661
685
  - **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);
662
686
  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.
663
687
 
@@ -670,9 +694,9 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
670
694
  | 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 |
671
695
  | 405 | Any method other than GET (`Allow: GET`) |
672
696
  | 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`) |
673
- | 502 | Storage unreachable or a read ended early (`unavailable`), the port's `failed` (`render_failed`), or a stage deadline (`read_timeout`, `render_timeout`) |
697
+ | 502 | Storage unreachable or a read ended early (`unavailable`), the port's `failed` (`render_failed`), or the render stage's deadline (`render_timeout`) |
674
698
  | 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 |
699
+ | 504 | A storage read past a deadline: the read port's own (`deadline`) or the read stage's `readMs` (`read_timeout`); never cached |
676
700
  | 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) |
677
701
 
678
702
  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).
@@ -687,8 +711,8 @@ The package never writes the file path itself; a `port_exception` line includes
687
711
  | Level | Outcomes |
688
712
  | --- | --- |
689
713
  | `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 |
714
+ | `warn` | 404 `not_visible` (a sign of token enumeration), the storage 404s (`not_found`, `denied`, `invalid_path`), 413 (`db_size`, and the port's `too_large` on an original), the first 429 of a viewer's window per bucket and the window's `rate_limit_suppressed` / `revalidation_rate_limit_suppressed` line, the busy 503s (`queue`, `concurrency`, the storage's `busy`), every 502, the 504 of a storage read past a deadline (`deadline`, `read_timeout`), port settlements after a deadline (`read_overrun`, `render_overrun`), ports still unsettled at twice their stage budget (`read_stuck`, `render_stuck`) and thumbnail reads that do not match the row's size (`size_mismatch`: another length, or a `too_large` on a row whose size is known) |
715
+ | `error` | 500 (`port_exception`, `port_contract` including a read over `maxBytes` (`code=over_max_bytes`), an original's `GET` read whose declared `size` is not the length of its bytes (`code=size_mismatch`) 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 |
692
716
 
693
717
  `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.
694
718
 
@@ -750,12 +774,19 @@ An expired cached report is therefore drawn at once, never preceded by `null`, a
750
774
  The first load of a report that is not cached starts inside the hook's effect, with no task between the row's mount and its request, so a held arrow key's stream of keydowns cannot postpone it until the key is released; only the refetch of an expired cached report waits 120 ms, to absorb rows that only scroll past. Loads of reports of one business date share one request.
751
775
 
752
776
  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.
777
+ The provider takes no items: it derives its rows from the module-resident ids stream session (below), so establish the session first — `createDailyReportClientLoader` in the route, or `ensureDailyReportIdsStreamSession({ apiBasePath, userKey })` on mount, as `DailyReportPage` does — and mount one provider per user (`key={userId}`), which also starts the new user with an empty editing store (**Editing** in [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)).
753
778
 
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.
779
+ The report id list is **not** part of the loader data: a module-resident NDJSON stream session (`GET {apiBasePath}/ids-stream`, the API route's ids stream; resilient client with cursor resume + exponential backoff) supplies it.
755
780
  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.
756
781
  `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).
757
782
  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`.
758
783
  `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).
784
+
785
+ - **Rows follow the stream by its changes.** Every items publish of the session carries, with the list, its revision (`itemsRevision`, never reused by another session) and the net changes of the latest publishes, one per publish — the reports added, changed and removed — chained by revision (`itemsChanges`, the latest 32).
786
+ `DailyReportActionProvider` keeps its rows in the canonical order (business date descending, then id descending, reports without a date last) and applies only the changes since the revision it last derived, folded into one however many publishes a render receives: rows that arrive after the old last row cost one comparison and a native concatenation (a publish of Δ such rows touches at most Δ + 1 rows, the stream client included), rows that arrive out of order are merged in by binary search, a changed row is replaced in place (⌈log2 n⌉ touches), and only a removal filters the list once.
787
+ It derives from the whole list only on its first render, after the session is replaced, when it falls more than 32 publishes behind, and after the development cache clear drops the tombstones (which brings back the reports they hid). A row the provider shows before the stream holds it — the optimistic row of a report being created, a report the user created, a deletion the server refused and rolled back — stays until the stream confirms it, or until a settled scan published after it lacks it; the optimistic row goes when its creation settles.
788
+ - **Publishes coalesce.** Chunks and every relay into the session — the SSE `report-create`, `report-publish` and `report-delete`, and the user's own creations and deletions — share one 50 ms window: the first change after a quiet window publishes at once, the changes crowded into the window publish once at its end, and the completion of a scan publishes what is pending without waiting.
789
+ A burst of SSE events therefore reaches the list in one publish, at most 50 ms after the first; the user's own creation and deletion show at once all the same, because the provider adds or removes that row itself. The window is measured on a monotonic clock (`performance.now()`), so a wall clock set back does not lengthen it.
759
790
  There is no `dailyReportIds` prop and no deferred `/ids` JSON fetch (the old `ids` endpoint was removed).
760
791
 
761
792
  ### View height (host layout)
@@ -775,7 +806,7 @@ Each view (List and DetailList) is as tall as the space the host's layout leaves
775
806
  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.
776
807
  - **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.
777
808
  `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.
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.
809
+ 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 every ratio that is a multiple of 1/4 (k device pixels at the ratio k/4): 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 the other quarter ratios (1.25, 1.5, 1.75, 2.25, 2.5, 2.75) it does when the view's end lies on the lattice.
779
810
  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
811
  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
812
  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.
@@ -806,7 +837,7 @@ The keys are delegated to each view's **list**: the element that holds the view'
806
837
  - **Ignored keys**: `Alt` / `Ctrl` / `Meta` / `Shift` combinations other than `Ctrl+Home` / `Ctrl+End` alone (so `Ctrl+PageDown` and `Shift+End` keep their browser meaning); an event whose default is already prevented; IME composition (`isComposing` or `keyCode` 229);
807
838
  keys from outside a row (the scroll bar, the side pane, the mobile overlay); keys from an input region — a `form` (every control of the in-row edit form and of the comment form, their buttons included) or an editable element (`input`, `textarea`, `select`, `contenteditable` other than `"false"`, `audio[controls]`, `video[controls]`) —
808
839
  or from inside ARIA widgets that use these keys themselves (`combobox`, `listbox`, `menu`, `menubar`, `radiogroup`, `slider`, `spinbutton`, `textbox`, `tree`, `grid`, `scrollbar`, `tablist`, `toolbar`, `treegrid`, `separator`) between the key's origin and the view's scroll container, the exits included.
809
- So no key pressed in an open in-row editor moves the list (moving it would scroll the editing row out of the rendered window and discard what was typed). Only elements **inside** the view count: wrapping the whole view in a host `form`, `role="grid"`, `role="listbox"` or `contenteditable` region does not disable the keys. While the viewport has no size yet, the movement keys are not handled.
840
+ So no key pressed in an open in-row editor moves the list: the keys keep their meaning in the field (the caret, the text selection, the form's buttons), and the row being edited stays where the viewer types. Only elements **inside** the view count: wrapping the whole view in a host `form`, `role="grid"`, `role="listbox"` or `contenteditable` region does not disable the keys. While the viewport has no size yet, the movement keys are not handled.
810
841
  - **One Tab order rule for both views: only the Tab-stop row's controls are in the Tab order.** The Tab-stop row is the row that owns focus (below) while it is inside the visible range, otherwise the selected row while it is visible, otherwise the first visible row (until the view has reported its visible range: the focus owner, then the selection).
811
842
  Every focusable control the package draws inside a row reads the rule: the List card's primary button, ★ and 既読, the DetailList row's ★, 既読, 編集 (on the viewer's own reports) and 削除, the comment controls (delete and its confirmation, the comment field and its send button), the edit form's fields and buttons, and the attachment links keep their natural order in the Tab-stop row and have `tabIndex=-1` in every other row. The stop row's own element is a stop too where it takes focus: a DetailList row (`tabIndex=0`) and a List row frame whose card is not shown.
812
843
  So the Tab path through a view is one row's controls long, however many rows are rendered: crossing the List takes exactly 3 presses (the primary button, ★ and 既読). Because the focus owner is the stop, a control reached by pointer continues within its own row, and a row in edit mode keeps its form in the Tab order.
@@ -828,11 +859,13 @@ The keys are delegated to each view's **list**: the element that holds the view'
828
859
  A request stays pending only on the paths that send focus to a row that may lie outside the rendered window: the focus owner's report leaving the list and the selected report deleted from the side pane or the mobile overlay (the table below), and the List's return to the card whose overlay closed (its scroll runs in an effect after the commit). Such a request settles when its row registers, however long that takes;
829
860
  the next key, a `pointerdown`, `wheel` or `touchstart` in the view, and the destination leaving the list drop it, and a dropped request never settles later.
830
861
  Focus is only ever taken from nowhere — no element, `body`, or an element inside an `inert` subtree such as the mobile overlay while it slides out (such an element cannot keep focus; `isFocusNowhere` in `src/client/keyboard/dom-node.ts`) — or from inside the view; focus that has meanwhile moved to the side pane, the open overlay or a host element stays there and the request is dropped.
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.
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.
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.
862
+ - **A key move writes at the focus hand-over and commits once.** The row cursor owns the `tabindex` of every registered focus target (0 on the Tab-stop row, −1 on every other row; the row components render none): it writes the attribute when a target registers and when the row's Tab stop changes, and never rewrites a value the element already has.
863
+ A move first makes the destination current and the Tab stop while focus is still on the origin, without a React commit and without writing its `tabindex` yet. The cursor writes the destination's `tabindex="0"` at the hand-over of its focus move: inside the origin's `focusout`, while no element holds focus (before the move when no element held focus, right after it when the move hands nothing over, and before any later change of the rows' states when no focus move comes first).
864
+ Then focus arrives on the destination, and the selection and the Tab stop settle on it (the cursor writes the origin's `-1`). The rows' states, the host's selection and the scroll commit in one synchronous React commit.
865
+ Chromium's `focus()` recalculates the document's style twice whenever focus moves between two elements — right after the origin loses focus and right after the destination gains it — and once more at its entry when a style change is still pending there (a `tabindex` written before the call is one whenever the page's CSS has a `[tabindex]` selector). The hand-over write rides on the first of the two.
866
+ 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,
867
+ so a key whose destination is rendered forces exactly the two recalculations of its one `focus()` call and no layout (a destination that the key's own commit renders also has the entry recalculation of the rows that commit inserted): in Chromium 148 with a host `[tabindex]` selector, 8 key moves force 16 recalculations (24 when the destination's `tabindex` is written before the call; 16 either way without such a selector).
868
+ The app's keyboard harness counts the style and layout work done inside keydown tasks and, in a run of its own, records the JavaScript stack of each. The entries without a stack (at 4× CPU from row 200,000 in run `2026-10-05T16-45-08-477Z`: two recalculations and two layouts, DetailList 16.00 ms and 23.00 ms, List 3.76 ms and 2.42 ms) are the frame's own rendering update, which Chromium ran in the same task as the keydown; the keydown handler forces none of them, and each laid out from virtualscroll's items boundary (`.aqvs-items-boundary`), none from the document root.
836
869
  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.
837
870
  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.
838
871
  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.
@@ -856,10 +889,11 @@ The keys are delegated to each view's **list**: the element that holds the view'
856
889
  - **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
890
  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
891
  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.
892
+ So a click or a tap into any element of a row, a text field included, is never keyboard focus; a key press inside the view (other than a modifier alone) turns the focus into keyboard focus without moving it. The row and card focus outlines follow this predicate through the attribute below, not `:focus-visible` alone, so they never show while the view treats the focus as a pointer's (also where Chromium matches `:focus-visible` after a bare `Shift` or `CapsLock` that follows a pointer press).
860
893
  - **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
894
  - **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.
862
895
  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.
896
+ The keyboard-focus outlines of the row frames and the List cards paint only under it (**Selection and focus appearance**), so an outline is drawn only while the circle is hidden and the two never show together; the package's controls (buttons, links, tabs, switches, fields) keep their plain `:focus-visible` outlines.
863
897
  - **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.
864
898
  - **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.
865
899
  A change of the list alone (SSE inserts and deletes, a stale removal) keeps what is on screen in place. Rows are keyed by report id in both views, and both views anchor their scroll position on a report, the way CSS scroll anchoring does: the anchor is the first visible report, how many px of it are hidden above the viewport, the last visible row and the scroll position it was taken at, all read from `VirtualScroll`'s handle, whose position is current right after a scroll call.
@@ -879,13 +913,17 @@ The keys are delegated to each view's **list**: the element that holds the view'
879
913
  - **List side pane**: keys select through `onSelectItem` directly, so on a mobile layout they never open the detail overlay.
880
914
  On the desktop layout the pane follows the selection in a deferred render: its frame (`[data-testid="daily-report-side-pane"]`) takes the new `data-report-id` in the key's own render and carries `aria-busy="true"` until the content catches up, and the content (`data-displayed-report-id`) renders from `useDeferredValue` of the selection, so React draws it when the main thread is free and drops an unfinished catch-up for a newer key.
881
915
  While a held key repeats, the content keeps the report the repeat stream started from and does not render at all (the frame keeps following the selection, with `aria-busy="true"`); it catches up once, when the stream ends.
882
- The content is keyed by the displayed report: each change of the displayed report mounts it exactly once, so nothing of one report (its loaded detail, an open editor, its comments, its thumbnails) renders under the next, and coming back to a report mounts its attachments once (one conditional request per loaded thumbnail). The chosen tab (article or relations) belongs to the pane, and to the mobile overlay, and is kept across reports.
916
+ The content is keyed by the displayed report: each change of the displayed report mounts it exactly once, so nothing of one report (its loaded detail, an open editor, its comments, its thumbnails) renders under the next, and coming back to a report mounts its attachments once (one conditional request per loaded thumbnail) and shows an editor it left open as it was left, with what was typed (**Editing** below). The chosen tab (article or relations) belongs to the pane, and to the mobile overlay, and is kept across reports.
883
917
  Auto-read and the article tab's scroll to the top follow the displayed report. Auto-read marks an unread report read once the pane has dwelt on it for 500 ms: on the desktop layout while the pane has caught up with the selection (the displayed report is the selected one and the pane is not `aria-busy`), in the mobile overlay while the overlay is open. The clock stops when the pane stops dwelling (a newer key, a held key's stream, the overlay sliding out)
884
918
  and starts over when it dwells again, so a report that was only selected — never displayed —, passed over with a held key, or opened in the overlay and closed within 500 ms stays unread. A report whose read state the viewer toggled is not marked again while it stays displayed.
885
919
  The mobile overlay shows the report it opened, so its pane carries the same `data-displayed-report-id` and no `aria-busy`.
886
920
  The article tab scrolls to its end (where the comments are) only after the viewer's own comment has been posted; a comment that arrives over SSE, from another user or another tab, never moves what the viewer is reading.
887
921
  - **Screen readers**: each list references `labels.listKeyboardHelp` (visually hidden) through `aria-describedby` — the DetailList only when it handles keys (with `onSelectItem`); there is no live region, announcements come from focus.
888
- The List card's primary button is named by its content ("YYYY-MM-DD creator"), described first by the row state (`labels.rowState`: read or unread, and starred), then by the markers the card paints and then by its preview, and carries `aria-current="true"` when selected. A DetailList row is named by its report heading (date, author and subject; `aria-labelledby`) and described by the row state and the markers its header paints.
922
+ The List card's primary button is named by its content ("YYYY-MM-DD creator"), described first by its position in the list (`labels.listRowPosition`, a visually hidden text: en "3 of 40", ja 「全 40 件中 3 件目」; the row frame carries `aria-posinset` / `aria-setsize`, which the focused button cannot carry), then by the row state (`labels.rowState`: read or unread, and starred), then by the markers the card paints and then by its preview, and carries `aria-current="true"` when selected.
923
+ The position text reads the row's place from the same context as the row frame, so an insert or a delete before the rendered window re-renders the frames and these texts, never a card's body.
924
+ The total the position names is the size of the set the rows belong to — every row frame's `aria-setsize` and the header's total count read the same value, which `DailyReportResolvedContent` decides once for both views (`resolveRowSetSize`):
925
+ while the ids stream delivers, the total the server declared on the stream's first line (never fewer than the rows shown, and unchanged as chunks arrive); before the server has declared it, unknown — `aria-setsize="-1"`, the header shows `labels.totalCountLoading`, and the position reads `labels.listRowPositionInUnknownTotal` (en "Item 3", ja 「3 件目」); once the scan has completed, the rows shown.
926
+ A host that renders `DailyReportList` or `DailyReportDetailList` itself passes that size as `rowSetSize` (−1 while unknown), or omits it when its rows are the whole set; the views' `VirtualScroll` always counts the rows given. A DetailList row is named by its report heading (date, author and subject; `aria-labelledby`) and described by the row state and the markers its header paints.
889
927
  The markers are the attachment marker (`<labels.attachments>: <count>`) and the source badge (its full `name` when the configuration gives one, otherwise its label); a description references only the markers that are painted. A row without content (loading, failed, missing, editing) is named by its ISO business date, with `aria-busy="true"` while loading. At most one element per view carries `aria-current`. ★, 既読, 編集 and 削除 carry `aria-label` equal to their `title` and no `aria-pressed`.
890
928
  - **Document structure**: every report has a heading at `config.headingLevel` (an integer from 2 to 5, default 3; pick the level that continues the host page's outline). A DetailList card starts with a visually hidden heading `<date> <author> <subject>`, which names the row.
891
929
  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).
@@ -895,7 +933,12 @@ The keys are delegated to each view's **list**: the element that holds the view'
895
933
  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.
896
934
  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).
897
935
  - **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).
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).
936
+ A draft of the viewer's own opens in the editor by itself, in a DetailList row and in the side pane alike (another user's draft never does), without moving focus; it closes by itself only when the report turns published, and an editor the viewer opened stays open then.
937
+ The editor's state lives in one editing store per action provider, by report (`src/client/contexts/daily-report-draft-store.ts`), not in the row or the pane: whether the editor is open, whether it opened by itself, and the typed title and body.
938
+ So an open editor survives everything that unmounts what draws it — a DetailList row leaving the rendered window (a wheel, a drag, the scroll bar, a tap scroll, keys from another row, a host selection) and the side pane or the mobile overlay showing another report — and comes back with what was typed. Typing re-renders only the form, never the row, the pane's content or the list.
939
+ An SSE update of the same report never overwrites the typed values, and Save keeps them (what was saved is what is being typed). Cancel drops them and keeps a draft of the viewer's own from opening by itself again; a successful Publish and the report's deletion — by the viewer, by an SSE `report-delete`, or the removal of a report that no longer loads — drop the report's entry, while a deletion the server refuses keeps it along with the report.
940
+ The store lives as long as the provider, so leaving the page, a reload and a user switch empty it (a user never sees another user's typing).
941
+ 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).
899
942
  - **Deleting a comment**: the trash button of the viewer's own comment is a disclosure (`aria-expanded`; while open, `aria-controls` names the confirmation pill it shows under itself). The confirmation has no time limit (WCAG 2.2.1): it stays open until it is confirmed or cancelled — by pressing the trash button again, by Escape (the comment list takes it before the mobile overlay while the confirmation is open; an Escape that belongs to an IME composition is left alone), by focus leaving the trash button and the confirmation, or by a pointer press outside them.
900
943
  A cancel by the trash button or by Escape returns focus to the trash button when focus was on the confirmation.
901
944
  Confirming moves focus before the comment is hidden, to a destination computed from what remains: the next remaining trash button, else the previous one, else the comment section's heading (`tabIndex=-1`) when the section stays, else the report's own anchor — the row in the DetailList (through the view's focus request), the pane's report heading in the side pane and the mobile overlay — so focus never falls to `body`.
@@ -918,21 +961,25 @@ Selection and keyboard focus are two separate channels, identical in both views,
918
961
  | State | Channel | Geometry | Light | Dark | Forced colours |
919
962
  | --- | --- | --- | --- | --- | --- |
920
963
  | Selected | box-shadow ring; the surface paints no shadow outside it | 2 px, 0–2 px outside the surface | blue-500 | blue-500 | 2 px `Highlight` outline of the row frame in the ring's band (below) |
921
- | Keyboard focus on a row or card | outline | 2 px at offset 4 (4–6 px outside) | blue-600 | blue-400 | kept (system colour) |
964
+ | Keyboard focus on a row or card (only while the view root carries `data-daily-report-keyboard-focus`) | outline | 2 px at offset 4 (4–6 px outside) | blue-600 | blue-400 | kept (system colour) |
922
965
  | Keyboard focus on a control or link | outline | 2 px at offset 2 | blue-600 | blue-400 | kept |
923
966
  | Pointer focus | none | — | — | — | — |
924
- | Hover (cards) | blue-300 border, `shadow-lg` (a selected card paints no shadow), 2 px lift (motion-safe) | — | — | — | — |
967
+ | Hover (cards) | blue-300 border, `shadow-lg` (a selected card paints no shadow), a lift L of at most 2 px on whole device pixels (motion-safe; **Row gutter G** below) | — | — | — | — |
925
968
 
926
969
  The selected surface sets the shadow colour to transparent (`shadow-transparent`; the selection rules are the only ones that write a shadow colour), and every shadow size the surface can take — the resting `shadow-sm`, the hover `shadow-lg` and the mid-scroll reset `[[data-daily-report-scrolling]_&]:hover:shadow-sm`, whose specificity (0,3,0) beats the selection rules' (0,2,0) — reads its colour from that one variable (`--tw-shadow-color`).
927
970
  Whichever size rule wins the cascade, a selected surface therefore paints nothing outside its ring, at rest, hovered or mid-scroll, and the 2 px separation band below the ring is page colour like the other three edges (any shadow there tints the band: the resting `shadow-sm` shifts its relative luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to 3.12:1 against it).
928
971
 
929
- - **Row gutter G = 8 px** on all four sides of both row frames: ring 2 + separation 2 + outline 2 + hover lift 2. Every term is a CSS pixel quantity and is written in px (`p-[8px]`, the lift `-translate-y-[2px]`), because the ring and the outline do not scale with the root font size and a `rem` term would break the sum at any root other than 16 px; the lengths built on G — the focus reach, the toolbar insets, the frame radius — are px too. A row aligned to either edge of the viewport while hovered, selected and focused is therefore never clipped, at any root font size;
972
+ - **Row gutter G = 8 px** on all four sides of both row frames: G = 8 ≥ ring 2 + separation 2 + outline 2 + hover lift L, with L ≤ 2. Every term is a CSS pixel quantity and is written in px (`p-[8px]`, the lift's values), because the ring and the outline do not scale with the root font size and a `rem` term would break the sum at any root other than 16 px; the lengths built on G — the focus reach, the toolbar insets, the frame radius — are px too. A row aligned to either edge of the viewport while hovered, selected and focused is therefore never clipped, at any root font size;
930
973
  a DetailList row is its measured body plus 2G = 16.
974
+ The lift L is the largest whole number of device pixels within 2 CSS px at the device-pixel ratio dpr, ⌊2·dpr⌋ / dpr, over the ratios the layout lattice is built for: every multiple of 1/4 from 1 to 3. Where 2·dpr is whole (1, 1.5, 2, 2.5, 3) L is 2 px; at 1.25, 1.75, 2.25 and 2.75 it is 1.6 px (2 device pixels), 12/7 px (3), 16/9 px (4) and 20/11 px (5).
975
+ A 2 px lift would be 2.5, 3.5, 4.5 and 5.5 device pixels at those four ratios, which puts the hovered surface on a half device pixel and blends its 1 px border, the ring and the outline into the next row of device pixels.
976
+ The card sets the value on itself as the custom property `--aqdr-hover-lift` (`2px`, overridden once under each of the media conditions `resolution: 1.25dppx`, `1.75dppx`, `2.25dppx` and `2.75dppx` with `calc(⌊2·dpr⌋px / dpr)`), and the lift reads it (`translate-y-[calc(-1*var(--aqdr-hover-lift))]`); every class is a literal, so a host's Tailwind build and the package's standalone stylesheet compile the same rules.
931
977
  - **List slot P**: the List card's content is sized in rem, so the slot follows the page's root font size R: P(R) = 4 · ⌈(2G + 2 × (1 + 15) (the card's border and padding) + 4 (spare) + 6.75R) / 4⌉ = 4 · ⌈(52 px + 6.75 rem) / 4⌉, the sum rounded up to the 4 px layout lattice (`listRowSlotHeight`, `LAYOUT_LATTICE_PX`), so every row top stays on the lattice.
932
978
  R is read from the root element's computed style and read again whenever a hidden 1 rem probe inside the List (`data-daily-report-root-font-size-probe`) changes size, and the List renders its `VirtualScroll` only once P is known (measured before the first paint).
933
979
  That one value sizes the row frames, `VirtualScroll`'s rows and the keyboard's row geometry, and when it changes the List keeps the first visible row in place by rescaling the scroll position. At the default 16 px root the sum is exactly 160, so P = 160: the card is P − 2G = 144 px, cards are 16 px apart and an edge-aligned card sits 8 px from the edge (exactly 8 when the view's height is a whole number of device pixels, see **G-symmetric frame**). Roots of 12, 20 and 24 px give 136, 188 and 216 (the sums 133, 187 and 214, rounded up), so the card's spare space grows by less than 4 px.
934
980
  P is not a host contract: it follows the host's root font size, so a host or a test locates a row by `[data-daily-report-row="<id>"]` or through the test handle (below), never by its index times a slot height.
935
- - **Row slots on the lattice**: every row slot of both views is a multiple of the 4 px layout lattice u (one constant, `LAYOUT_LATTICE_PX` in `src/shared/layout-lattice.ts`, which the List slot, the DetailList estimate and the attachment tile's pad all read), which is a whole number of device pixels at the ratios 1, 1.25, 1.5, 1.75 and 2 (4, 5, 6, 7 and 8), so every row top sits on a device-pixel boundary wherever the list is scrolled (`VirtualScroll` snaps only the layer that moves the rows; a row starts on a device pixel when the rows above it add up to whole device pixels).
981
+ - **Row slots on the lattice**: every row slot of both views is a multiple of the 4 px layout lattice u (one constant, `LAYOUT_LATTICE_PX` in `src/shared/layout-lattice.ts`, which the List slot, the DetailList estimate and the attachment tile's pad all read), which is a whole number of device pixels at every ratio that is a multiple of 1/4 (k at the ratio k/4: 4, 5, 6, 7 and 8 at 1, 1.25, 1.5, 1.75 and 2),
982
+ so every row top sits on a device-pixel boundary wherever the list is scrolled (`VirtualScroll` snaps only the layer that moves the rows; a row starts on a device pixel when the rows above it add up to whole device pixels).
936
983
  The List slot is P above. A DetailList row is its measured body plus 2G, and the body — the frame's direct child in every state: the card, the skeleton, the edit form and the error — rounds its content height up to the lattice (`LATTICE_BLOCK_SIZE_CLASS_NAME`: `height: calc-size(auto, round(up, size, 4px))`), so rem line boxes at a root other than 16 px (a 25 px line at a 20 px root) leave the body on the lattice; at a 16 px root the content is already on it. Engines without `calc-size()` drop the declaration and keep the content height (see the browser floor table).
937
984
  A row that has not been measured yet is laid out at an estimate of 352 px (`UNMEASURED_ROW_HEIGHT_ESTIMATE_PX` = 88 u), also on the lattice, so the unmeasured rows above the window move the rows below by whole lattice steps. An image attachment tile is padded to the lattice as well (**Tile** in [Attachment display](#attachment-display)), so the rows below an image grid stay on it.
938
985
  - **List card**: a wrapping row of the primary button and the action row (markers and toggles), with the preview always on the next line. The primary button takes the remaining width but never less than 96 px, enough for the business-date pill, and the action row keeps to the card's end; on a card too narrow for both (a phone with a pinned host menu), the action row wraps under the button instead of squeezing it to nothing, and the card clips the preview lines that no longer fit.
@@ -940,6 +987,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
940
987
  - **Forced colours**: the ring (a box shadow) disappears, so the row frame draws the selection itself: a 2 px solid `Highlight` outline at offset −8 (−G), which puts the line in the ring's band, 0–2 px outside the surface. The frame's corner radius is the surface's radius plus G (`calc(var(--radius-2xl) + 8px)`, 24 at a 16 px root), so the line is concentric with the surface at every root font size.
941
988
  A DetailList row and a List row without a card read their own `aria-current`; a List row with a card reads its card's primary button (`:has(> [data-daily-report-card] > [aria-current=true])`). The selected row differs by shape as well as by colour (WCAG 1.4.1); the surface border stays 1 px in every state, and the focus outline (4–6 px outside) sits beside the selection line without touching it.
942
989
  - **State comes from the row itself**: a row frame styles its surface — the direct child that carries `data-daily-report-row-surface` in every load state — from its own `aria-current` / `:focus-visible`, and the List card surface styles itself from its direct-child primary button (`:has(> …)`). Conditions on an ancestor read only the attributes the package writes itself (`data-daily-report-scrolling`, `data-daily-report-keyboard-focus`), so an ancestor that carries shared attributes such as `aria-current` never lights up a row.
990
+ The focus outline of both surfaces also requires the view root's `data-daily-report-keyboard-focus` (`:where([data-daily-report-keyboard-focus]) <frame>:focus-visible > :where(<surface>)` and `:where([data-daily-report-keyboard-focus]) <card surface>:has(> <primary button>:focus-visible)`): the ancestor sits in `:where()`, so the rules keep their specificity of (0,2,0) and (0,3,0), and the attribute changes only when the input modality does, never per arrow key, so it restyles the surfaces once per change of modality.
943
991
  - **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.
944
992
  - **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.
945
993
  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.
@@ -958,7 +1006,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
958
1006
  Outside the rows, the side pane's tab panels are the package's own (`role="tabpanel"`, named by their trigger, hidden and empty while not selected) and read no computed style when they mount, and the scroll bar's business-day bubble reads its date and wheel state from a store of its own (`useSyncExternalStore`), so a change of the visible range or a wheel re-renders only the bubble, never the view or `VirtualScroll`.
959
1007
  The bubble sits at a constant offset from the thumb overlay's box, 16 px beside the bar, and follows the thumb's centre by `transform` alone, with no transition, and the thumb itself moves by a translate snapped to device pixels (`@aiquants/virtualscroll` 3.9.0); so a scroll step that keeps the rendering window writes no `top` or `left` and adds no layout from the document root while the bubble shows.
960
1008
  - **Focus indicators are the package's own**: every focus indicator the package draws is one of the outlines above, including those of its tabs, buttons, switches, inputs and text areas (`src/client/ui`); none uses the host's `--ring` (measured at 2.43:1 in light and 1.22:1 in dark against the tab list), and no element that carries a focus indicator transitions its colours (in Tailwind 4 `transition-colors` also fades the outline in).
961
- No element is scaled: a scaled element scales its outline with it. The switches are drawn at their real size — a 40 × 24 track whose transparent 4 px border (its style declared) frames a 16 px thumb that moves 16 px — so the 24 px track is its own target and its 2 px outline at offset 2 is drawn at full width. `src/client/ui/focus-indicator.spec.ts` checks the shipped CSS, including that no rule scales an element.
1009
+ No element is scaled: a scaled element scales its outline with it. The switches are drawn at their real size — a 40 × 24 track whose transparent 4 px border (its style declared) frames a 16 px thumb that moves 16 px — so the 24 px track is its own target and its 2 px outline at offset 2 is drawn at full width. `src/client/ui/focus-indicator.spec.ts` checks the shipped CSS, including that no rule scales an element and that every outline rule of a row or card surface requires `data-daily-report-keyboard-focus` on an ancestor.
962
1010
  - **Focus reach**: a control's outline reaches 4 px outside it (offset 2 + width 2 = 4 = u; a row's or a card's outline stays inside the gutter G), and every box that clips its content and holds focusable content keeps that reach inside its clipping edges, so no outline is ever cut.
963
1011
  The rows' gutter holds it in both views; the side pane's tab panels — the pane's scroll box, also inside the mobile overlay — carry `FOCUS_RING_REACH_CLASS_NAME` (`-mx-[4px] px-[4px] pb-[4px] scroll-p-[4px]`, in px like the outline it holds): the negative inline margin and the equal padding move the clipping edges 4 px out on both sides without moving or narrowing the content, `pb-[4px]` keeps the last control's outline, `scroll-p-[4px]` keeps the outline of a control that Tab scrolls to an edge, and the panel's 8 px top padding holds the top.
964
1012
  The side pane's header boxes (the date and author column and the tab list, which clip to reserve the scroll-bar gutter; **Side pane** below) move their clipping edges 4 px out on all four sides the same way (`-m-[4px] p-[4px]` in `SIDE_PANE_HEADER_BOX_CLASS_NAME`), so the heading's and the tabs' outlines stay whole.
@@ -1010,7 +1058,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
1010
1058
  **The 12 px tier**: every panel with a 12 px corner has one inset, 12, equal to its corner, so its content box's corner is concentric with the panel's — the reading panel, the DetailList metadata column and the DetailList skeleton's inner panel (`p-3`, no border), the placeholder panel, a posted comment and the bordered pill (1 + 11); a 24 px pill has the same 12 px padding at its round ends. The tab bars are not in that tier: their 4 px inset makes them concentric with their 8 px tabs (12 − 4 = 8).
1011
1059
  The only other exception is the visually hidden text. `src/client/ui/spacing-ladder.spec.ts` checks every class the package writes, that every bordered box's border + padding is on the ladder or equals its corner, and that every 12 px-corner panel's inset is 12.
1012
1060
  - **Origin on the 4 px lattice**: the views' centred column (at most 72 rem, `max-w-6xl`, centred with `mx-auto`) starts at the centring offset rounded down to a multiple of 4 px (`VIEW_COLUMN_ORIGIN_CLASS_NAME`: `ml-[max(0px,round(down,calc((100%-72rem)/2),4px))]`, part of `VIEW_COLUMN_CLASS_NAME`, so the loading and load-error screens start there too), less than 4 px left of the exact centre.
1013
- 4 CSS px is a whole number of device pixels at the ratios 1, 1.25, 1.5, 1.75 and 2 (4, 5, 6, 7 and 8), so every inline edge built on the origin — G, the ladder steps, the card, the row frame — starts on a whole device pixel.
1061
+ 4 CSS px is a whole number of device pixels at every ratio that is a multiple of 1/4 (k at the ratio k/4: 4, 5, 6, 7 and 8 at 1, 1.25, 1.5, 1.75 and 2), so every inline edge built on the origin — G, the ladder steps, the card, the row frame — starts on a whole device pixel.
1014
1062
  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.
1015
1063
  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.
1016
1064
  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.
@@ -1093,7 +1141,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
1093
1141
  Under HTTP/2 all tabs of a profile share one connection per origin, and the limit is the server's number of concurrent streams. The value stays 2 because it must be safe wherever HTTP/1.1 remains (development, E2E, TLS-inspecting proxies, deployments without HTTP/2), and it also bounds how many server-side renders one screen starts at once. Module state cannot see other tabs, so the page is the widest unit that can be counted; counting per component or per list would multiply the limit when the side pane and several DetailList cards show thumbnails at once.
1094
1142
  - A frame waiting for a slot keeps being observed: if it leaves view it gives up its place in the queue, and when it comes back it waits the 250 ms again before queuing.
1095
1143
  - A load ends on `load`, on `error`, or after 75 s without either, counted from the moment the slot was granted. 75 s is the sum of the server's time budget, which bounds a request from the generation gate onward, so the client does not give up on a response the server may still produce once the request has reached the gate; authentication and authorization come before the gate and their time is taken out of the same 75 s. The slot is returned then, and a timed-out load has its `src` removed first.
1096
- - A failed or timed-out load is retried once, 5 s later, through the same visible-settle-slot path (an `<img>` cannot see the status code, so a transient 503 / 502 looks the same as a permanent failure). Only when the retry also fails does the frame become `unavailable`.
1144
+ - A failed or timed-out load is retried once, 5 s later, through the same visible-settle-slot path (an `<img>` cannot see the status code, so a transient 503, 502 or 504 looks the same as a permanent failure). Only when the retry also fails does the frame become `unavailable`.
1097
1145
  The 5 s and the 60 s below are the server's own timing, read by both sides from one shared module (`src/shared/attachment-delivery-timing.ts`) because an `<img>` cannot follow a `Retry-After` it receives: 5 s is the `Retry-After` of the server's overload 503, and 60 s is the window of the per-minute rate buckets, the `Retry-After` of their 429.
1098
1146
  - **Page-wide progress**: the page remembers up to 1,024 thumbnail URLs (the least recently used is forgotten first). A URL that loaded before shows its image in the first render of a remounted frame, with no observation, slot or timer — for example after the arrow keys moved away from a report and back.
1099
1147
  Because thumbnails are `private, no-cache`, the browser revalidates such an image with one conditional request (answered 304 without a body when unchanged, after authorization); the revalidation waits for no settle time and takes no slot, and at most one runs per mounted frame. If it fails, the page forgets that the URL loaded, counts one failed attempt, and the frame goes on with the retry wait.
@@ -1125,7 +1173,7 @@ DOM hooks are `data-*` attributes, test ids and ARIA; class names are styling an
1125
1173
 
1126
1174
  | Hook | Element |
1127
1175
  | --- | --- |
1128
- | `[data-daily-report-row="<id>"]` | Row frame of both views (`role="listitem"` with `aria-posinset` / `aria-setsize`), in every load state |
1176
+ | `[data-daily-report-row="<id>"]` | Row frame of both views (`role="listitem"` with `aria-posinset` / `aria-setsize`; the set size is the declared total while the ids stream delivers and `-1` before it is declared, see **Screen readers**), in every load state |
1129
1177
  | `[data-daily-report-row-surface]` | Surface of a row frame: its direct child that paints the selection ring and the focus outline, in every load state (opaque and never animated; a loading pulse runs inside it) |
1130
1178
  | `[data-daily-report-card]` | List card surface (present while the card is shown) |
1131
1179
  | `button[data-daily-report-button="<id>"]` | List card primary button (selection, focus target, `aria-current`) |
@@ -1167,8 +1215,8 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1167
1215
  ```
1168
1216
 
1169
1217
  - **One surface for all wording.** The catalog covers the 11 engine chrome keys of
1170
- `@aiquants/virtualscroll` plus 83 own keys: field headings, the page title (`title`, passed to
1171
- `renderHeader` and used as the accessible name of both lists), the lists' keyboard help, the row state read to
1218
+ `@aiquants/virtualscroll` plus 85 own keys: field headings, the page title (`title`, passed to
1219
+ `renderHeader` and used as the accessible name of both lists), the lists' keyboard help, the row state and the List card's position read to
1172
1220
  screen readers, tabs, buttons, placeholders, `confirm()` / `alert()` texts, stream badges, the load-error screen
1173
1221
  and the mutation-failure message. The engine part of each catalog is `VIRTUAL_SCROLL_LABEL_CATALOGS[locale]`, reused by
1174
1222
  value, so the scroll arrows, the names of the scroll bar and its thumb, and the "No items" text always speak the same language as the rest.
@@ -1176,17 +1224,17 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1176
1224
  and the 11 engine keys of the resolved labels to their `VirtualScroll`. The engine label object keeps
1177
1225
  its identity while its values are unchanged, so rebuilding `config.labels` on every render does not
1178
1226
  re-render the memoized list subtree.
1179
- - **Formatter keys.** Ten keys take arguments and are functions: `totalCount(count)`,
1227
+ - **Formatter keys.** Twelve keys take arguments and are functions: `totalCount(count)`,
1180
1228
  `debounce(milliseconds)`, `streamProgressCount(loaded)`, `streamProgressRatio(loaded, total, percent)`,
1181
1229
  `streamRetrying(loaded)`, `reportLoadFailed(reportHubId)`, `reportSummaryLoadFailed(reportHubId)`,
1182
1230
  `interviewerWithAffiliation(name, affiliation)`, `operationFailed(operation)` (`operation` is a
1183
1231
  `DailyReportOperation`: `create` / `update` / `publish` / `delete` / `addComment` / `deleteComment` /
1184
- `toggleStar` / `toggleRead`) and `rowState({ isRead, isStarred })`. An override of such a key must be a function too.
1232
+ `toggleStar` / `toggleRead`), `rowState({ isRead, isStarred })`, `listRowPosition(position, total)` and `listRowPositionInUnknownTotal(position)`. An override of such a key must be a function too.
1185
1233
  - **Digit grouping follows the UI locale, not the runtime.** The built-in formatters group numbers with
1186
1234
  the BCP 47 tag bound to their locale (`en` → `"en-US"`, `ja` → `"ja-JP"`); a host override receives
1187
1235
  the raw number and formats it itself.
1188
1236
  - **Fail-fast**: an unsupported `locale` (`"EN"`, `"ja-JP"`, `"fr"`, `""`, `null`, ...) throws a
1189
- `RangeError` at render. So do an unknown `labels` key (a key error that lists the 94 keys), a string
1237
+ `RangeError` at render. So do an unknown `labels` key (a key error that lists the 96 keys), a string
1190
1238
  key whose value is not a string with non-whitespace content, and a formatter key whose value is not a
1191
1239
  function. An `undefined` value keeps the catalog value.
1192
1240
  - **`config` has a closed key set**: `apiBasePath`, `ssePath`, `draftLabelName`, `locale`, `labels`,
@@ -1223,7 +1271,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1223
1271
  grouping follows `locale` (`en` → `"en-US"`, `ja` → `"ja-JP"`) and dates stay in ISO form. Packages
1224
1272
  that do format dates (for example `@aiquants/period-slider`) name that separate axis `formatLocale`.
1225
1273
 
1226
- Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 94 keys, the 11 engine keys
1274
+ Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 96 keys, the 11 engine keys
1227
1275
  first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReportLabels(locale, labels)`
1228
1276
  (the effective frozen labels — the catalog object itself when `labels` is `undefined`), the resolved
1229
1277
  `locale` / `labels` on `useDailyReportConfig()`, and the types `DailyReportLocale` (= `VirtualScrollLocale`),
@@ -1271,7 +1319,7 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
1271
1319
  | `tabDetailList` | top-level tab | 📋 DetailList | 📋 詳細一覧 |
1272
1320
  | `viewTabList` | accessible name (`aria-label`) of the top-level tab list | Views | 表示の切り替え |
1273
1321
  | `treeComingSoon` | tree tab body | The tree view is coming soon | ツリーは現在準備中です |
1274
- | `totalCountLoading` | header annotation while loading | Total: loading... | 総件数: 読み込み中... |
1322
+ | `totalCountLoading` | header annotation while the size of the views' set is unknown (the loading screen, and the loaded screen before the ids stream declares its total) | Total: loading... | 総件数: 読み込み中... |
1275
1323
  | `createReport` | create button | New report | 日報作成 |
1276
1324
  | `createReportTitle` | create button tooltip | Create a daily report | 日報を作成 |
1277
1325
  | `devControls` | dev toolbox tooltip (`showDevControls`) | Development-only controls | 開発環境限定コントロール |
@@ -1315,7 +1363,7 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
1315
1363
  | `commentDeleteConfirm` | comment delete confirmation | Delete | 削除する |
1316
1364
  | `unknownUser` | author name when none is known (legacy / relational comment without a name, optimistic data of a user without a name) | Unknown | 不明なユーザー |
1317
1365
  | `sourceInternal` | built-in `Internal` badge (only when `sourceTypeConfigs` is omitted) | Original | オリジナル |
1318
- | `totalCount` | header annotation | `(1234)` → Total: 1,234 | `(1234)` → 総件数: 1,234 件 |
1366
+ | `totalCount` | header annotation: the size of the views' set (the declared total while the ids stream delivers, the rows once it completes) | `(1234)` → Total: 1,234 | `(1234)` → 総件数: 1,234 件 |
1319
1367
  | `debounce` | header annotation | `(300)` → Debounce: 300 ms | `(300)` → デバウンス: 300 ms |
1320
1368
  | `streamProgressCount` | stream badge before the total is known | `(1234)` → Loading 1,234 | `(1234)` → 読み込み中 1,234 件 |
1321
1369
  | `streamProgressRatio` | stream badge once the total is known | `(1234, 5000, 25)` → Loading 1,234 / 5,000 (25%) | `(1234, 5000, 25)` → 読み込み中 1,234 / 5,000 (25%) |
@@ -1324,7 +1372,9 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
1324
1372
  | `reportSummaryLoadFailed` | list card load failure | `(12345)` → Failed to load the summary of report 12,345. | `(12345)` → 日報 12,345 の概要読み込みに失敗しました。 |
1325
1373
  | `interviewerWithAffiliation` | interviewer chip | `("Sato", "Acme")` → Sato (Acme) | `("佐藤", "山田商事")` → 佐藤 (山田商事) |
1326
1374
  | `operationFailed` | error banner / `DailyReportErrorInfo.message` | `("update")` → Failed to save the report. Please try again later. | `("update")` → 日報の保存に失敗しました。時間をおいて再度お試しください。 |
1327
- | `rowState` | visually hidden row state, the first description of the List card's primary button and of a DetailList row | `({ isRead: false, isStarred: true })` → Unread, starred | `({ isRead: false, isStarred: true })` → 未読、スター付き |
1375
+ | `rowState` | visually hidden row state, the first description of a DetailList row and the second of the List card's primary button | `({ isRead: false, isStarred: true })` → Unread, starred | `({ isRead: false, isStarred: true })` → 未読、スター付き |
1376
+ | `listRowPosition` | visually hidden position of a List card in the list, the first description of its primary button (`position` is 1-based; `total` is the size of the views' set) | `(3, 1234)` → 3 of 1,234 | `(3, 1234)` → 全 1,234 件中 3 件目 |
1377
+ | `listRowPositionInUnknownTotal` | the same position while the size of the views' set is unknown (the ids stream has not declared its total yet), in place of `listRowPosition` | `(3)` → Item 3 | `(3)` → 3 件目 |
1328
1378
 
1329
1379
  ### External Source Badge Configuration (`sourceTypeConfigs`)
1330
1380
 
@@ -1412,6 +1462,7 @@ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000
1412
1462
  | Not authenticated | 200, `retry: 86400000` → `event: auth-required` (`Set-Cookie` forwarded) | terminated; calls `config.onSessionExpired()` once; never reconnects |
1413
1463
  | Authenticated but no internal user | 200, `retry: 86400000` → `event: forbidden` | terminated; no navigation (a reload cannot fix it); visible through `sseStatus` |
1414
1464
  | 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 |
1465
+ | A React Router single-fetch data request (`<path>.data`, `GET` or `HEAD`) | 404 JSON before authentication (`{"error":{"message":"Not Found"}}`, `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, no `Set-Cookie`), with no subscription or timer and no line in the refusal record ([Request isolation](#request-isolation)) | never sent by the client: `EventSource` requests the route itself |
1415
1466
  | Unknown endpoint | 404 | treated as retryable; backoff up to 30 s |
1416
1467
  | 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 |
1417
1468
  | 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 |
@@ -1448,12 +1499,12 @@ Every entry exports by name (no `export *`), so a module-internal helper never b
1448
1499
  - **shared** (`@aiquants/daily-report`, isomorphic):
1449
1500
  - Values: `buildDailyReportAttachmentUrl` / `parseDailyReportAttachmentQuery` / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_VARIANTS` / `isDailyReportAttachmentThumbnailVariant` (the attachment URL codec and the variant list), `DAILY_REPORT_SSE_TERMINAL_EVENTS` / `DAILY_REPORT_SSE_HEARTBEAT_MS` / `DAILY_REPORT_SSE_CURSOR_PARAM` / `isDailyReportSseStreamId` (the SSE wire constants),
1450
1501
  `dailyReportSseMessageSchema` (8 discriminated union types) with its members `connectedMessageSchema` / `reportCreateMessageSchema` / `reportUpdateMessageSchema` / `reportPublishMessageSchema` / `reportDeleteMessageSchema` / `statusUpdateMessageSchema` / `commentAddMessageSchema` / `commentDeleteMessageSchema` and the part schemas `dailyReportDetailSchema` / `dailyReportPostedCommentSchema` / `dailyReportExternalCommentSchema` / `dailyReportInterviewerSchema` / `dailyReportLabelDefSchema`,
1451
- `isIdsStreamChunkLine`, `normalizeBusinessDateKey`, `mergeComments(legacyComments, modernComments, currentUserId, unknownAuthorName)`.
1502
+ `isIdsStreamChunkLine`, `normalizeBusinessDateKey` (a `Date`'s local calendar day, or a string read by the strict parser of **Request values**; `null` for anything else), `mergeComments(legacyComments, modernComments, currentUserId, unknownAuthorName)`.
1452
1503
  - Types: `DailyReportItem` / `DailyReportDetail` / `DailyReportUser` / `DailyReportInterviewer` / `DailyReportLabelDef` / `DailyReportPostedComment` / `DailyReportExternalComment` / `DailyReportAttachmentSummary`, `DailyReportSseMessage` / `DailyReportSseStreamAnchor` / `DailyReportIdsStreamChunkLine`, `DailyReportAttachmentDeliveryRequest` / `DailyReportAttachmentInvalidQuery` / `DailyReportAttachmentThumbnailVariant` / `DailyReportAttachmentThumbnailBox`.
1453
1504
  - **client** (`@aiquants/daily-report/client`, React):
1454
1505
  - Components: `DailyReportPage` / `DailyReportResolvedContent` / `DailyReportList` / `DailyReportDetailList` / `DailyReportAttachmentIndicator`, the providers `DailyReportConfigProvider` / `DailyReportActionProvider` / `DailyReportErrorProvider`.
1455
- - Hooks: `useDailyReportConfig` / `useDailyReportActionContext` (`sseStatus`) / `useDailyReportDetail` / `useDailyReportPrefetch` / `useDailyReportComments` / `useDailyReportSseConnection` (returns `DailyReportSseConnectionStatus`) / `useDailyReportIdsStream` (state carries `streamAnchor`).
1456
- - The ids stream: `DailyReportIdsStreamClient` (`resync()`) / `DailyReportIdsStreamStatus` / `primeDailyReportIdsStreamSession` / `bootstrapDailyReportIdsStreamSession` / `ensureDailyReportIdsStreamSession` / `resyncDailyReportIdsStream`; the route helpers `createDailyReportClientLoader` / `dailyReportShouldRevalidate`.
1506
+ - Hooks: `useDailyReportConfig` / `useDailyReportActionContext` (`sseStatus`) / `useDailyReportDetail` / `useDailyReportPrefetch` / `useDailyReportComments` / `useDailyReportSseConnection` (returns `DailyReportSseConnectionStatus`) / `useDailyReportIdsStream` (state carries `streamAnchor`, and `itemsRevision` / `itemsChanges`, the revision and the latest net changes of `items`).
1507
+ - The ids stream: `DailyReportIdsStreamClient` (`resync()`, `has(reportHubId)`; its `now` option is the monotonic clock of the publish window) / `DailyReportIdsStreamStatus` / `primeDailyReportIdsStreamSession` / `bootstrapDailyReportIdsStreamSession` / `ensureDailyReportIdsStreamSession` / `resyncDailyReportIdsStream`; the route helpers `createDailyReportClientLoader` / `dailyReportShouldRevalidate`.
1457
1508
  - Configuration and wording: `defaultDailyReportClientConfig` / `resolveDailyReportLabels` / `DAILY_REPORT_LABEL_CATALOGS` / `DAILY_REPORT_LABEL_KEYS` / `resolveSourceTypeConfig`.
1458
1509
  - 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
1510
  - 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)).
@@ -1461,8 +1512,7 @@ Every entry exports by name (no `export *`), so a module-internal helper never b
1461
1512
  - Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor }`; `readSseStreamAnchor`; `attachmentDelivery`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
1462
1513
  - 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`.
1463
1514
  - 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)).
1465
- - Types: `DailyReportServerConfig` / `DailyReportServiceConfig` / `DailyReportTables` / `DailyReportDb` / `DailyReportAuthenticate` / `DailyReportAuthResult` / `DailyReportIdCodec` / `DailyReportRedisProvider` / `DailyReportSseReaderPort` (the shared reader `createDailyReportHandlers` takes) / `StreamEntry` / `ExternalReportFields`,
1515
+ - Types: `DailyReportServerConfig` / `DailyReportServer` (what `createDailyReportServer` returns) / `DailyReportHandlersConfig` / `DailyReportHandlers` (what `createDailyReportHandlers` takes and returns) / `DailyReportServiceConfig` / `DailyReportTables` / `DailyReportDb` / `DailyReportAuthenticate` / `DailyReportAuthResult` / `DailyReportIdCodec` / `DailyReportRedisProvider` / `DailyReportSseReaderPort` (the shared reader `createDailyReportHandlers` takes) / `StreamEntry` / `ExternalReportFields`,
1466
1516
  and for attachments `DailyReportAttachmentDelivery` / `DailyReportReadAttachment` / `DailyReportAttachmentError` / `DailyReportAttachmentFailure` / `DailyReportAttachmentThumbnailRenderer` / `DailyReportAttachmentThumbnail` / `DailyReportAttachmentThumbnailSourceFormat` / `DailyReportAttachmentThumbnailOutputMediaType` / `SharpLike`.
1467
1517
 
1468
1518
  MIT