@aiquants/daily-report 0.30.0 → 0.32.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.1**: install 3.11.1 or later.
29
+ The peer floor of `@aiquants/virtualscroll` is **3.11.5**: install 3.11.5 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).
@@ -109,7 +109,7 @@ The standalone build bundles every JSX utility (and maps the shadcn tokens), but
109
109
  ## Required database schema
110
110
 
111
111
  Seven tables: `DailyReportHub` (core/cross-source), `DailyReportInternal` (in-app content), `DailyReportComment`, `DailyReportLabel`, `DailyReportHub_Label`, `DailyReportUserStatus` (read status & stars), and the optional `DailyReportAttachment` (attachment metadata — omit it and the attachment feature degrades gracefully: `attachments` stays an empty array and the attachment endpoint returns 404 for every token).
112
- Attachment delivery additionally requires the `attachmentIdCodec` and `readAttachment` DI ports — service configuration keys (`DailyReportServiceConfig`), which `createDailyReportServer` accepts because `DailyReportServerConfig` extends the service configuration; see [Attachments](#attachments).
112
+ Attachment delivery additionally requires the `attachments` block (`DailyReportAttachmentsConfig`), which always carries the id codec and the read port: a key of the service configuration (`DailyReportServiceConfig`), which `createDailyReportServer` accepts because `DailyReportServerConfig` extends the service configuration; see [Attachments](#attachments).
113
113
  The factory then exposes `dailyReportServer.attachment.loader`, which the host app must mount on its own byte-serving route (e.g. `daily_report.api.attachment.$token` — a route separate from the `:endpoint` JSON router, whose fixed `Content-Type: application/json` + CSP headers are incompatible with byte delivery). The route must name its parameter `token`: a route without it is a configuration error that every request answers with 500 and an `error` log line (see **Thumbnail endpoint** below). The same route and loader also serve thumbnails (`?thumbnail=tile`); no extra route is needed.
114
114
 
115
115
  Existing apps can inject their own drizzle models (structural typing — see `DailyReportTables`). Greenfield projects can generate definitions:
@@ -140,6 +140,11 @@ export const dailyReportServer = createDailyReportServer({
140
140
  ],
141
141
  draftLabelNames: ["Draft", "Work in Progress"], // Draft label names (can specify a single string or array of candidates)
142
142
  enableDevCacheClear: import.meta.env.DEV, // Optional: allow the dev-only `intent=clearCache` (default false → 400)
143
+ attachments: { // Optional: attachment delivery (see Attachments); omit it for a deployment without attachments
144
+ idCodec: attachmentIdCodec, // Attachment id obfuscation (an instance separate from the user-id codec)
145
+ read: readAttachment, // DailyReportReadAttachment
146
+ thumbnails: { renderer: thumbnailRenderer }, // Optional: image previews (createSharpThumbnailRenderer(sharp))
147
+ },
143
148
  })
144
149
  ```
145
150
 
@@ -163,13 +168,35 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
163
168
  **Streaming routes**: the SSE route and the API route's ids stream (`GET {apiBasePath}/ids-stream`) answer with a body that streams until the client leaves. The package keeps that body from starting, or from being held, for a request nobody reads as a stream, so the two routes need no guard of their own:
164
169
 
165
170
  - **`HEAD`**: both answer the status and headers a `GET` would get, and start no work for the body.
166
- Everything that decides the status runs as for `GET` — the request isolation, authentication, the internal user, the endpoint, the resume cursor (a malformed ids cursor is still 400, a malformed SSE cursor still the `resync-required` frame) and the viewer's visibility —
171
+ 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), the ids stream's `forceRefresh` (400 for a value other than `true` / `false`) and the viewer's visibility —
167
172
  but the SSE route subscribes to nothing and starts no heartbeat and no visibility refresh timer, and the ids stream runs neither its full query nor its first-page query (so a resumed `GET`, which waits for the full query, can still end in a 500 that a `HEAD` does not see: it answers 200).
168
173
  React Router runs a resource route's loader for `HEAD` and drops the body without reading or cancelling it, and an adapter need not abort the request's signal once the response is sent, so body work started for a `HEAD` would run until the process ends. A `GET` whose signal is already aborted starts no body work either.
169
174
  - **Single fetch**: React Router answers a single-fetch data request (`<path>.data`) by running the route's loader and reading its whole body before it answers, so a `.data` request to either stream would hold every event, or the whole NDJSON, in server memory until the client leaves.
170
- The package's request isolation refuses them before authentication ([Request isolation](#request-isolation)): on the SSE route every one, and on the API route the ids stream's (`ids-stream.data`, with or without a cursor, and its trailing-slash form `ids-stream/_.data`), `GET` and `HEAD` alike, with a 404 JSON (`{"error":{"message":"Not Found"}}`, `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`) thrown the way a loader answers early.
175
+ 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
176
  A refused request authenticates nothing, subscribes to nothing, starts no timer and runs no query. The JSON endpoints (`report`, `business-date`) and the action keep serving single fetch, which React Router's own fetchers use.
172
- - **The API route's methods**: authentication comes first (401), then the endpoint — an unknown name is 404, an inherited name such as `toString` included — and then the method: one the endpoint does not answer is 405 with `Allow`, `GET` for the JSON endpoints (their ETag is computed from the body, so a `HEAD` would build the body only to discard it) and `GET, HEAD` for the ids stream.
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`.
178
+ 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.
179
+
180
+ **Request values**: the API route and the action read every value of a request — business dates, ids, time stamps, `forceRefresh` and the action's payload — with one strict parser each, after authentication and before any query or service call, and answer a value they do not accept with 400:
181
+
182
+ - **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.
183
+ 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.
184
+ 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.
185
+ - **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.
186
+ `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`.
187
+ 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`.
188
+ - **`forceRefresh`** (`parseForceRefreshParam`; the `business-date`, `report` and `ids-stream` endpoints): absent reads as `false`, and only `true` and `false` are accepted. Anything else — `yes`, `TRUE`, `1`, the empty value — is `{"error":{"message":"Invalid forceRefresh"}}`; the ids stream answers it before its `HEAD` short-circuit.
189
+ - **The action's form**: the action reads the body with a cap and parses the whole form before it resolves the internal user, the viewer's visibility or any intent (`readActionCommand` in `src/server/action-form.ts`, the only code that reads the form; the intents run on the parsed command and never see the form).
190
+ - **Size**: the body is read up to `ACTION_FORM_MAX_BYTES` (1 MiB, 1,048,576 bytes). A declared `Content-Length` above it is answered without reading the body, and a body without a declared length (chunked) or with a wrong one stops being read where the count of bytes passes the cap: both are 413 `{"error":"Form too large"}`, with no error log line.
191
+ The refusals are recorded per handler factory in windows of 60 s (`ACTION_FORM_REFUSAL_LOG_WINDOW_MS`) at `warn`: the window's first refusal as `413 action reason=form_too_large measured=<declared|counted> limit=1048576`, and, when the window counted more, one `form_too_large_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> measured=declared:<n>,counted:<n>` written by the window's own timer at its end.
192
+ The cap is the only bound of a report's body and a comment's text, which have no ceiling of their own.
193
+ - **Text only**: a body that is no form (no form media type, a broken multipart body, a body cut short) and a form with a file part anywhere are `{"error":"Invalid form"}`, without an error log line.
194
+ - **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`, and then the intent's own values below, or `Invalid intent` for a name that is no intent.
195
+ A `clearCache` that `enableDevCacheClear` does not open is `Invalid intent` as well, also before the user lookup. A star or read toggle echoes the timestamp as it was sent, `null` when none was sent.
196
+ - **`update`**: `title` and `content` change only the fields that are sent. A field that is not sent keeps its stored value, and the empty text clears the field, which is stored as NULL (one rule, the service's `storedText`).
197
+ The title is at most the length the injected title column declares, in UTF-16 code units (`service.titleMaxLength`, read from `tables.hub.title`: 200 for `defineDailyReportSchema`'s `nvarchar(200)`, which counts code units as `String.length` does, so a surrogate pair takes 2); a longer one is `Invalid title`. The package's client always sends both fields as typed.
198
+ - **`toggleStar` / `toggleRead`**: `isStarred` / `isRead` is exactly `true` or `false` (`parseWireBoolean`). Anything else — `TRUE`, `1`, `yes`, the empty text — is `Invalid isStarred` / `Invalid isRead`, and a missing one is `isStarred required` / `isRead required`.
199
+ - **`addComment`**: `content` is a non-empty text (`Content required` otherwise). **`deleteComment`**: `commentId` (**Ids** above).
173
200
 
174
201
  ### DI ports
175
202
 
@@ -180,14 +207,14 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
180
207
  - `externalSources[]` — When `Hub.source_type` matches, performs a `LEFT JOIN` on `Hub.source_id_num = idColumn` and converts fields via `mapRow`.
181
208
  - `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.
182
209
  - `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.
183
- - `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).
184
- - `attachmentIdCodec` / `readAttachment` / `attachmentThumbnailRenderer` / `attachmentMaxBytes` — Attachment delivery ports and size limit, owned by the service. See [Attachments](#attachments).
185
- - `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)).
186
- - Primary tuning parameters: `idsTtlMs` (180s) / `businessDateTtlMs` (300s) / `streamKey` / `streamMaxLen` / `loginRedirectPath`, plus the attachment tuning keys listed under [Attachments](#attachments).
210
+ - `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).
211
+ - `attachments` — Optional attachment delivery block (`DailyReportAttachmentsConfig`), owned by the service: the id codec and the read port it always carries, the size limit and the original route's tuning, and the optional `thumbnails` with the renderer port and its tuning. See [Attachments](#attachments).
212
+ - `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)), `[DailyReportIsolation]` from `warn` (the refusal record of [Request isolation](#request-isolation)) and `[DailyReportAction]` from `warn` (the record of the action's 413, **Request values** in [Server wiring](#server-wiring-di)).
213
+ - Primary tuning parameters: `idsTtlMs` (180s) / `businessDateTtlMs` (300s) / `streamKey` / `streamMaxLen` / `loginRedirectPath`; the attachment tuning lives in the `attachments` block ([Attachments](#attachments)).
187
214
 
188
215
  **Configuration errors** follow one convention on the server and on the client: the message reads `[daily-report] <path> must be <expectation>`, `<path>` being the public key or argument to fix, and ends with `; got <value>` only when it shows a value.
189
216
  A rejected value — a wrong type included, also a port or a function given with the wrong type — is a `RangeError` whose message ends with `; got <value>` (a string as JSON; a number, a boolean, `null` or `undefined` as written; an array as `array`; anything else only its `typeof`). An unknown key is a `RangeError` that lists the known keys as JSON strings: `<path> must be one of "<key>", "<key>", …; got "<unknown key>"`. A missing (`undefined`) function or port is a `TypeError`, without `; got`.
190
- For example `[daily-report] attachmentConcurrency must be an integer >= 1; got "2"`, `[daily-report] config key must be one of "apiBasePath", "ssePath", …; got "title"` and `[daily-report] readAttachment must be injected when attachmentIdCodec is given`.
217
+ For example `[daily-report] attachments.concurrency must be an integer >= 1; got "2"`, `[daily-report] config key must be one of "apiBasePath", "ssePath", …; got "title"` and `[daily-report] attachments.read must be injected when attachments is given`.
191
218
  The three helpers of `src/shared/config-errors.ts` (`configValueError`, `configKeyError`, `configPortError`) are the only code that constructs a `RangeError` or a `TypeError`, so every such message has the prefix and the form: `src/host-facing-errors.spec.ts` reads the sources' syntax tree (specs and test helpers aside) and fails on a construction anywhere else.
192
219
 
193
220
  ### Request isolation
@@ -201,10 +228,12 @@ The policy's `frameworkDataRefusal` receives the loader's arguments and returns
201
228
  | --- | --- |
202
229
  | `attachment.loader` (`ATTACHMENT_ISOLATION_POLICY`) | Every `<token>.data` — `GET` or `HEAD`, inline, download or thumbnail, from the same origin or from another origin's navigation alike — is refused with the attachment route's 404 (`Attachment not found`). React Router would read the original into memory and drop every protective header; the 404 carries no attachment content, so it is safe after React Router has replaced its headers, and a caller of the loader itself still sees the attachment security headers |
203
230
  | `sse.loader` (`SSE_ISOLATION_POLICY`) | Every one is refused with a 404 JSON (`{"error":{"message":"Not Found"}}`, `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`; `dataRequestRefusal`): the body never ends, and `EventSource` requests the route itself |
204
- | `api.loader` (`API_LOADER_ISOLATION_POLICY`) | Refused with the same 404 when `params.endpoint` names the ids stream (the whole NDJSON would be held); served for the JSON endpoints |
205
- | `api.action` (`NOT_NAVIGABLE`) | Served (React Router's own fetchers write through single fetch) |
231
+ | `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) |
232
+ | `api.action` (`ACTION_ISOLATION_POLICY`) | Served (React Router's own fetchers write through single fetch) |
206
233
 
207
- All four policies also decide the navigations they serve from another origin and build their own 403 (`crossSiteRejection`), so the route, its refusals and their response builders cannot be paired wrongly.
234
+ 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`).
235
+ 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.
236
+ 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`.
208
237
 
209
238
  | Request | Answer |
210
239
  | --- | --- |
@@ -296,13 +325,13 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
296
325
  | --- | --- | --- |
297
326
  | inline | `{apiBasePath}/attachment/{token}` | The original (shown by the browser for inline-safe types) |
298
327
  | download | `...?download=1` | The original with `Content-Disposition: attachment` |
299
- | thumbnail | `...?thumbnail=<variant>` | A downscaled preview in the named variant, when a renderer port is injected |
328
+ | thumbnail | `...?thumbnail=<variant>` | A downscaled preview in the named variant, when the `attachments` block carries `thumbnails` |
300
329
 
301
330
  - **Build and parse the URL with the shared codec** (root entry `@aiquants/daily-report`). `buildDailyReportAttachmentUrl(apiBasePath, token, request)` takes `{ kind: "inline" }`, `{ kind: "download" }` or `{ kind: "thumbnail", variant }` (the `DailyReportAttachmentDeliveryRequest` union), and `parseDailyReportAttachmentQuery(searchParams)` reads a query back into the same request or into `{ kind: "invalid", reason: "thumbnail_variant" | "download_value" | "conflict" }` (`DailyReportAttachmentInvalidQuery`). The parameter names are private to the codec.
302
331
  - **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).
303
332
  - **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).
304
333
  - **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.
305
- - **Methods**: the original (inline and download) answers `GET` and `HEAD` (a HEAD reads metadata only and reports the same `Content-Length` as GET); any other method answers **405** with `Allow: GET, HEAD`. The thumbnail answers `GET` only (see **Thumbnail endpoint** below). Both 405s are checked right after the query, before the ports and the token.
334
+ - **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.
306
335
  - **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);
307
336
  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.
308
337
  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) —
@@ -310,32 +339,36 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
310
339
  The three headers come from one set (`ATTACHMENT_RESPONSE_SECURITY_HEADERS`) that every path building an attachment response spreads last — the failure builder `attachmentFailureResponse`, which every JSON failure goes through, and the 200 and 304 of both deliveries — so no status the route answers can lose them. `Content-Security-Policy: default-src 'none'; sandbox` stays on the two content 200s.
311
340
  The one request whose answer the route does not control is React Router's single-fetch data request (`<token>.data`): React Router would read the original into memory, re-encode it and keep only `Set-Cookie` of its headers (no `Cache-Control`, Content Security Policy, `Content-Disposition` or any of the three), so the route refuses it before anything runs, with a 404 that carries no attachment content ([Request isolation](#request-isolation)).
312
341
 
313
- **Configuration**
314
-
315
- | Key | Owner | Default | Requires | Meaning |
316
- | --- | --- | --- | --- | --- |
317
- | `attachmentIdCodec` | service | not injected | `readAttachment` | Obfuscates attachment ids (use an instance separate from the user-id codec). Without the codec and the read port, the detail's `attachments` is always `[]` and the endpoint answers 404. |
318
- | `readAttachment` | service | not injected | `attachmentIdCodec` | Reads the original bytes (`DailyReportReadAttachment`). |
319
- | `attachmentThumbnailRenderer` | service | not injected | `readAttachment`, `attachmentIdCodec` | Renders the preview (`DailyReportAttachmentThumbnailRenderer`: `{ id, render }`; the package's sharp implementation is `createSharpThumbnailRenderer`). Validated when the service is created. Without it `hasThumbnail` is always `false` and every thumbnail request answers 404 (it never falls back to the original). |
320
- | `attachmentMaxBytes` | service | 32 MiB | `readAttachment` | Upper bound for an original, for every delivery. Keep it below the read port's wire limit. Integer ≥ 1. |
321
- | `attachmentRateLimitPerMinute` | handlers | 60 | `readAttachment` | Original downloads per user per minute; a token is spent before authorization. Integer ≥ 1. |
322
- | `attachmentConcurrency` | handlers | 4 | `readAttachment` | Simultaneous original reads. A slot is held while the original is referenced (below: until its body has been handed over, the client cancels it, or the client stops reading for 60 s), and one viewer holds at most half of the slots, rounded up (⌈n / 2⌉: 2 of the default 4). A request that finds no free slot, or whose viewer already holds that share, fails fast with 503 (`reason=concurrency`, `Retry-After: 5`). Integer ≥ 1. |
323
- | `attachmentThumbnailRateLimitPerMinute` | handlers | 120 | `attachmentThumbnailRenderer` | Thumbnail generations per user per minute, in a bucket separate from originals; revalidations (requests with `If-None-Match`) pay from a second bucket of ten times this value. Both admit before authorization, and an empty bucket answers 429 at once; a revalidation that does not end in a 304 also pays a generation token, right after authorization (see **Rate admission** below). Integer ≥ 1. |
324
- | `attachmentThumbnailConcurrency` | handlers | 2 | `attachmentThumbnailRenderer` | Simultaneous thumbnail generations (read + render), with a wait queue. Integer ≥ 1. |
325
- | `attachmentThumbnailCacheBytes` | handlers | 8 MiB | `attachmentThumbnailRenderer` | Total size of the thumbnail cache. `0` disables caching. Integer ≥ 0. |
326
-
327
- - **The service is the only owner of the delivery configuration.** The four service keys go to `createDailyReportService` / `createDailyReportServer`; the service resolves them once into the frozen `service.attachmentDelivery` (`DailyReportAttachmentDelivery`: `{ idCodec, readAttachment, thumbnailRenderer, maxBytes }`, uninjected ports `undefined`, `maxBytes` resolved to its default), and the handlers read only that object.
328
- The `hasThumbnail` flag in the lists and the endpoint's behaviour therefore always come from the same values. `createDailyReportHandlers` does not accept these keys; a hand-written service stand-in passed to it must provide `attachmentDelivery`.
329
- - **Keys work only together with the keys in the Requires column, and a partial wiring fails at creation.** A key given without a key it requires throws a `TypeError` that names both, for example `[daily-report] readAttachment must be injected when attachmentIdCodec is given` or `[daily-report] attachmentThumbnailRenderer must be injected when attachmentThumbnailConcurrency is given`.
330
- A key counts as given unless it is `undefined` (so `null` counts), and the first missing key is reported. So there are three valid wirings: no attachment port (no attachments; none of the other attachment keys either), the codec and the read port (originals only, with `attachmentMaxBytes` and the original tuning keys), or all three ports (originals and thumbnails, every key).
331
- `createDailyReportService` checks the ports and `attachmentMaxBytes`; `createDailyReportHandlers` checks the tuning keys against the ports of `service.attachmentDelivery` (a hand-written service stand-in is checked too); `createDailyReportServer` runs both.
332
- - **The six numeric settings are validated once, at creation.** Only an omitted key (`undefined`) takes the default. Any other value that is not a safe integer at or above the minimum — `null`, `NaN`, `1.5`, `Infinity`, `-1` or the string `"2"` — throws a `RangeError` that names the public key, for example `[daily-report] attachmentThumbnailCacheBytes must be an integer >= 0; got -1` or `[daily-report] attachmentConcurrency must be an integer >= 1; got "2"`.
333
- `createDailyReportService` checks `attachmentMaxBytes`; the handler factory checks the other five (`createDailyReportServer` runs both). The wiring check runs first, so a numeric key given without its port throws the `TypeError`.
334
- - **Handler tuning is per process.** Rate buckets, gates and the cache live in the handler factory's closure and are not shared across workers; the effective container-wide limit is the configured value times the number of workers. `createDailyReportServer` forwards the five tuning keys explicitly.
342
+ **Configuration**: one optional block, `attachments` (`DailyReportAttachmentsConfig`), a key of the service configuration (so of `createDailyReportServer`'s too). Without it the deployment has no attachments: the detail's `attachments` is always `[]` and the attachment route answers 404 for every delivery.
343
+ Its `thumbnails` (`DailyReportAttachmentThumbnailsConfig`) is optional too: without it `hasThumbnail` is always `false` and every thumbnail request answers 404 (it never falls back to the original).
344
+
345
+ | Path | Default | Meaning |
346
+ | --- | --- | --- |
347
+ | `attachments.idCodec` | required in the block | Obfuscates attachment ids (`DailyReportIdCodec`). Use an instance separate from the user-id codec, or the token of a user id in a response would read as an attachment token. |
348
+ | `attachments.read` | required in the block | Reads the original bytes (`DailyReportReadAttachment`, **Read port** below). |
349
+ | `attachments.maxBytes` | 32 MiB | Upper bound for an original, for every delivery. Keep it below the read port's wire limit. Integer ≥ 1. |
350
+ | `attachments.rateLimitPerMinute` | 60 | Original downloads per user per minute; a token is spent before authorization. Integer ≥ 1. |
351
+ | `attachments.concurrency` | 4 | 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. |
352
+ | `attachments.thumbnails.renderer` | required in `thumbnails` | Renders the preview (`DailyReportAttachmentThumbnailRenderer`: `{ id, render }`; the package's sharp implementation is `createSharpThumbnailRenderer`). Validated when the service is created (**Thumbnail renderer port** below). |
353
+ | `attachments.thumbnails.rateLimitPerMinute` | 120 | 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. |
354
+ | `attachments.thumbnails.concurrency` | 2 | Simultaneous thumbnail generations (read + render), with a wait queue. Integer ≥ 1. |
355
+ | `attachments.thumbnails.cacheBytes` | 8 MiB | Total size of the thumbnail cache. `0` disables caching. Integer ≥ 0. |
356
+
357
+ - **The block admits only the wirings that work.** The codec and the read port are required members of the block, and the renderer and the thumbnail tuning exist only inside `thumbnails`, so the type admits exactly three wirings: no block (no attachments), a block without `thumbnails` (originals only) and a block with `thumbnails` (originals and thumbnails). Every tuning value sits next to the port it tunes, and no combination of keys can be given that does nothing.
358
+ - **The service is the only owner of the delivery configuration.** `createDailyReportService` (and so `createDailyReportServer`) resolves the block once into the frozen `service.attachmentDelivery` (`DailyReportAttachmentDelivery`: `{ idCodec, read, maxBytes, rateLimitPerMinute, concurrency, thumbnails }` with every default resolved, `thumbnails` being `{ renderer, rateLimitPerMinute, concurrency, cacheBytes }` or `null`; `null` without the block), and the handlers read only that object.
359
+ The `hasThumbnail` flag in the lists and the endpoint's behaviour therefore always come from the same values. `DailyReportHandlersConfig` has no attachment key; a hand-written service stand-in passed to `createDailyReportHandlers` provides `attachmentDelivery` in the resolved shape, or `null`.
360
+ - **Values from outside the type check are validated once, at creation** (a host written in JavaScript, a configuration read from a file), by the convention under **Configuration errors**, each message naming the nested path.
361
+ A missing port is a `TypeError`: `[daily-report] attachments.idCodec must be injected when attachments is given`, `[daily-report] attachments.read must be injected when attachments is given` and `[daily-report] attachments.thumbnails.renderer must be injected when attachments.thumbnails is given`.
362
+ A rejected value is a `RangeError`: a block or a `thumbnails` that is not an object (`null` included), a codec without `encode` and `decode` functions, a read port that is not a function, a renderer of the wrong shape (**Thumbnail renderer port** below), and a number.
363
+ Only an omitted number (`undefined`) takes the default; any other value that is not a safe integer at or above the minimum, such as `null`, `NaN`, `1.5`, `Infinity`, `-1` or the string `"2"`, is refused: `[daily-report] attachments.thumbnails.cacheBytes must be an integer >= 0; got -1`, `[daily-report] attachments.concurrency must be an integer >= 1; got "2"`.
364
+ - **No attachment key outside the block.** A configuration that carries one of the nine attachment keys of the flat layout — `attachmentIdCodec`, `readAttachment`, `attachmentMaxBytes`, `attachmentRateLimitPerMinute`, `attachmentConcurrency`, `attachmentThumbnailRenderer`, `attachmentThumbnailRateLimitPerMinute`, `attachmentThumbnailConcurrency` or `attachmentThumbnailCacheBytes` —, even with the value `undefined`,
365
+ stops `createDailyReportService`, `createDailyReportHandlers` and `createDailyReportServer` with a `TypeError` that names the path that takes its value (`[daily-report] attachments.concurrency must be given in place of the flat key attachmentConcurrency`).
366
+ A host that still passes them fails at startup instead of silently losing its attachments; `CHANGELOG.md` maps every key to its path.
367
+ - **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. The handler factory builds them from `service.attachmentDelivery`, and builds no thumbnail bucket, gate or cache without `thumbnails`.
335
368
  - **An original's body is handed over slice by slice.** A `GET` of an original sends copies of 256 KiB slices of the bytes, one per read of the host's writer (the body queues nothing ahead of the writer), and keeps its concurrency slot exactly as long as it references the original: the slot is released when the last slice has been handed over, when the client cancels the body, or when the writer has not asked for the next slice for 60 s.
336
369
  That idle deadline restarts on every read, so it bounds the time to send one slice, not the transfer (a reader slower than about 35 kbit/s, or one that stopped reading, cannot keep a slot and the original); when it passes, the body ends with an error and the rest of the original is dropped. A `HEAD` of an original holds its slot only until its answer is built.
337
- - **Heap estimate per process**: while transfers progress, originals hold at most `attachmentConcurrency × attachmentMaxBytes` plus the one slice each response has handed to the host's writer, and a response stalled past the idle deadline holds only that one slice (its slot and the original are released); one viewer holds at most ⌈`attachmentConcurrency` / 2⌉ of the slots, so with two slots or more a viewer who stops reading several downloads cannot take every slot from the others.
338
- Thumbnails add `attachmentThumbnailConcurrency × (attachmentMaxBytes + the renderer's decode memory)`, plus `attachmentThumbnailCacheBytes`. The package's sharp renderer decodes only a source whose predicted working set is at most `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_BUDGET_BYTES` (200,000,000 bytes, server entry), and the prediction is built to stay above the measured one: read that constant in your own estimate instead of restating it (see **The sharp renderer** below).
370
+ - **Heap estimate per process**: while transfers progress, originals hold at most `attachments.concurrency × attachments.maxBytes` 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 ⌈`attachments.concurrency` / 2⌉ of the slots, so with two slots or more a viewer who stops reading several downloads cannot take every slot from the others.
371
+ Thumbnails add `attachments.thumbnails.concurrency × (attachments.maxBytes + the renderer's decode memory)`, plus `attachments.thumbnails.cacheBytes`. 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).
339
372
 
340
373
  **Read port**
341
374
 
@@ -351,7 +384,9 @@ type DailyReportReadAttachment = (
351
384
  >
352
385
  ```
353
386
 
354
- - Never throw; return a typed failure. `filePath` stays on the server. With `head: true` read metadata only and declare the real `size` (the HEAD `Content-Length` must match GET).
387
+ - Never throw; return a typed failure. `filePath` stays on the server.
388
+ - **`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.
389
+ 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.
355
390
  - **Failure reasons** form one closed union (`DailyReportAttachmentFailure`), and each has the status the handlers answer with; `code` is the raw storage code for the log line and never decides a status. A reason outside the union — which a host outside the type check can return — is a port contract violation on either delivery: 500, logged at `error` as `reason=port_contract code=failure_reason`, never guessed into another status.
356
391
 
357
392
  | Reason | When | Original | Thumbnail |
@@ -366,7 +401,7 @@ type DailyReportReadAttachment = (
366
401
 
367
402
  `busy`, `deadline` and `unavailable` are transient: a thumbnail generation concludes nothing from them, caches nothing and marks nothing missing. The requests that joined that generation receive the same failure, and the next request reads again.
368
403
  - **`principal` is for attribution and audit only: the read result must not depend on it.** The SQL predicate (`getAttachmentForUser`) is the only authorization boundary; the port reads the `filePath` of a row that has already been authorized. Thumbnail generation relies on this: requests waiting for the same content share one read, made with the first requester's `principal`, and the outcome is cached for every authorized viewer of that attachment.
369
- So a host whose storage enforces per-principal ACLs must not inject `attachmentThumbnailRenderer` (one requester's read and its cached outcome would reach authorized viewers outside that ACL), and `denied` is only for a refusal that is the same for every requester (a path outside the sandbox, a permission the service's own credentials lack).
404
+ So a host whose storage enforces per-principal ACLs must not configure `attachments.thumbnails` (one requester's read and its cached outcome would reach authorized viewers outside that ACL), and `denied` is only for a refusal that is the same for every requester (a path outside the sandbox, a permission the service's own credentials lack).
370
405
  - **The `bytes` of `ok: true` are the complete object, at most `maxBytes` long.** A read that ends early must fail (`unavailable`), and an object over `maxBytes` must fail as `too_large` without being read. Because an object can change between its size check and its read, the server checks what a read returns:
371
406
  - bytes over `maxBytes` are a port contract violation on either delivery: 500, logged at `error` as `reason=port_contract code=over_max_bytes`, and nothing is sent, rendered, cached or recorded;
372
407
  - a thumbnail generation compares what it observed with the size the attachment row declares (`fileSize`, which the content identity names): the length it read, or for a `too_large` only that the object is over `maxBytes`.
@@ -495,8 +530,8 @@ type DailyReportAttachmentThumbnailRenderer = {
495
530
 
496
531
  - `id` names the version of your rendering pipeline, for example `sharp-<version>/vips-<version>/webp-q75-e4/flatten-#ffffff/v1` (the id of the package's sharp renderer below, with the versions read from `sharp.versions` at run time), so a library update never leaves the id stale.
497
532
  **Change it whenever the output bytes change** — output format, quality, effort, library version, or how you implement the contract. The id is part of the content identity and therefore of the ETag: a new id makes both the in-process cache and browser revalidation (304) stop reusing old previews. If you change the output without changing the id, browsers keep the old preview through 304s.
498
- - `createDailyReportService` / `createDailyReportServer` check the port when they are created, in this order, by the convention under **Configuration errors**: a port that is not an object (`null` included)
499
- throws `RangeError` (`[daily-report] attachmentThumbnailRenderer must be an object with id and render; got null`), a missing `render` throws `TypeError` (`[daily-report] attachmentThumbnailRenderer.render must be a function`), a `render` that is given but is not a function throws `RangeError` (`…render must be a function; got "sharp"`), and an `id` that is not a string with non-whitespace content throws `RangeError` (`[daily-report] attachmentThumbnailRenderer.id must be a non-blank string naming the rendering pipeline version; got ""`).
533
+ - `createDailyReportService` / `createDailyReportServer` check the port (`attachments.thumbnails.renderer`) when they are created, in this order, by the convention under **Configuration errors**: a missing port throws `TypeError` (`[daily-report] attachments.thumbnails.renderer must be injected when attachments.thumbnails is given`), a port that is not an object (`null` included)
534
+ throws `RangeError` (`[daily-report] attachments.thumbnails.renderer must be an object with id and render; got null`), a missing `render` throws `TypeError` (`[daily-report] attachments.thumbnails.renderer.render must be a function`), a `render` that is given but is not a function throws `RangeError` (`…render must be a function; got "sharp"`), and an `id` that is not a string with non-whitespace content throws `RangeError` (`[daily-report] attachments.thumbnails.renderer.id must be a non-blank string naming the rendering pipeline version; got ""`).
500
535
  - `render`: fit the image inside `maxWidth` × `maxHeight` — the box of the requested variant — keeping the aspect ratio (never enlarge), apply the EXIF orientation, drop metadata, flatten transparency onto white, bound decode memory before decoding (a pixel count alone does not: a 16-bit sample takes twice the bytes of an 8-bit one), start no decoder other than the one for `format`, and never throw.
501
536
  - `signal` aborts when every request waiting for that content has gone or when the render stage's deadline passes (`renderMs`). A renderer that can stop (a remote image service, for example) should forward it and settle with `{ ok: false, reason: "failed" }`. A render cut short by the signal must never answer `unsupported`: an `unsupported` that settles after the generation stopped waiting, at the deadline or on the abort, is recorded as a content-determined outcome (below).
502
537
  A render still running at its deadline is answered with 502 (`reason=render_timeout`) without waiting for it. A renderer that cannot stop (in-process libvips) is still valid: the response is bounded by the deadline, but the generation slot stays held until the render settles (`render_overrun ms=<elapsed>`; a render that has still not settled at twice its budget is logged once as `render_stuck ms=<elapsed>`).
@@ -576,66 +611,70 @@ export const thumbnailRenderer = createSharpThumbnailRenderer(sharp)
576
611
 
577
612
  Reference values for sizing, not guarantees (one development host: sharp 0.35.5, libvips 8.18.7, Node 24, one libvips thread per image; p50 of warm runs): a 1920 × 1080 PNG screenshot about 35 ms, a 1280 × 800 PNG about 27 ms, a 12 MP JPEG about 79 ms, a 12 MP PNG about 200 ms with a process peak of about 194 MiB. A baseline JPEG is cheap because libvips shrinks it while decoding (a progressive one fills its coefficient buffer first, above); PNG cost grows with the pixel count.
578
613
 
579
- Example — the wiring (the ports are service keys; the tuning keys stay in the host's base configuration):
614
+ Example — the wiring (one `attachments` block with the codec, the read port, the per-process concurrency of both routes and the thumbnails):
580
615
 
581
616
  ```ts examples/attachment-wiring.ts
582
617
  /**
583
618
  * Example wiring of attachment delivery into `createDailyReportServer` (not shipped; type-checked by `pnpm run typecheck:examples`).
584
619
  * 添付配信を `createDailyReportServer` へ結線する例 (同梱しない。`pnpm run typecheck:examples` で型検査する)。
585
620
  *
586
- * The three ports are service keys, like `attachmentMaxBytes`; the tuning keys (`attachmentThumbnailConcurrency`, ...) configure the
587
- * handlers of one process and stay in the host's base configuration. Every attachment key works only together with the ports it
588
- * needs, and injecting all three ports makes every attachment key valid. Mount `attachment.loader` of the result on the host's own
589
- * byte-serving route (`{apiBasePath}/attachment/{token}`); thumbnails use the same route.
590
- * 3 つのポートは `attachmentMaxBytes` と同じくサービスのキーで、調整値 (`attachmentThumbnailConcurrency` など) は 1 プロセスの
591
- * ハンドラーの設定としてホストの基本設定に置く。添付のキーはどれも要るポートと組でしか効かず、3 つのポートをすべて注入すれば
592
- * 添付のどのキーも有効になる。結果の `attachment.loader` はホスト自身のバイト列配信ルート (`{apiBasePath}/attachment/{token}`) に
593
- * マウントし、サムネイルも同じルートを使う。
621
+ * Attachment delivery is one optional block, `attachments`: the id codec and the read port it always carries, the size limit and the
622
+ * original route's tuning, and the optional `thumbnails` with their renderer and tuning. Omitted values take their defaults, and the
623
+ * tuning applies per process. Mount `attachment.loader` of the result on the host's own byte-serving route
624
+ * (`{apiBasePath}/attachment/{token}`); thumbnails use the same route.
625
+ * 添付配信は任意の 1 つのブロック `attachments` にまとまる。ブロックが必ず持つ ID コーデックと読み取りポート、大きさの上限と原本の経路の調整値、
626
+ * 描画ポートと調整値を持つ任意の `thumbnails`。省いた値は既定値になり、調整値はプロセス単位で効く。結果の `attachment.loader` は
627
+ * ホスト自身のバイト列配信ルート (`{apiBasePath}/attachment/{token}`) にマウントし、サムネイルも同じルートを使う。
594
628
  */
595
629
  import { createDailyReportServer, type DailyReportIdCodec, type DailyReportServer, type DailyReportServerConfig } from "@aiquants/daily-report/server"
596
630
  import { createObjectStoreReadAttachment, type ObjectStore, type ObjectStoreReadSettings } from "./read-attachment-port"
597
631
  import { thumbnailRenderer } from "./sharp-thumbnail-renderer"
598
632
 
599
633
  /**
600
- * What the host supplies: its server configuration without the attachment ports, and what the read port is built from.
601
- * ホストが渡すもの: 添付のポートを除いたサーバー設定と、読み取りポートの材料。
634
+ * What the host supplies: its server configuration without the attachment block, what the read port is built from, and the per-process concurrency.
635
+ * ホストが渡すもの: 添付のブロックを除いたサーバー設定と、読み取りポートの材料と、プロセス単位の同時実行の上限。
602
636
  */
603
637
  export type AttachmentWiring = {
604
- /** The rest of the server configuration, including the attachment size limit and tuning keys. 添付のサイズ上限と調整値を含む、サーバー設定の残り。 */
605
- base: Omit<DailyReportServerConfig, "attachmentIdCodec" | "readAttachment" | "attachmentThumbnailRenderer">
638
+ /** The rest of the server configuration. 添付のブロックを除いたサーバー設定の残り。 */
639
+ base: Omit<DailyReportServerConfig, "attachments">
606
640
  /** Attachment id obfuscation (an instance separate from the user-id codec). 添付 ID の難読化 (ユーザー ID のコーデックとは別のインスタンス)。 */
607
641
  idCodec: DailyReportIdCodec
608
642
  /** Storage client and its read settings. ストレージクライアントとその読み取り設定。 */
609
643
  storage: { store: ObjectStore; settings: ObjectStoreReadSettings }
644
+ /** Simultaneous original reads and thumbnail generations per process (a host running several workers divides its budget by their count). プロセス単位の原本の読み取りとサムネイルの生成の同時実行の上限 (複数のワーカーを動かすホストは、予算をその数で割る)。 */
645
+ concurrency: { originals: number; thumbnails: number }
610
646
  }
611
647
 
612
648
  /**
613
649
  * Creates the daily-report server with attachment delivery and thumbnails enabled.
614
650
  * 添付配信とサムネイルを有効にした日報サーバーを作る処理。
615
651
  *
616
- * @param wiring Base configuration and the attachment dependencies. 基本設定と添付の依存。
652
+ * @param wiring Base configuration, the attachment dependencies and the per-process concurrency. 基本設定・添付の依存・プロセス単位の同時実行の上限。
617
653
  * @returns The server (`DailyReportServer`); mount its `attachment.loader` on the attachment route. サーバー (`DailyReportServer`。`attachment.loader` を添付のルートへマウントする)。
618
- * @throws {RangeError} When a numeric attachment setting in `base` is not an integer in range. `base` の添付の数値設定が範囲内の整数でないとき。
654
+ * @throws {RangeError} When a concurrency is not an integer of at least 1 (the message names its path, such as `attachments.thumbnails.concurrency`). 同時実行の上限が 1 以上の整数でないとき (メッセージは `attachments.thumbnails.concurrency` などのパスを名乗る)。
619
655
  */
620
- export const createDailyReportServerWithAttachments = ({ base, idCodec, storage }: AttachmentWiring): DailyReportServer =>
656
+ export const createDailyReportServerWithAttachments = ({ base, idCodec, storage, concurrency }: AttachmentWiring): DailyReportServer =>
621
657
  createDailyReportServer({
622
658
  ...base,
623
- attachmentIdCodec: idCodec,
624
- readAttachment: createObjectStoreReadAttachment(storage.store, storage.settings),
625
- attachmentThumbnailRenderer: thumbnailRenderer,
659
+ attachments: {
660
+ idCodec,
661
+ read: createObjectStoreReadAttachment(storage.store, storage.settings),
662
+ concurrency: concurrency.originals,
663
+ thumbnails: { renderer: thumbnailRenderer, concurrency: concurrency.thumbnails },
664
+ },
626
665
  })
627
666
  ```
628
667
 
629
- **`hasThumbnail`**: every `DailyReportAttachmentSummary` carries a required `hasThumbnail: boolean`. It is `true` only when both `attachmentThumbnailRenderer` and `readAttachment` are injected, the declared `fileType` is exactly one of `image/png`, `image/jpeg`, `image/gif` or `image/webp` (exact match: `IMAGE/PNG`, parameters and surrounding whitespace do not qualify), the known `fileSize` is at most `attachmentMaxBytes` (an unknown size qualifies; the read port's `too_large` enforces the bound), and `state` is not `"absent"`.
668
+ **`hasThumbnail`**: every `DailyReportAttachmentSummary` carries a required `hasThumbnail: boolean`. It is `true` only when the `attachments` block carries `thumbnails`, the declared `fileType` is exactly one of `image/png`, `image/jpeg`, `image/gif` or `image/webp` (exact match: `IMAGE/PNG`, parameters and surrounding whitespace do not qualify), the known `fileSize` is at most `attachments.maxBytes` (an unknown size qualifies; the read port's `too_large` enforces the bound), and `state` is not `"absent"`.
630
669
  On the SSE wire an attachment entry without `hasThumbnail` (published before the field existed) is accepted as `false` (`.default(false)`); a non-boolean value is rejected.
631
670
  The client places a thumbnail on this flag alone; it has no switch of its own.
632
671
 
633
672
  **Thumbnail endpoint (`?thumbnail=tile`)**
634
673
 
635
674
  - **GET only**: `HEAD` and every other method answer 405 with `Allow: GET` (checked right after the query). A HEAD response would need a generated body to report the same `Content-Length` as GET.
636
- - **Order**: request isolation (a React Router single-fetch data request, `<token>.data`, is the 404 before it; 403 for a request from another origin's page, a navigation included; [Request isolation](#request-isolation)) → authenticate → query (400) → method → ports → token → viewer → renderer port → rate admission (below; an empty bucket answers 429 without resolving visibility or running the authorization query)
675
+ - **Order**: request isolation (a React Router single-fetch data request, `<token>.data`, is the 404 before it; 403 for a request from another origin's page, a navigation included; [Request isolation](#request-isolation)) → authenticate → query (400) → method → the `attachments` block (404 without it) → token → viewer → its `thumbnails` (404 without them) → rate admission (below; an empty bucket answers 429 without resolving visibility or running the authorization query)
637
676
  → authorization (`getAttachmentForUser`, the SQL predicate) → eligibility, identity and ETag → `If-None-Match` (304) → the generation token of a revalidation (below) → not visible / not eligible (404) → cache → generation. The cache, joining a generation and the 304 all come **after** authorization, so a cached preview is never returned to a viewer who cannot see the attachment.
638
- - **Rate admission uses two buckets per user and process**. Every token is taken synchronously — before the first `await` on admission, right after authorization's last `await` otherwise — so concurrent requests can never spend one token twice. With L = `attachmentThumbnailRateLimitPerMinute`:
677
+ - **Rate admission uses two buckets per user and process**. Every token is taken synchronously — before the first `await` on admission, right after authorization's last `await` otherwise — so concurrent requests can never spend one token twice. With L = `attachments.thumbnails.rateLimitPerMinute`:
639
678
  - The **generation bucket** holds L tokens and refills L per minute. Every answer except a matching 304 needs one of its tokens. A request without `If-None-Match` can never be a 304, so it spends its token on admission or is answered 429 (`reason=rate_limit`) before authorization.
640
679
  A request that fails authorization (`not_visible`) or goes on to generate (a cache miss) keeps the whole token spent; a cache hit and a not-eligible 404 give back all but a tenth of it, the tenth paying for the authorization query. A refund refills the bucket first and never lifts it above L.
641
680
  - The **revalidation bucket** holds 10 L tokens (L ÷ the tenth) and refills 10 L per minute. A request with `If-None-Match` spends one of them on admission or is answered 429 before authorization (`reason=revalidation_rate_limit`); it holds no generation token while it waits for authorization.
@@ -647,7 +686,7 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
647
686
  A generation's outcome carries this identity only when what it read matched the row's size (**verified**; see **Read port** above); an **unverified** outcome gets no validator and is never cached.
648
687
  - **Revalidation**: a verified preview carries `Cache-Control: private, no-cache` and the ETag. `If-None-Match` is compared weakly (`W/` stripped, lists accepted); `*` never matches. A match answers 304 without touching the gate, storage or renderer. Re-mounted thumbnails cost one 304 round trip, and a logout or a visibility change takes effect on the next revalidation.
649
688
  An unverified preview is sent with `Cache-Control: no-store` and no `ETag`: the browser neither keeps nor revalidates it, so a later `If-None-Match` can never pin it through 304s, and the next view generates again.
650
- - **Generation**: a process-local gate with `attachmentThumbnailConcurrency` slots and a FIFO wait queue (at most 64 waiting, 8 per user, waiting at most `queueWaitMs`). A slot is held while the original is read and rendered. Concurrent requests for the same identity join one generation; the joined requests share one read made with the first requester's principal. A request that disconnects withdraws from the shared generation, but its handler still settles with the shared outcome (which no client receives).
689
+ - **Generation**: a process-local gate with `attachments.thumbnails.concurrency` slots and a FIFO wait queue (at most 64 waiting, 8 per user, waiting at most `queueWaitMs`). A slot is held while the original is read and rendered. Concurrent requests for the same identity join one generation; the joined requests share one read made with the first requester's principal. A request that disconnects withdraws from the shared generation, but its handler still settles with the shared outcome (which no client receives).
651
690
  **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.
652
691
  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.
653
692
  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.
@@ -662,7 +701,7 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
662
701
  | `transferMarginMs` — sending the response (at most 1 MiB) | 5 s | (the client's margin) |
663
702
  | Client load timeout | 75 s (the sum) | the load counts as one failure |
664
703
 
665
- - **Cache**: an LRU bounded by `attachmentThumbnailCacheBytes` and 1,024 entries (each entry weighs its body plus 256 bytes). A preview is stored as an owned copy sized exactly to its body, so a view into a larger buffer never keeps that buffer alive, and later writes to the port's buffer do not change the cached preview.
704
+ - **Cache**: an LRU bounded by `attachments.thumbnails.cacheBytes` 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.
666
705
  It stores only verified outcomes: successful previews and the content-determined failures (signature mismatch and the port's `unsupported`), including those a render delivers after its generation stopped waiting for it, past its deadline or after every requester left (see **Thumbnail renderer port** above).
667
706
  A `too_large` conclusion is cached only when the row's recorded size is over the limit, which eligibility already answers with the not-eligible 404 before any read; so the endpoint never caches a `too_large`: on a row whose known size is within the limit it is unverified, and on a row without a recorded size it is unrecorded (answered with the 404 with `Cache-Control: no-store`, and read again on the next view; see **Read port** above).
668
707
  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.
@@ -675,7 +714,7 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
675
714
  | 304 | `If-None-Match` matches (carries `Cache-Control`, `ETag`, `X-Content-Type-Options: nosniff`, `Cross-Origin-Resource-Policy: same-origin`, `X-Frame-Options: SAMEORIGIN`, `Set-Cookie`) |
676
715
  | 400 | A query that names no delivery (unknown or repeated variant such as `?thumbnail=1`, combined with `download`), or a malformed token |
677
716
  | 401 / 403 | Not authenticated / no internal user, or (403, before authentication, the query and the method) a request from another origin's page that the isolation refuses ([Request isolation](#request-isolation): it serves no thumbnail to another origin, not even to a navigation) |
678
- | 404 | Ports not injected, not visible, not eligible, signature mismatch, `unsupported`, `too_large`, `not_found`, `denied`, `invalid_path`, and (before authentication) a React Router single-fetch data request (`<token>.data`) — one identical body for all |
717
+ | 404 | No `attachments` block (both deliveries) or no `attachments.thumbnails`, 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 |
679
718
  | 405 | Any method other than GET (`Allow: GET`) |
680
719
  | 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`) |
681
720
  | 502 | Storage unreachable or a read ended early (`unavailable`), the port's `failed` (`render_failed`), or the render stage's deadline (`render_timeout`) |
@@ -687,7 +726,7 @@ Error responses are JSON (`{ "error": { "message": "..." } }`) with `Cache-Contr
687
726
  Log lines carry `attachment=<id> viewer=<id> variant=thumbnail`, plus `cache=hit|miss|revalidated` once the cache stage is reached; the 200 and 404 lines of an unverified outcome end with `identity=unverified`, and the 404 line of an unrecorded `too_large` with `size=unrecorded`.
688
727
  Unexpected exceptions outside generation go to the loader's shared catch and are logged as `500 attachment=? viewer=? reason=unexpected message=<msg>` (no `variant`).
689
728
  A route that mounts `attachment.loader` without a `token` route parameter is the host's configuration error, on both deliveries: right after the port check the loader throws `[daily-report] params.token must be passed by the route that mounts attachment.loader; declare a "token" route parameter ({apiBasePath}/attachment/{token})`, which that catch logs and answers 500, without decoding an empty token or querying the database.
690
- Rejections up to the renderer-port check (401 / 400 / 405 / 404 for a missing port / the 403 of a viewer without an internal user) are not logged by the loader; the request isolation's 403 goes to the handlers' bounded refusal record instead (at most two lines per 60-s window, [Request isolation](#request-isolation)), and the isolation's 404 of a `.data` request is not logged.
729
+ Rejections up to the thumbnails check (401 / 400 / 405 / the 404 of a deployment without the `attachments` block or its `thumbnails` / the 403 of a viewer without an internal user) are not logged by the loader; the request isolation's 403 goes to the handlers' bounded refusal record instead (at most two lines per 60-s window, [Request isolation](#request-isolation)), and the isolation's 404 of a `.data` request is not logged.
691
730
  The package never writes the file path itself; a `port_exception` line includes the port's own error message verbatim, so keep paths out of your port's error messages.
692
731
 
693
732
  **Log levels** (both deliveries; the line formats are fixed):
@@ -696,7 +735,7 @@ The package never writes the file path itself; a `port_exception` line includes
696
735
  | --- | --- |
697
736
  | `info` | 200, 304, the 503 nobody receives (`reason=aborted`), 404 `not_eligible`, and the content-determined 404s (`signature`, `unsupported`, `too_large`; cached when verified, `too_large size=unrecorded` on a row without a recorded size) |
698
737
  | `warn` | 404 `not_visible` (a sign of token enumeration), the storage 404s (`not_found`, `denied`, `invalid_path`), 413 (`db_size`, and the port's `too_large` on an original), the first 429 of a viewer's window per bucket and the window's `rate_limit_suppressed` / `revalidation_rate_limit_suppressed` line, the busy 503s (`queue`, `concurrency`, the storage's `busy`), every 502, the 504 of a storage read past a deadline (`deadline`, `read_timeout`), port settlements after a deadline (`read_overrun`, `render_overrun`), ports still unsettled at twice their stage budget (`read_stuck`, `render_stuck`) and thumbnail reads that do not match the row's size (`size_mismatch`: another length, or a `too_large` on a row whose size is known) |
699
- | `error` | 500 (`port_exception`, `port_contract` including a read over `maxBytes` (`code=over_max_bytes`) and a read failure reason outside the union (`code=failure_reason`), the loader's `unexpected`, a route without the `token` parameter among them) and failed `markAttachmentMissing` / `markAttachmentPresent` writes |
738
+ | `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 |
700
739
 
701
740
  `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.
702
741
 
@@ -758,12 +797,23 @@ An expired cached report is therefore drawn at once, never preceded by `null`, a
758
797
  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.
759
798
 
760
799
  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.
800
+ 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)).
801
+ The provider builds its actions once — the functions, the ledgers of the optimistic state and the viewing user, in one value whose identity changes only with the viewing user — and hands out its state apart: the rows (`items`), the version that announces a change of the ledgers, and the SSE state (`isSseEnabled`, `sseStatus`). Each function calls the implementation of the last committed render, so a function kept from an earlier render does what the current one does.
802
+ The package's rows, List cards, DetailList rows and report cards, the side pane's content and the edit form read only the actions, so an ids stream publish, an SSE message or a change of the connection status re-renders none of them; of a report's parts, a version bump re-renders only its comment section (`ReportComments`). `useDailyReportActionContext()` returns the actions and the state as one value, the same value on a render where the state did not change; its caller re-renders whenever the state changes.
761
803
 
762
804
  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.
763
805
  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.
764
806
  `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).
765
807
  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`.
766
808
  `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).
809
+
810
+ - **Rows follow the stream by its changes.** Every items publish of the session carries its revision (`itemsRevision`, never reused by another session), the number of reports it holds (`loadedCount`) and the net changes of the latest publishes, one per publish — the reports added, changed and removed — chained by revision (`itemsChanges`, the latest 32).
811
+ The list itself is not part of a publish, so a publish costs the size of its change, never the list's length; a reader that needs the whole list builds it when it reads (`DailyReportIdsStreamClient.readItems()`: the list as of the last publish, with the changes still waiting in the coalescing window taken back).
812
+ `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.
813
+ It reads the whole list (once) and derives from it 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).
814
+ A report the user deleted carries a deletion mark until a settled scan lacks it, and a marked report is never shown, also when the stream's net change for it is a value change (a deletion and a relay inside one coalescing window, or a change derived after the mark). 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.
815
+ - **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.
816
+ 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.
767
817
  There is no `dailyReportIds` prop and no deferred `/ids` JSON fetch (the old `ids` endpoint was removed).
768
818
 
769
819
  ### View height (host layout)
@@ -783,7 +833,7 @@ Each view (List and DetailList) is as tall as the space the host's layout leaves
783
833
  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.
784
834
  - **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.
785
835
  `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.
786
- It adds nothing — the surface sits exactly G from the edge — when the view's edge and the row slots are whole numbers of device pixels. The slots are multiples of the 4 px lattice (**Row slots on the lattice**), a whole number of device pixels at the ratios that are multiples of 1/4 (1, 1.25, 1.5, 1.75, 2, 3): at the integer ratios a view's end on a whole CSS pixel is on a device pixel too, so the bottom-aligned surface sits exactly G there, and at 1.25, 1.5 and 1.75 it does when the view's end lies on the lattice.
836
+ 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.
787
837
  Anywhere else — a view's end off the lattice at those ratios, or browser zoom such as 0.9, 1.1 or 1.33 — the snap keeps the surface at least G and less than G + 1 device pixel from the end (measured in Chromium: 8.2 px at 1.25 and 8.333 px at 1.5 for a view's end 1 and 3 px off the lattice, 8.091 px at 1.1, 8.052–8.173 px at 1.33, 8.778 px at 0.9).
788
838
  The host's part is its bars, not the window: it sizes the bars around the views — a header and a footer — on the lattice unit, exported as `DAILY_REPORT_LAYOUT_LATTICE_PX` (4 CSS px, client entry; the package's own unit, not a copy), so the views' top edge lies on the lattice. It cannot size the window, so the remainder of the window height modulo the unit stays inside the view, whose end lies on the lattice only when the window height is a multiple of the unit.
789
839
  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.
@@ -814,7 +864,7 @@ The keys are delegated to each view's **list**: the element that holds the view'
814
864
  - **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);
815
865
  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]`) —
816
866
  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.
817
- 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.
867
+ 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.
818
868
  - **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).
819
869
  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.
820
870
  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.
@@ -890,14 +940,17 @@ The keys are delegated to each view's **list**: the element that holds the view'
890
940
  - **List side pane**: keys select through `onSelectItem` directly, so on a mobile layout they never open the detail overlay.
891
941
  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.
892
942
  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.
893
- 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.
943
+ 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.
894
944
  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)
895
945
  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.
896
946
  The mobile overlay shows the report it opened, so its pane carries the same `data-displayed-report-id` and no `aria-busy`.
897
947
  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.
898
948
  - **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.
899
949
  The List card's primary button is named by its content ("YYYY-MM-DD creator"), described first by its position in the list (`labels.listRowPosition`, a visually hidden text: en "3 of 40", ja 「全 40 件中 3 件目」; the row frame carries `aria-posinset` / `aria-setsize`, which the focused button cannot carry), then by the row state (`labels.rowState`: read or unread, and starred), then by the markers the card paints and then by its preview, and carries `aria-current="true"` when selected.
900
- The position text reads the row's place from the same context as the row frame, so an insert or a delete before the rendered window re-renders the frames and these texts, never a card's body. A DetailList row is named by its report heading (date, author and subject; `aria-labelledby`) and described by the row state and the markers its header paints.
950
+ 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.
951
+ 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`):
952
+ 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.
953
+ 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.
901
954
  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`.
902
955
  - **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.
903
956
  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).
@@ -907,7 +960,12 @@ The keys are delegated to each view's **list**: the element that holds the view'
907
960
  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.
908
961
  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).
909
962
  - **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).
910
- A draft that opens in the editor by itself does not move focus. The editor is a real `<form>` whose submission is cancelled: Save, Publish and Cancel are `type="button"`, and an implicit submission (Enter in the title) or a `requestSubmit()` neither navigates nor saves nor publishes, so what was typed stays. As an input region the form keeps the list keys out (above).
963
+ 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.
964
+ 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.
965
+ 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.
966
+ 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.
967
+ 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).
968
+ 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).
911
969
  - **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.
912
970
  A cancel by the trash button or by Escape returns focus to the trash button when focus was on the confirmation.
913
971
  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`.
@@ -940,14 +998,15 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
940
998
 
941
999
  - **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;
942
1000
  a DetailList row is its measured body plus 2G = 16.
943
- The lift L is the largest whole number of device pixels within 2 CSS px at the device-pixel ratio dpr, ⌊2·dpr⌋ / dpr: 2 px at 1, 1.5, 2 and 3, 1.6 px (2 device pixels) at 1.25 and 12/7 px (3 device pixels) at 1.75.
944
- A 2 px lift would be 2.5 and 3.5 device pixels at 1.25 and 1.75, which puts the hovered surface on a half device pixel and blends its 1 px border, the ring and the outline into the next row of device pixels.
945
- The card sets the value on itself as the custom property `--aqdr-hover-lift` (`2px`, overridden under the media conditions `resolution: 1.25dppx` and `resolution: 1.75dppx`), and the lift reads it (`translate-y-[calc(-1*var(--aqdr-hover-lift))]`); every class is a literal, so a host's Tailwind build and the package's standalone stylesheet compile the same rules.
1001
+ 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).
1002
+ 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.
1003
+ 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.
946
1004
  - **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.
947
1005
  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).
948
1006
  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.
949
1007
  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.
950
- - **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).
1008
+ - **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),
1009
+ 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).
951
1010
  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).
952
1011
  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.
953
1012
  - **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.
@@ -1013,9 +1072,11 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
1013
1072
  Selection, focus, colours and borders switch instantly, and with reduced motion nothing moves, script included: the side pane's scroll after the viewer posts a comment is smooth only while the pane's own window does not prefer reduced motion and instant under `prefers-reduced-motion: reduce` (`scrollBehaviorFor` in `src/client/ui/motion.ts` reads the preference on every scroll, because browsers do not apply it to a script's `behavior: "smooth"`).
1014
1073
  A touch or pen flick starts no glide under `prefers-reduced-motion: reduce` either: `VirtualScroll` continues a flick with a scripted `requestAnimationFrame` glide (for up to 10 s with the views' fast-scroll options), so each view reads the preference of its own window (`useReducedMotion`, which follows a change from the next render and is `false` on the server) and passes inertia options whose start threshold no velocity reaches (`inertiaOptionsFor(true)`); the content still follows the finger 1:1 while it drags and stops when it is released.
1015
1074
  Both option sets are module constants, so the view's props to `VirtualScroll` stay equal from render to render.
1075
+ The header's total count and the ids stream's counters ease to a new value over 250 ms only while the window of the element that shows the number does not prefer reduced motion; under `prefers-reduced-motion: reduce` they show the new value in the same render and ask for no animation frame (`useAnimatedNumber`, which reads the preference with `useReducedMotion`).
1016
1076
  The floating tap-scroll circle and its visual, the scroll bar's thumb and arrow buttons and the scroll-to-edge buttons are `VirtualScroll`'s own: one rule of its stylesheet (since 3.8.2) stops every transition and animation of every part it animates under `prefers-reduced-motion: reduce`
1017
1077
  (the circle and every element inside it, overriding the visual's inline transitions; the bar's circle wrapper; the arrow buttons; the thumb; the scroll-to-edge buttons and their overlay), so the circle follows a drag without easing and the other parts change state at once.
1018
- While the list scrolls (`data-daily-report-scrolling` on the view's scroll container) the hover lift and its shadow are held still. `src/client/ui/motion-policy.spec.ts` checks the shipped CSS, and scans the sources so that no smooth scroll behaviour, no `animate()` call and no inertia option other than one chosen by `inertiaOptionsFor` is written outside `src/client/ui/motion.ts`.
1078
+ While the list scrolls (`data-daily-report-scrolling` on the view's scroll container) the hover lift and its shadow are held still. `src/client/ui/motion-policy.spec.ts` checks the shipped CSS, and scans the sources so that no smooth scroll behaviour, no `animate()` call and no inertia option other than one chosen by `inertiaOptionsFor` is written outside `src/client/ui/motion.ts`,
1079
+ and so that only the files of an allowlist with a written reason ask for an animation frame: the counters' easing, which must read the reduced-motion preference, and two uses that move nothing (the held key's one move per frame and the attachment grid's track width handed to the next frame), so script-driven interpolation can only be written behind that preference.
1019
1080
  That check is lexical — the stylesheets and the literals of the scripts. What actually moves is checked in the browser by the host app's reduced-motion end-to-end guard: under `prefers-reduced-motion: reduce`, after each step of a pointer and a touch tour of both views (the tap-scroll circle pressed and dragged, a card hovered, a flick, the mobile overlay opened and closed, a thumbnail loaded), no animation or transition of a motion property runs in the view, including what `VirtualScroll` renders.
1020
1081
  - **Spacing**: every spacing the package writes is 4 (within a group), 8 (between parts) or 16 (between items), and every box length it writes sits on the 4 px grid, line boxes included: one-line text such as each List preview line has a 20 px line box (`leading-5`, the preview's lines 8 px apart at a 28 px pitch) and the reading text of the side pane, the DetailList content and the comments a 24 px one (`leading-6`); no line height is proportional to the font size.
1021
1082
  These are the values at the default 16 px root: lengths written in rem (type, line boxes, the card's content, most spacing) scale with the root font size, while the CSS-pixel quantities of the state indicators — G and the lengths built on it — are written in px and keep their values at every root (see **Row gutter G** and **List slot P**).
@@ -1026,7 +1087,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
1026
1087
  **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).
1027
1088
  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.
1028
1089
  - **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.
1029
- 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.
1090
+ 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.
1030
1091
  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.
1031
1092
  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.
1032
1093
  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.
@@ -1035,6 +1096,8 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
1035
1096
  In any other window, and at other ratios (browser zoom such as 0.9, 1.1 or 1.33), it sits at least G and less than G + 1 device pixel from the end (**G-symmetric frame** in [View height](#view-height-host-layout)).
1036
1097
  - **Side pane** (List, desktop layout): the panel group fills the view's root, and the list and the side-pane panels both take its height ([View height](#view-height-host-layout)), so a long report scrolls only inside the pane's article tab and never lengthens the page. The pane sits G inside its panel on all four sides, so its top and bottom edges line up with the first and last cards, and its bottom edge sits exactly G above the page's bottom edge, as far as the toolbar is above the first surface.
1037
1098
  The List panel ends G after its scroll bar (`LIST_PANEL_END_GUTTER_CLASS_NAME`, `pr-[8px]`), so a 2G = 16 px channel lies between the scroll bar and the pane, and the resize handle that `@aiquants/resize-panels` centres on the panel boundary (a 10 px hit area around a 6 px grip) covers neither the scroll bar nor the pane's edge.
1099
+ Two pointer targets there fall short of WCAG 2.5.8 (a target of at least 24 × 24 px, or one whose 24 px circle meets no other target and no other such circle): the resize handle's 10 px hit area, centred in the 16 px channel 3 px from the scroll bar and 3 px from the pane, and the view's 8 px scroll bar.
1100
+ The centres of their 24 px circles are 12 px apart (the handle's on the panel boundary, the bar's 12 px before it), so the circles intersect and the spacing exception does not apply either. Both keep the specified frame — the 2G channel and the 8 px scroll bar of both views — and no wider construction has been decided yet.
1038
1101
  The side-pane panel is at least 200 px wide (`SIDE_PANE_PANEL_MIN_WIDTH_PX`): the frame around its centred selection prompt — 2G = 16 (the pane sits G inside its panel), the card surface's insets 2 × 16 and the placeholder panel's insets 2 × 12 (`PLACEHOLDER_PANEL_INSET_PX`), 72 in all — plus the prompt's longest phrase at the default 16 px root, 「選択してください。」 (9 full-width glyphs of `text-sm`, 9 × 0.875 rem = 126 px), is 198, rounded up to the 4 px lattice.
1039
1102
  At the default root the ja prompt therefore breaks only between phrases and leaves no one-glyph line; at a larger root its phrases widen and the prompt breaks inside one without crossing its frame (**Line breaks of wrapping labels** below). The List panel keeps its own minimum of 160 px, and the two minimums and the 10 px handle (370 px) fit in the List's two-column minimum of 460 px.
1040
1103
  The pane's header and its scrolling body end on one edge: the date and author column and the tab list sit in boxes that reserve the same scroll-bar gutter as the article and relations tab panel (`SIDE_PANE_HEADER_BOX_CLASS_NAME` and the panel both compose `SCROLLBAR_GUTTER_CLASS_NAME`, `scrollbar-thin` with `scrollbar-gutter: stable`, which an `overflow: hidden` box reserves too), so with a classic thin scroll bar, a wider one or an overlay one of no width, the header's end and the body's end share one x, in the desktop pane and in the mobile overlay alike.
@@ -1141,7 +1204,7 @@ DOM hooks are `data-*` attributes, test ids and ARIA; class names are styling an
1141
1204
 
1142
1205
  | Hook | Element |
1143
1206
  | --- | --- |
1144
- | `[data-daily-report-row="<id>"]` | Row frame of both views (`role="listitem"` with `aria-posinset` / `aria-setsize`), in every load state |
1207
+ | `[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 |
1145
1208
  | `[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) |
1146
1209
  | `[data-daily-report-card]` | List card surface (present while the card is shown) |
1147
1210
  | `button[data-daily-report-button="<id>"]` | List card primary button (selection, focus target, `aria-current`) |
@@ -1183,7 +1246,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1183
1246
  ```
1184
1247
 
1185
1248
  - **One surface for all wording.** The catalog covers the 11 engine chrome keys of
1186
- `@aiquants/virtualscroll` plus 84 own keys: field headings, the page title (`title`, passed to
1249
+ `@aiquants/virtualscroll` plus 85 own keys: field headings, the page title (`title`, passed to
1187
1250
  `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
1188
1251
  screen readers, tabs, buttons, placeholders, `confirm()` / `alert()` texts, stream badges, the load-error screen
1189
1252
  and the mutation-failure message. The engine part of each catalog is `VIRTUAL_SCROLL_LABEL_CATALOGS[locale]`, reused by
@@ -1192,17 +1255,17 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1192
1255
  and the 11 engine keys of the resolved labels to their `VirtualScroll`. The engine label object keeps
1193
1256
  its identity while its values are unchanged, so rebuilding `config.labels` on every render does not
1194
1257
  re-render the memoized list subtree.
1195
- - **Formatter keys.** Eleven keys take arguments and are functions: `totalCount(count)`,
1258
+ - **Formatter keys.** Twelve keys take arguments and are functions: `totalCount(count)`,
1196
1259
  `debounce(milliseconds)`, `streamProgressCount(loaded)`, `streamProgressRatio(loaded, total, percent)`,
1197
1260
  `streamRetrying(loaded)`, `reportLoadFailed(reportHubId)`, `reportSummaryLoadFailed(reportHubId)`,
1198
1261
  `interviewerWithAffiliation(name, affiliation)`, `operationFailed(operation)` (`operation` is a
1199
1262
  `DailyReportOperation`: `create` / `update` / `publish` / `delete` / `addComment` / `deleteComment` /
1200
- `toggleStar` / `toggleRead`), `rowState({ isRead, isStarred })` and `listRowPosition(position, total)`. An override of such a key must be a function too.
1263
+ `toggleStar` / `toggleRead`), `rowState({ isRead, isStarred })`, `listRowPosition(position, total)` and `listRowPositionInUnknownTotal(position)`. An override of such a key must be a function too.
1201
1264
  - **Digit grouping follows the UI locale, not the runtime.** The built-in formatters group numbers with
1202
1265
  the BCP 47 tag bound to their locale (`en` → `"en-US"`, `ja` → `"ja-JP"`); a host override receives
1203
1266
  the raw number and formats it itself.
1204
1267
  - **Fail-fast**: an unsupported `locale` (`"EN"`, `"ja-JP"`, `"fr"`, `""`, `null`, ...) throws a
1205
- `RangeError` at render. So do an unknown `labels` key (a key error that lists the 95 keys), a string
1268
+ `RangeError` at render. So do an unknown `labels` key (a key error that lists the 96 keys), a string
1206
1269
  key whose value is not a string with non-whitespace content, and a formatter key whose value is not a
1207
1270
  function. An `undefined` value keeps the catalog value.
1208
1271
  - **`config` has a closed key set**: `apiBasePath`, `ssePath`, `draftLabelName`, `locale`, `labels`,
@@ -1239,7 +1302,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1239
1302
  grouping follows `locale` (`en` → `"en-US"`, `ja` → `"ja-JP"`) and dates stay in ISO form. Packages
1240
1303
  that do format dates (for example `@aiquants/period-slider`) name that separate axis `formatLocale`.
1241
1304
 
1242
- Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 95 keys, the 11 engine keys
1305
+ Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 96 keys, the 11 engine keys
1243
1306
  first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReportLabels(locale, labels)`
1244
1307
  (the effective frozen labels — the catalog object itself when `labels` is `undefined`), the resolved
1245
1308
  `locale` / `labels` on `useDailyReportConfig()`, and the types `DailyReportLocale` (= `VirtualScrollLocale`),
@@ -1287,7 +1350,7 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
1287
1350
  | `tabDetailList` | top-level tab | 📋 DetailList | 📋 詳細一覧 |
1288
1351
  | `viewTabList` | accessible name (`aria-label`) of the top-level tab list | Views | 表示の切り替え |
1289
1352
  | `treeComingSoon` | tree tab body | The tree view is coming soon | ツリーは現在準備中です |
1290
- | `totalCountLoading` | header annotation while loading | Total: loading... | 総件数: 読み込み中... |
1353
+ | `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... | 総件数: 読み込み中... |
1291
1354
  | `createReport` | create button | New report | 日報作成 |
1292
1355
  | `createReportTitle` | create button tooltip | Create a daily report | 日報を作成 |
1293
1356
  | `devControls` | dev toolbox tooltip (`showDevControls`) | Development-only controls | 開発環境限定コントロール |
@@ -1331,7 +1394,7 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
1331
1394
  | `commentDeleteConfirm` | comment delete confirmation | Delete | 削除する |
1332
1395
  | `unknownUser` | author name when none is known (legacy / relational comment without a name, optimistic data of a user without a name) | Unknown | 不明なユーザー |
1333
1396
  | `sourceInternal` | built-in `Internal` badge (only when `sourceTypeConfigs` is omitted) | Original | オリジナル |
1334
- | `totalCount` | header annotation | `(1234)` → Total: 1,234 | `(1234)` → 総件数: 1,234 件 |
1397
+ | `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 件 |
1335
1398
  | `debounce` | header annotation | `(300)` → Debounce: 300 ms | `(300)` → デバウンス: 300 ms |
1336
1399
  | `streamProgressCount` | stream badge before the total is known | `(1234)` → Loading 1,234 | `(1234)` → 読み込み中 1,234 件 |
1337
1400
  | `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%) |
@@ -1341,7 +1404,8 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
1341
1404
  | `interviewerWithAffiliation` | interviewer chip | `("Sato", "Acme")` → Sato (Acme) | `("佐藤", "山田商事")` → 佐藤 (山田商事) |
1342
1405
  | `operationFailed` | error banner / `DailyReportErrorInfo.message` | `("update")` → Failed to save the report. Please try again later. | `("update")` → 日報の保存に失敗しました。時間をおいて再度お試しください。 |
1343
1406
  | `rowState` | visually hidden row state, the first description of a DetailList row and the second of the List card's primary button | `({ isRead: false, isStarred: true })` → Unread, starred | `({ isRead: false, isStarred: true })` → 未読、スター付き |
1344
- | `listRowPosition` | visually hidden position of a List card in the list, the first description of its primary button (`position` is 1-based) | `(3, 1234)` → 3 of 1,234 | `(3, 1234)` → 全 1,234 件中 3 件目 |
1407
+ | `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 件目 |
1408
+ | `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 件目 |
1345
1409
 
1346
1410
  ### External Source Badge Configuration (`sourceTypeConfigs`)
1347
1411
 
@@ -1457,6 +1521,8 @@ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000
1457
1521
  loading the ids and opening the stream. `null` means "position unknown" (empty stream, no redis, read failure): the
1458
1522
  hook then opens without a cursor and the server starts from the newest entry at connect time.
1459
1523
  - **Delivery is idempotent**: an anchor can be up to one ids-cache TTL old; replaying from it is safe because `report-*` messages upsert and `comment-add` is matched by comment id.
1524
+ - **Event size**: the service writes an event to the stream only when its JSON is at most `SSE_EVENT_MAX_BYTES` (6,356,992 bytes, `src/server/payload-limits.ts`): the action's form cap (1 MiB, **Request values**) at the largest growth `JSON.stringify` can give a text of the form (6 times: a control character, one raw byte in a multipart body, becomes the escape `\u00XX`), plus 64 KiB for the rest of the event.
1525
+ So no text an accepted action carries can push its event over the bound. A larger event — only a `report-create`, `report-update` or `report-publish` of a report whose accumulated detail (its body and all its comments) exceeds about 6 MiB — is not written, and the service logs `[SSE] Event too large (<operation>): <bytes> bytes > 6356992; not published` at `warn`; the caches were invalidated before, so viewers read the change on their next load.
1460
1526
  - **A resync that gives up is retried**: after `resync-required` the hook asks the ids session for a rescan (`resyncDailyReportIdsStream()`) and does not reconnect until an anchor newer than the one it had arrives. When that rescan gives up (the ids phase stays `complete` with an `error`, which happens after repeated failures during an outage), the hook asks again after a full-jitter backoff from 2 s to 30 s while SSE is enabled, until an anchor arrives. An initial ids scan that ends in `failed` is left to the page's manual retry.
1461
1527
 
1462
1528
  ## API Surface (Summary)
@@ -1466,20 +1532,21 @@ Every entry exports by name (no `export *`), so a module-internal helper never b
1466
1532
  - **shared** (`@aiquants/daily-report`, isomorphic):
1467
1533
  - 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),
1468
1534
  `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`,
1469
- `isIdsStreamChunkLine`, `normalizeBusinessDateKey`, `mergeComments(legacyComments, modernComments, currentUserId, unknownAuthorName)`.
1535
+ `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)`.
1470
1536
  - Types: `DailyReportItem` / `DailyReportDetail` / `DailyReportUser` / `DailyReportInterviewer` / `DailyReportLabelDef` / `DailyReportPostedComment` / `DailyReportExternalComment` / `DailyReportAttachmentSummary`, `DailyReportSseMessage` / `DailyReportSseStreamAnchor` / `DailyReportIdsStreamChunkLine`, `DailyReportAttachmentDeliveryRequest` / `DailyReportAttachmentInvalidQuery` / `DailyReportAttachmentThumbnailVariant` / `DailyReportAttachmentThumbnailBox`.
1471
1537
  - **client** (`@aiquants/daily-report/client`, React):
1472
1538
  - Components: `DailyReportPage` / `DailyReportResolvedContent` / `DailyReportList` / `DailyReportDetailList` / `DailyReportAttachmentIndicator`, the providers `DailyReportConfigProvider` / `DailyReportActionProvider` / `DailyReportErrorProvider`.
1473
- - Hooks: `useDailyReportConfig` / `useDailyReportActionContext` (`sseStatus`) / `useDailyReportDetail` / `useDailyReportPrefetch` / `useDailyReportComments` / `useDailyReportSseConnection` (returns `DailyReportSseConnectionStatus`) / `useDailyReportIdsStream` (state carries `streamAnchor`).
1474
- - The ids stream: `DailyReportIdsStreamClient` (`resync()`) / `DailyReportIdsStreamStatus` / `primeDailyReportIdsStreamSession` / `bootstrapDailyReportIdsStreamSession` / `ensureDailyReportIdsStreamSession` / `resyncDailyReportIdsStream`; the route helpers `createDailyReportClientLoader` / `dailyReportShouldRevalidate`.
1539
+ - Hooks: `useDailyReportConfig` / `useDailyReportActionContext` (the provider's actions — functions that keep their identity while the provider is mounted, the ledgers and `user` — and its state: `items`, `version`, `isSseEnabled`, `sseStatus`; `updateReport` takes both `title` and `content`) / `useDailyReportDetail` / `useDailyReportPrefetch` / `useDailyReportComments` /
1540
+ `useDailyReportSseConnection` (returns `DailyReportSseConnectionStatus`) / `useDailyReportIdsStream` (state carries `loadedCount`, `streamAnchor`, and `itemsRevision` / `itemsChanges`, the revision of the session's list and its latest net changes; the list itself is not part of the state).
1541
+ - The ids stream: `DailyReportIdsStreamClient` (`resync()`, `has(reportHubId)`, `readItems()` — the list as of the last publish, built when it is read; its `now` option is the monotonic clock of the publish window) / `DailyReportIdsStreamStatus` / `primeDailyReportIdsStreamSession` / `bootstrapDailyReportIdsStreamSession` / `ensureDailyReportIdsStreamSession` / `resyncDailyReportIdsStream`; the route helpers `createDailyReportClientLoader` / `dailyReportShouldRevalidate`.
1475
1542
  - Configuration and wording: `defaultDailyReportClientConfig` / `resolveDailyReportLabels` / `DAILY_REPORT_LABEL_CATALOGS` / `DAILY_REPORT_LABEL_KEYS` / `resolveSourceTypeConfig`.
1476
1543
  - 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.
1477
1544
  - 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)).
1478
1545
  - **server** (`@aiquants/daily-report/server`, Node.js):
1479
- - Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor }`; `readSseStreamAnchor`; `attachmentDelivery`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
1546
+ - Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor, snapshot }`; `readSseStreamAnchor`; `attachmentDelivery`, `null` without the `attachments` block; `titleMaxLength`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
1480
1547
  - 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`.
1481
1548
  - SSE and caching: `DailyReportSseReader` / `DAILY_REPORT_SSE_VISIBILITY_REFRESH_MS` / `DAILY_REPORT_SSE_CATCH_UP_BATCH` / `isStreamIdLte` / `SqlResultCache` / `transformJsonArray` / `jsonResponseWithETag` / `generateETag`.
1482
1549
  - 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`,
1483
- and for attachments `DailyReportAttachmentDelivery` / `DailyReportReadAttachment` / `DailyReportAttachmentError` / `DailyReportAttachmentFailure` / `DailyReportAttachmentThumbnailRenderer` / `DailyReportAttachmentThumbnail` / `DailyReportAttachmentThumbnailSourceFormat` / `DailyReportAttachmentThumbnailOutputMediaType` / `SharpLike`.
1550
+ and for attachments `DailyReportAttachmentsConfig` / `DailyReportAttachmentThumbnailsConfig` (the `attachments` block and its `thumbnails`) / `DailyReportAttachmentDelivery` / `DailyReportReadAttachment` / `DailyReportAttachmentError` / `DailyReportAttachmentFailure` / `DailyReportAttachmentThumbnailRenderer` / `DailyReportAttachmentThumbnail` / `DailyReportAttachmentThumbnailSourceFormat` / `DailyReportAttachmentThumbnailOutputMediaType` / `SharpLike`.
1484
1551
 
1485
1552
  MIT