@aiquants/daily-report 0.32.0 → 0.34.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.5**: install 3.11.5 or later.
29
+ The peer floor of `@aiquants/virtualscroll` is **3.14.0**: install 3.14.0 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).
@@ -42,14 +42,20 @@ An `.mjs` or `.css` target of `exports` without an entry, an entry for a file th
42
42
  Every `publish:*` script measures the build that `pnpm run verify` ends with (verify's last steps are the build and the bundle check) and does not build again: right after verify it runs `node scripts/check-bundle-size.mjs --write`, then `node scripts/check-bundle-size.mjs --exact`, before the leak check (which reads the same build) and the version bump.
43
43
  `--write` lowers every entry the build shrank, so a shrink is recorded into the release by construction (no failed publish, no separate baseline commit); a growth is left unrecorded, and `--exact` — besides the budgets, it fails when any measured size differs from its baseline entry in either direction — stops it and prints the `node scripts/check-bundle-size.mjs --write --accept` command that records that build after review.
44
44
  So a release always ships the baseline of its own build: an entry left high after a shrink would loosen every budget derived from it, and one left low would judge the next change against a build nobody shipped. `--exact` cannot be combined with `--write` (exit 2).
45
- `pnpm run verify` runs the type checks, Biome (`src/` and `scripts/`), markdownlint, the two count ratchets (docstrings, then the no-fallback guard), the unit tests with per-file coverage floors, the coverage ratchet, the examples, the build and, last, the bundle check.
45
+ `pnpm run verify` runs the type checks, Biome (`src/` and `scripts/`), markdownlint, the two count ratchets (docstrings, then the no-fallback guard), the unit tests with per-file coverage floors, the coverage ratchet, the changed-lines coverage gate, the examples, the build and, last, the bundle check.
46
+ The changed-lines coverage gate (`pnpm run check:changed-lines-coverage`) reads the coverage run and the lines added to `src` since the package's latest release tag (`<package name>@<version>`, resolved from `package.json`; untracked files count as added):
47
+ an added line inside a statement that no spec executed fails unless `scripts/changed-lines-coverage-exemptions.json` names it with its text and the reason, and an exemption that covers no such line is stale and fails (exit 0 / 1, and 2 when nothing is proven), so new code cannot hide behind a file's floor.
48
+ The gate is the repository's one shared module (`.config/scripts/lib/changed-lines-coverage.mjs`, which `@aiquants/virtualscroll` runs too): `scripts/check-changed-lines-coverage.mjs` is a thin entry that passes it this package's root and its exemptions file.
49
+ The build fails on any esbuild warning (`scripts/lib/build-warnings.mjs`), and the CommonJS bundles replace `import.meta.hot` with `undefined`, so they carry no `import.meta` while the ESM bundles keep the hot-module clean-up for the host's Vite.
46
50
  The unit tests include the repository's publish leak guard over the markdown the package ships (`src/shipped-markdown-leaks.spec.ts`: every markdown file `package.json` `files` ships — `README.md`, `CHANGELOG.md` and any shipped docs — packed with `package.json` into a tarball and checked by the guard itself, with its own rules), so an internal name in them fails verify instead of stopping a publish; the publish paths still run the guard on the packed tarball, `dist` included.
47
51
  Every source file under `src` (without specs, tests, declarations and test helpers) and every gate script under `scripts` (`scripts/**/*.mjs`) has a committed floor of branch and function coverage in `coverage-floors.json`: Vitest fails a file below its floor, `pnpm run check:coverage` (`scripts/ratchet-coverage.mjs`) fails a file without an entry or an entry without a file, and `pnpm run coverage:ratchet` raises each floor to the measured percentage rounded down after a whole-suite coverage run (a file at 100 % stays at 100; no floor is ever lowered by the script).
48
52
  Each gate script is a two-statement command-line entry around the `main({ packageRoot, argv })` of its module under `scripts/lib/` (the shared exit codes, JSON readers and error descriptions are `scripts/lib/cli.mjs`); the specs run `main` in the test process against temporary packages and keep one subprocess test of each entry, so the scripts' logic is measured by the same floors as `src` (all at 100).
49
53
  A new file enters the ratchet only at 100 % of both metrics: the script records no entry below that, so a file that cannot reach 100 % needs a committed entry with a non-empty `exemption` (the reason, reviewed with the floors), which the script keeps while it raises the floors.
50
54
  Two source guards keep per-file counts that only go down, through one engine (`scripts/lib/count-ratchet.mjs`): each file must match its entry exactly and a file without an entry counts 0, so a count below the entry fails too until the baseline is lowered (a fixed violation cannot come back unnoticed); an entry for a file the scan does not cover is stale; the exit code is 0 (pass), 1 (findings, each with its fix) or 2 (a configuration error, nothing proven); and `--write` lowers the entries to the measured counts and drops those at 0 and the stale ones, never raising or adding one.
51
55
  The guards are the violations of the workspace's docstring and comment language rules, in `docstring-baseline.json` (`scripts/ratchet-docstrings.mjs`: `pnpm run check:docstrings` verifies, `pnpm run docstrings:ratchet` lowers), and the implicit value substitutions (`??` / `||` fallbacks) of the production sources, in `no-fallback-baseline.json` at the package root (`scripts/check-no-fallback.mjs`: `pnpm run check:no-fallback` verifies, `pnpm run no-fallback:ratchet` lowers).
52
- A substitution that the specification or a port contract defines is a reviewed exception of the no-fallback guard instead (`EXCEPTIONS` in `scripts/lib/check-no-fallback.mjs`: the file, the exact expression, the number of `occurrences` it covers and the reason), and its matches are not counted; an exception that matches another number of expressions than it declares fails (exit 1: fewer means the expression was fixed or rewritten, more a new copy that needs its own review), and a malformed list is exit 2.
56
+ Production code takes no docstring violation at all: a production source (no spec, test, test helper or declaration; the repository's one definition, `isProductionSourcePath`) fails on any violation and may not have an entry in `docstring-baseline.json`, even one that equals its count (`--write` removes such an entry), so only specs and test helpers keep counted entries.
57
+ The no-fallback rule is the repository's one shared detector (`.config/scripts/lib/no-fallback.mjs`, which the host app's wiring spec runs too, so both flag the same expressions): a value-position `??` / `||` whose right operand is a literal other than `null` / `undefined`, or anywhere reads a property or an element, calls a function (`a ?? f()` and `a || f()` included) or constructs a value, and a `??` chained on another `??`.
58
+ A substitution that the specification or a port contract defines is a reviewed exception instead (`scripts/no-fallback-exceptions.json`: the file, the exact expression, the number of `occurrences` it covers and the reason), and its matches are not counted; an exception that matches another number of expressions than it declares fails (exit 1: fewer means the expression was fixed or rewritten, more a new copy that needs its own review), and a missing or malformed exceptions file is exit 2.
53
59
  Every TypeScript or JavaScript code block of this README names its source in its info string: an example file, or a `#region` of one, which `src/docs-examples.spec.ts` compares byte for byte (`ts examples/<file>.ts[#<region>]`), or `illustrative` for a fragment that is not type-checked.
54
60
 
55
61
  **Browser floor**: Chrome 121, Firefox 122, Safari 17.4 (Baseline 2024). The client uses each platform feature below as it is, with no feature detection and no second code path, so on an engine under the floor the listed behaviour fails and nothing else replaces it. Five CSS features of the table are newer than parts of the floor, and the row says what those engines draw:
@@ -59,8 +65,8 @@ Every TypeScript or JavaScript code block of this README names its source in its
59
65
  | `Element.checkVisibility({ visibilityProperty: true })` | Chrome 121, Firefox 122, Safari 17.4 (the method alone: Chrome 105, Firefox 106) | Finding Tab stops for the `Ctrl+Home` / `Ctrl+End` exits and the mobile overlay's Tab wrap | Without the method (Safari before 17.4) those key handlers throw a `TypeError`: the exit keys keep their browser meaning, and at the overlay's ends Tab moves to the browser's own interface instead of wrapping (the page behind the dialog stays inert). With the method but not the option (Chrome 105–120, Firefox 106–121) an element hidden by `visibility: hidden` counts as a stop, so an exit key whose nearest stop is such an element is consumed while focus stays where it was |
60
66
  | `:has()` | Chrome 105, Firefox 121, Safari 15.4 | The List card's selection ring and keyboard focus outline, and the forced-colours selection outline of a List row with a card (all read from the card's primary button) | List cards show neither the selection nor keyboard focus (the DetailList is unaffected) |
61
67
  | Container size queries and container query units (`cqi`) | Chrome 105, Firefox 110, Safari 16 | The attachment grid's column count and its whole-pixel width (both read from the grid's own size container); the tile frame's height and the tile's lattice pad (`100cqi` of the tile root, an inline-size container as wide as its track); the view-mode toolbar's end inset in the single-column List | One attachment column at every width; frames sized by `aspect-ratio` alone and tiles without the pad (below); the toolbar's end edge overhangs the List's scroll bar by 8 px |
62
- | CSS `round()` | Chrome 125, Firefox 118, Safari 15.4 | The attachment grid's whole-pixel width `calc(round(down, 100% − 16 (n − 1), n px) + 16 (n − 1))` per column count, which makes every track a whole pixel, the tile frame's whole-pixel height `round(nearest, 100cqi * 320 / 480, 1px)` and the tile's lattice pad `round(up, h, 4px) − h` under its last line (see [Attachment display](#attachment-display)); the views' column origin on the 4 px lattice (see **Origin on the 4 px lattice**) | Chrome 121–124, inside the floor, drop those declarations: the grid fills its container with the fluid tracks (W − 16 (n − 1)) / n (every breakpoint keeps its column count, which no `round()` declaration sets), so tile edges can fall between pixels and the names are fitted less than 1 px narrower than the track; the frame's `aspect-ratio: 480 / 320`, always declared beside its height, sizes the frame at the exact fractional 2t / 3, so the frames of one grid can paint one device pixel apart; a tile stays its frame's fractional height + 48 px tall, so the rows below an image grid can start between device pixels; and the column keeps the `mx-auto` centre |
63
- | CSS `calc-size()` | Chrome 129 | The DetailList row bodies' height, their content height rounded up to the 4 px lattice (`calc-size(auto, round(up, size, 4px))`, see **Row slots on the lattice**) | Chrome 121–128, and any engine that does not implement it, drop the declaration: a body keeps its content height, which is on the lattice at a 16 px root and can leave it at other roots, so the DetailList rows below can start between device pixels |
68
+ | CSS `round()` | Chrome 125, Firefox 118, Safari 15.4 | The attachment grid's whole-pixel width `calc(round(down, 100% − 16 (n − 1), n px) + 16 (n − 1))` per column count, which makes every track a whole pixel, the tile frame's whole-pixel height `round(nearest, 100cqi * 320 / 480, 1px)` and the tile's lattice pad `round(up, h, var(--aqdr-lattice-block)) − h` under its last line (see [Attachment display](#attachment-display)); the views' column origin on the 4 px lattice (see **Origin on the 4 px lattice**) | Chrome 121–124, inside the floor, drop those declarations: the grid fills its container with the fluid tracks (W − 16 (n − 1)) / n (every breakpoint keeps its column count, which no `round()` declaration sets), so tile edges can fall between pixels and the names are fitted less than 1 px narrower than the track; the frame's `aspect-ratio: 480 / 320`, always declared beside its height, sizes the frame at the exact fractional 2t / 3, so the frames of one grid can paint one device pixel apart; a tile stays its frame's fractional height + 48 px tall, so the rows below an image grid can start between device pixels; and the column keeps the `mx-auto` centre |
69
+ | CSS `calc-size()` | Chrome 129 | The DetailList row bodies' and the error notice's height, their content height rounded up to the block quantum of the device-pixel lattice (`calc-size(auto, round(up, size, var(--aqdr-lattice-block)))`: 4 px, 8 px at the odd multiples of 1/8; see **Row slots on the lattice**) | Chrome 121–128, and any engine that does not implement it, drop the declaration: a body keeps its content height, which is on the 4 px lattice at a 16 px root and can leave it at other roots and at the odd multiples of 1/8, so the DetailList rows below can start between device pixels |
64
70
  | `scrollbar-gutter: stable` | Chrome 94, Firefox 97, Safari 18.2 | The side pane's tab panels keep their scroll bar's width whatever their content's height | Safari 17.4–18.1, inside the floor, ignore it; their default scroll bars overlay the content and take no width |
65
71
  | `text-wrap: balance` | Chrome 114, Firefox 121, Safari 17.5 | The short centred labels that can wrap (`WRAPPING_LABEL_CLASS_NAME`: the side pane's selection prompt, an attachment tile's unavailable label) keep their lines about equally long | Safari 17.4, inside the floor, drops the value and wraps them greedily, so a wrapped label can end with a short last line |
66
72
  | `word-break: auto-phrase` | Chrome 119 (not in Firefox or Safari) | The same labels break Japanese at phrase boundaries where the host document's `lang` is `ja` | Firefox and Safari drop the declaration and break Japanese between any two characters (the default), so a narrow label can break inside a word |
@@ -168,7 +174,7 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
168
174
  **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:
169
175
 
170
176
  - **`HEAD`**: both answer the status and headers a `GET` would get, and start no work for the body.
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 —
177
+ 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`, then the viewer's refresh bucket for a forced read: 429, **Request values** below), the viewer's stream slots (503, **Streams per viewer** below: a `HEAD` is checked and takes no slot) and the viewer's visibility —
172
178
  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).
173
179
  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.
174
180
  - **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.
@@ -176,40 +182,77 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
176
182
  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.
177
183
  - **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
184
  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.
185
+ - **Streams per viewer**: one viewer holds at most `DAILY_REPORT_STREAMS_PER_VIEWER` = 32 streaming responses at once in one worker process, SSE streams and ids streams together (`createStreamSlots` in `src/server/stream-body.ts`: one set per handler factory, keyed by the internal user id, no wait queue). The bound is the package client's own need with headroom: a view holds 2 streams (its SSE connection for its whole life, and its ids stream while it loads or rescans), and a viewer may keep 16 views open across tabs, windows and devices.
186
+ It is not derived from HTTP/1.1's 6 connections per origin, since one HTTP/2 connection carries many streams. A held stream costs about 10 KB of heap and one visibility re-resolution every 60 s, so 32 streams cost about 310 KB and 0.53 re-resolutions a second.
187
+ A stream takes its slot after the checks that decide its status — on the SSE route after the resume cursor and before the visibility is resolved or anything subscribed, on the ids stream after its 400s and the refresh bucket — and gives it back in the settle path that ends its body (the SSE connection's close; the ids stream's end, cancel, abort or error, and a resumed `GET` that fails before its body starts).
188
+ The request after the last slot starts no body work and is answered 503 with `Retry-After: 5`: the SSE route with the text `Too many streams`, the ids stream with `{"error":{"message":"Too many streams"}}`; both clients come back on their own backoff. A `HEAD`, or a `GET` whose signal is already aborted, takes no slot: it is only checked, and gets the status a `GET` would get. A viewer without an internal user takes none (the SSE route answers it `forbidden`, and its ids stream is one empty line without a query).
189
+ The refusals are recorded per viewer in windows of 60 s at `warn` (`[DailyReportStreams]` by default): the window's first as `503 <sse|ids-stream> viewer=<internal id> reason=streams_per_viewer`, the rest counted into one `streams_per_viewer_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> viewer=<internal id> routes=sse:<n>,ids-stream:<n>` line written by the window's own timer at its end.
190
+ The slots are per worker process, so the effective bound across workers is the worker count times 32.
179
191
 
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:
192
+ **Request values**: the API route and the action read every value of a request — business dates, ids, time stamps, `forceRefresh`, the action's echo id and the rest of its 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
193
 
182
194
  - **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
195
  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
196
  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.
197
+ - **Ids** (`parseCanonicalPositiveId`; the text readers of the query and the form are `src/shared/wire-text.ts`, and the id domain is `src/shared/ids.ts`): `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
198
  `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
199
  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
200
  - **`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.
201
+ A forced read (`true`) bypasses the SQL result cache, so two bounds keep one viewer from running uncached queries without limit:
202
+ - **Coalesced per key** (`SqlResultCache.getOrFetch`): at most one forced fetch of a key runs and one waits. A forced call that arrives while one runs joins the waiting fetch, which starts when the running one settles (either way), so any number of simultaneous forced reads of one key cost at most two fetches, and every forced caller receives a fetch that started after it arrived.
203
+ The running forced fetch is the key's in-flight fetch, so plain readers join it, and its records enter the cache unless an invalidation dropped it meanwhile.
204
+ - **A refresh bucket per viewer**: every forced read spends one token after its 400 checks and before any service call, `REFRESH_RATE_LIMIT_PER_MINUTE` = 60 per viewer and per handler factory (so per worker process), keyed by the external user id and refilled over one minute (`REFRESH_RATE_WINDOW_MS`). An empty bucket answers 429 `{"error":{"message":"Too many requests"}}` with `Retry-After: 60` and calls no service, the ids stream's `HEAD` included.
205
+ The bound is the package client's own forced reads with the action's headroom (**Rate** below): `DWELLING_PANES_HEADROOM` = 5 views × `FORCED_RESCANS_PER_VIEW` = 2 forced rescans per view and minute (the SSE `resync-required` and the development cache clear start one; a rescan that keeps failing spans more than half a window before it gives up) × `FORCED_RESCAN_ATTEMPTS` = 6 requests per rescan (the ids stream client's first attempt and its five retries, which keep `forceRefresh`); the business-date list's absence check (one forced read per date every 10 s at most) stays inside it.
206
+ The refusals are recorded per viewer in windows of one minute at `warn` (`[DailyReportAPI]` by default): the window's first as `429 endpoint=<business-date|report|ids-stream> reason=refresh_rate_limit`, the rest counted into one `refresh_rate_limit_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> route=api` line at the window's end. No line names the user.
207
+ - **The echo id** (`clientTempId`; every intent but `clearCache`): the client makes one id per operation, sends it with the action and recognizes its own operation by it in the answer and in the SSE event. The action accepts exactly the two forms the package's client makes (`parseClientTempId`, server entry, which returns the branded type `DailyReportClientTempId`):
208
+ a lowercase UUID (`crypto.randomUUID()`: 8-4-4-4-12 lowercase hexadecimal digits) and a negative decimal integer without a leading zero of at most 16 digits (the optimistic temporary id of a report or a comment: one page-wide sequence draws each id strictly below the last one drawn and at most minus the wall clock's milliseconds, so two operations in the same millisecond never share one). A missing or empty id is `clientTempId required`;
209
+ anything else — an uppercase UUID, `-0`, `tmp-1`, surrounding whitespace, 37 characters, a text of 1 MiB — is `Invalid clientTempId`, before any service call.
210
+ The server writes the id into the persistent SSE event of every intent and echoes it in the answer, so the two forms keep the id from deciding an event's size; the SSE message schema reads every event's id against the same pattern (`DAILY_REPORT_CLIENT_TEMP_ID_PATTERN` in `src/shared/client-temp-id.ts`), so an entry with any other id is dropped on delivery (fail-closed).
211
+ The service's write methods — `setStarStatus`, `setReadStatus`, `addComment`, `deleteComment`, `createDailyReport`, `updateDailyReport`, `publishDailyReport` and `deleteDailyReport` — take only a `DailyReportClientTempId`, which only `parseClientTempId` produces, so a host that calls the service directly (an integration test, a batch) reads its id through the parser first and cannot publish an event that delivery would drop.
189
212
  - **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.
213
+ - **Rate**: before the body is read, each request spends one token of its viewer's bucket, keyed by the external user id `authenticate` returns: `ACTION_RATE_LIMIT_PER_MINUTE` = 600 per viewer and per handler factory (so per worker process), refilled in proportion to the time over one minute (`ACTION_RATE_WINDOW_MS`). The bucket starts full, so a burst of up to 600 passes at once.
214
+ An empty bucket answers 429 `{"error":"Too many requests"}` with `Retry-After: 60` (one window, after which the bucket is full again); it reads no body and calls no service.
215
+ The bound is derived from the client's own fastest flow, not written: a side pane auto-reads the report it shows after `AUTO_READ_DWELL_MS` (500 ms), so one pane sends at most ⌈60,000 ÷ 500⌉ = 120 `toggleRead` a minute, and the limit is `DWELLING_PANES_HEADROOM` = 5 such panes × 120 = 600; stars, comments and saves come at the pace of a person.
216
+ The client's dwell and the server's limit read the one constant in `src/shared/auto-read.ts`, so a shorter dwell raises the limit with it.
217
+ This limit and the refresh bucket's (`forceRefresh` above) are not host settings: both follow the package client's own pace, which a host cannot change, so a setting could only break the client's own flow (a smaller value) or loosen the bound (a larger one); the configuration keeps its tuning values where a host chooses a cost of its own, the storage reads of the `attachments` block.
218
+ The refusals are recorded per viewer in windows of the rate window at `warn` (through the shared bucket of `src/server/rate-limit.ts`, which the attachment paths use too): the window's first refusal as `429 action reason=rate_limit`, and the rest counted into one `rate_limit_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> route=action` line written by the window's own timer at its end. No line names the user.
219
+ - **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 413 `{"error":"Form too large"}` without reading the body.
220
+ A body without a declared length (chunked) or with a wrong one stops being read where the count of bytes passes the cap, and the request's body is cancelled. On Node adapters the cancel destroys the request's socket (`@react-router/node`'s `createReadableStreamFromReadable`, for one), so the sender gets a closed connection, not the 413: delivering the 413 would mean reading the rest of an upload of unbounded size, which is the cost the cap exists to avoid. Neither refusal writes an error line.
221
+ Both 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
222
  The cap is the only bound of a report's body and a comment's text, which have no ceiling of their own.
193
223
  - **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.
224
+ - **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` when it is missing or empty, then `Invalid clientTempId` for any other form; **The echo id** above), 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
225
  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
226
  - **`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
227
  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
228
  - **`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
229
  - **`addComment`**: `content` is a non-empty text (`Content required` otherwise). **`deleteComment`**: `commentId` (**Ids** above).
230
+ - **One wire contract for the action and the JSON endpoints**, which the server, the package's client and a host that calls the routes itself all read from the shared entry (`@aiquants/daily-report`; neither side restates a name):
231
+ - the intents (`DAILY_REPORT_ACTION_INTENTS` and their type `DailyReportActionIntent`, from which the server's form reading and the client's operations derive) and the form's field names (`DAILY_REPORT_ACTION_FIELDS`);
232
+ - the command of each intent (`DailyReportActionCommand`) with its form encoding (`encodeDailyReportActionCommand`, which the server's parsing reads back as the same command);
233
+ - the 200 answer of each intent (`DailyReportActionResult`, one member per intent, discriminated by `intent`; `DailyReportActionResultOf<Intent>` picks one) and its strict reading (`parseDailyReportActionResult`), and a refusal (`DailyReportActionFailure`: `{"error":"<message>"}`);
234
+ - the endpoint and query-parameter names (`DAILY_REPORT_API_ENDPOINTS`, `DAILY_REPORT_API_QUERY_PARAMS`; the ids stream's own query is written and read by one encoder and one strict reader inside the package, so a renamed parameter fails their round trip instead of turning every resume into a full replay) and the pattern of the echo id (`DAILY_REPORT_CLIENT_TEMP_ID_PATTERN`).
235
+
236
+ Every answer carries its `intent` (`deleteComment`'s too), the echo id and the report's id as a JSON number, the same id domain every SSE event uses (`dailyReportIdSchema` in `src/shared/ids.ts`: an integer from 1 to 2^53 − 1), beside the intent's own values (the created or published report, the toggled statuses, the posted comment, the deleted comment's id, also a JSON number). The server builds each 200 answer from its command alone (`answerActionCommand`), so it cannot answer one intent with another's shape.
237
+ `parseDailyReportActionResult(intent, json)` reads an answer as the result of the intent that was sent and returns `{ result }`, or `{ mismatch }` when the body is not that result: a field of another type (the text `"7"` where the id belongs, 0, a negative or a fraction), a missing field or another intent's answer, and nothing is converted.
238
+ The mismatch names the failing paths and the issue codes only, never a value (`describeWireMismatch` in `src/shared/wire-mismatch.ts`: `<path>: <code>` items joined by a semicolon and a space, the root as `(root)`), so a host debugging a version skew or a rewriting proxy learns which fields failed and nothing it received reaches a log.
239
+ The package's client fails the action with `[daily-report] <intent> answered a body that is not its result (<mismatch>)`, and reads the `report` and `business-date` endpoints with strict schemas (`{ report }` and `{ reports }` of `dailyReportDetailSchema`): a body that does not match rejects with `[daily-report] <endpoint> answered a body that does not match its schema (<mismatch>)`, a status other than 2xx with `[daily-report] <endpoint> answered HTTP <status>`.
200
240
 
201
241
  ### DI ports
202
242
 
203
243
  - `authenticate(request, { failureRedirect })` — Session verification, declared as the overloaded `DailyReportAuthenticate`. With `failureRedirect: string` the implementation must throw a redirect on unauthenticated requests, so a normal return always carries `user` (typed as required — leaving it optional would force callers to write an unreachable `!user` guard). With `failureRedirect: null` it must not redirect and resolves without `user` instead; forward the returned `cookie` on unauthenticated responses too, otherwise a destroyed session lingers in the browser.
204
244
  - `resolveUserId(externalId)` — External ID → internal numeric ID (`null` = unregistered). In-process caching can be disabled with `disableUserIdCache` (useful for testing).
205
245
  - `encodeUserId(id)` — Obfuscates IDs sent to the client. Inject app-level implementation to preserve existing ID namespaces.
246
+ It must be deterministic — the same id always gives the same text — because the client decides that a comment is the viewer's own by comparing the viewer's id (`userId` of `DailyReportPage`, which the host's loader builds with this port) with the comment's author id (which the service writes with it), for comments that arrive by SSE too; an implementation that returns another text on every call (a fresh salt, for example) makes the viewer's own comments look like someone else's.
206
247
  - `redis` — `getClient()` (get/incr/xAdd/xRange/xRevRange) + `createClient()` (blocking xRead). Structurally matches a `node-redis` v5 client. If omitted, SSE publishing is skipped (with a warning) and epoch is disabled.
207
248
  - `externalSources[]` — When `Hub.source_type` matches, performs a `LEFT JOIN` on `Hub.source_id_num = idColumn` and converts fields via `mapRow`.
208
249
  - `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.
209
250
  - `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.
210
251
  - `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
252
  - `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)).
253
+ - `logger` — Optional console-compatible logger (`debug` / `info` / `warn` / `error`) that receives every server log line of the package as it is.
254
+ 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)), `[DailyReportAction]` from `warn` (the record of the action's 413 and 429, **Request values** in [Server wiring](#server-wiring-di)), `[DailyReportAPI]` from `warn` (the refresh bucket's 429, **`forceRefresh`** there) and `[DailyReportStreams]` from `warn` (the 503 of **Streams per viewer** there).
255
+ A JSON endpoint's answer is traced by the API route alone, as one `debug` line on this logger after the endpoint answered (`<status> endpoint=<name> etag_match=<true|false>`; the default `[DailyReportAPI]` console logger starts at `error`, so without a host logger the line is not written). `jsonResponseWithETag` itself writes nothing, and no line carries a request header's value or traces an answer given before the endpoint (the 401, 404 and 405), so a client decides neither the number nor the size of the lines.
213
256
  - Primary tuning parameters: `idsTtlMs` (180s) / `businessDateTtlMs` (300s) / `streamKey` / `streamMaxLen` / `loginRedirectPath`; the attachment tuning lives in the `attachments` block ([Attachments](#attachments)).
214
257
 
215
258
  **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.
@@ -331,6 +374,9 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
331
374
  - **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).
332
375
  - **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).
333
376
  - **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.
377
+ - **The original's file name**: `Content-Disposition` names the file twice, `filename="<ASCII form>"` (every character outside printable ASCII, a quote and a backslash replaced by `_`) and `filename*=UTF-8''<percent-encoded>` (RFC 8187: only attr-char is left unescaped).
378
+ The encoded form is the UTF-8 of the name's well-formed form: a lone UTF-16 surrogate — a high one without its low one, or a low one without its high one, as a truncation of the source system's `nvarchar` column can leave — becomes U+FFFD (`%EF%BF%BD`), so every stored name is delivered (`encodeRfc8187` never throws).
379
+ The two forms and the row's media type are built before the slot is taken and the original is read, so nothing derived from the row can fail a response after the read.
334
380
  - **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.
335
381
  - **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);
336
382
  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.
@@ -387,6 +433,7 @@ type DailyReportReadAttachment = (
387
433
  - Never throw; return a typed failure. `filePath` stays on the server.
388
434
  - **`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
435
  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.
436
+ GET and HEAD share one bound as well: a GET read whose bytes exceed `maxBytes` is the violation `code=over_max_bytes`, and a HEAD read, which carries no bytes, is checked by the `size` it declares — a declared size above `maxBytes` is the same 500 with the same `error` line, without `Content-Length`, so no HEAD answers 200 for an object whose GET ends in 500.
390
437
  - **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.
391
438
 
392
439
  | Reason | When | Original | Thumbnail |
@@ -690,7 +737,8 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
690
737
  **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.
691
738
  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.
692
739
  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.
693
- - **Time budget**: one budget bounds a cache miss from the generation gate onward — the wait for a slot, the read, the render and the transfer — so a request that has reached the gate leaves the server within the sum of those stages, and the client waits exactly that sum before counting a load as failed. Each port stage runs under its own deadline and answers at that deadline even when the port ignores the signal (the read with 504 `read_timeout`, the render with 502 `render_timeout`); a deadline that passes after every requester has gone ends as 503 `aborted` instead, since nobody receives it.
740
+ - **Time budget**: one budget bounds a cache miss from the generation gate onward — the wait for a slot, the read, the render and the transfer — so a request that has reached the gate leaves the server within the sum of those stages, and the client waits exactly that sum before counting a load as failed.
741
+ Each port stage runs under its own deadline and answers at that deadline even when the port ignores the signal (the read with 504 `read_timeout`, the render with 502 `render_timeout`); a deadline that passes after every requester has gone ends as 503 `aborted` instead, since nobody receives it.
694
742
  Two things are outside the budget. Authentication and authorization run before the gate and are bounded only by the host's own timeouts; their time is taken out of the client's sum as well. And the budget cannot end a port that ignores its signal: after the deadline's answer such a port keeps its generation slot, and the server logs `read_stuck` / `render_stuck` once when it has not settled at twice its stage budget.
695
743
 
696
744
  | Stage | Budget | At the deadline |
@@ -735,11 +783,11 @@ The package never writes the file path itself; a `port_exception` line includes
735
783
  | --- | --- |
736
784
  | `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) |
737
785
  | `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) |
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 |
786
+ | `error` | 500 (`port_exception`, `port_contract` including a read over `maxBytes` and an original's `HEAD` that declares a size over it (`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 |
739
787
 
740
788
  `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.
741
789
 
742
- **429 lines**: each rate bucket (the original's, and the thumbnail's generation and revalidation buckets) records its refusals in windows per viewer of `RATE_LIMIT_LOG_WINDOW_MS` (60 s, the rate window and the 429's `Retry-After`), kept apart from the buckets themselves.
790
+ **429 lines**: each rate bucket (the original's, and the thumbnail's generation and revalidation buckets) records its refusals in windows per viewer of its own rate window (`ATTACHMENT_RATE_WINDOW_MS`, 60 s, also the 429's `Retry-After`; one bucket, `createRateLimitBucket` in `src/server/rate-limit.ts`, serves these paths and the action), kept apart from the buckets themselves.
743
791
  A viewer's refusal with no open window writes its `429 … reason=rate_limit` (`reason=revalidation_rate_limit`) line at once and opens the window; later refusals in the window are only counted, and the window's own timer writes, when it counted any, one `rate_limit_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> viewer=<id>` (`revalidation_rate_limit_suppressed …`) line (with `variant=thumbnail` on the thumbnail path) and closes the window. The 429 of a revalidation refused after authorization counts in the generation bucket's window.
744
792
  A viewer therefore writes at most two lines per bucket and window, whatever its request rate or the refill; the count is written at the window's end, also when the refusals stop, and a bucket the limiter evicts (it keeps 1,024 users) loses none of it.
745
793
 
@@ -796,7 +844,17 @@ Every field `useDailyReportDetail(reportHubId, businessDate)` returns — `repor
796
844
  An expired cached report is therefore drawn at once, never preceded by `null`, and refetched behind it; an answer that arrives for an earlier id, or after the component unmounted, is dropped. An id of 0 or less fetches nothing and returns no report (0 also returns an `Invalid ID` error).
797
845
  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.
798
846
 
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.
847
+ 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 notice. Failures on a continuation that resolves after a no-reload user switch are suppressed, and deleting a report the server no longer has (404) counts as done: the row stays removed and nothing is shown.
848
+ If you compose `DailyReportActionProvider` yourself instead of using `DailyReportPage`, wrap it in `DailyReportErrorProvider` (both are exported from `@aiquants/daily-report/client`) so `onError` / the notice work.
849
+
850
+ - **The built-in notice** is one `role="alert"` band (`data-testid="daily-report-error-banner"`) with a close button named by `labels.close` and described by the message. It never leaves on a timer (WCAG 2.2.3): only the user removes it, with the close button or with `Escape` while focus is inside it, which removes the notice alone (it sits on top of the `Escape` stack the mobile overlay uses, so the overlay stays open). A newer failure replaces the message; there is one notice.
851
+ - **It covers nothing**: the notice is rendered in the flow, where it takes its own room. While the List's mobile overlay is open it renders inside the overlay's modal `<dialog>`, between the report and the close button (in the top layer, so it is neither inert nor hidden from assistive technology); otherwise it renders in the provider's own slot after the page, centred with the row gutter G around it (`data-daily-report-notice-slot`). Its block size is rounded up to the lattice like a DetailList body.
852
+ - **Focus is handed back, never dropped**: when the notice leaves while it holds focus, focus goes to where it came from (when that element is still in the document), else to the view's cursor row (its one Tab stop), else to the dialog that hosts the notice.
853
+ - **The page's stream status**: until the first ids chunk arrives `DailyReportPage` shows one status panel (`data-testid="daily-report-status-panel"`, `data-phase` `loading` or `failed`) whose content switches between loading and the load error with its retry button, so the panel persists through a retry, and the header's progress badge is one persistent container whose content switches by phase.
854
+ A retry button that its own press removes first hands focus to its container — the status panel, or the badge's container — and the page hands focus on to the view's cursor row, or to its status region, when the panel leaves with focus.
855
+ One persistent, visually hidden region of the page (`data-testid="daily-report-stream-announcer"`, outside the screens the phase swaps) announces the stream's lifecycle: loading (`labels.loadingIds`) and reconnecting (`labels.streamRetrying`) as a `role="status"`, a failure as a `role="alert"` (`labels.loadError` before any report arrived, `labels.streamFailed` after).
856
+ The badge's own retrying and failed contents are live regions only where the page does not announce them: `DailyReportIdsStreamStatus` placed alone keeps its `role="status"`.
857
+
800
858
  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
859
  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
860
  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.
@@ -805,7 +863,12 @@ The report id list is **not** part of the loader data: a module-resident NDJSON
805
863
  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.
806
864
  `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).
807
865
  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`.
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).
866
+ `DailyReportPage` renders 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).
867
+
868
+ - **An items publish re-renders only what it changes, behind the keys.** The package's own components read the session through narrow views (internal), each of which re-renders its reader only when one of its fields changes: the page root and the SSE connection read the status (the phase, the background revalidation, the last error, the SSE anchor and whether any report has arrived), the progress badge the phase, the revalidation and the two counts, and the loaded screen's set size the phase and the declared total.
869
+ `DailyReportActionProvider` follows the list itself through one subscription that re-renders it inside `startTransition` when the published revision or the scan's settledness changes, and reads the revision, the latest changes, the list's accessor and the settledness from the session in the same render, so they always agree.
870
+ The rows derivation and the views' row count therefore render at transition priority: a key pressed while the stream delivers commits before the publish's render, and an items publish re-renders neither the page root nor the SSE connection's host (`useSyncExternalStore`, which the public hook below uses, renders its update at the sync priority even inside `startTransition`, so it cannot carry the transition).
871
+ The public `useDailyReportIdsStream` keeps its contract — the whole state on every publish — so a host component that calls it re-renders on every publish.
809
872
 
810
873
  - **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
874
  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).
@@ -823,9 +886,11 @@ Each view (List and DetailList) is as tall as the space the host's layout leaves
823
886
  - **Host contract**: render `DailyReportPage` (or `DailyReportResolvedContent`) as the content of a column flex container (`display: flex; flex-direction: column`) whose height is bounded — a definite height, or the growing item of a column with a minimum height, such as a `min-height: 100dvh` shell whose footer follows the content. The header that `renderHeader` returns and the page box are items of that container, and the page box takes the rest of it. Nothing is bound and no host variable is read.
824
887
  Outside such a container the view has no height to fill: its size is contained (below), so its content cannot size it, and it is 0 px tall.
825
888
  - **The fill chain**: every box from the page box down to a view's root — the page box, its centred width-capped wrapper, the view-mode tabs and the active tab panel — fills the rest of its parent column (`flex: 1 1 0%; min-height: 0`, `FILL_COLUMN_CLASS_NAME`) and declares no height. The wrapper's padding is 4 px at the top and the sides and none at the bottom, so the view's box ends exactly at the page's bottom edge.
826
- - **One page frame and one column for every screen**: the loading screen, the load-error screen and the loaded screen share the page box (`VIEW_PAGE_FRAME_CLASS_NAME`: the fill rule and the page surface, slate-50 / dark slate-950) and its centred column (`VIEW_COLUMN_CLASS_NAME`: the fill rule, at most `max-w-6xl`, 4 px from the frame's top and side edges, starting on the 4 px lattice). So the page keeps its colour in both schemes while loading ends (a dark page never shows the light surface first) and the content does not move sideways between the screens.
827
- - **The view's root contains its size** (`VIEW_ROOT_CLASS_NAME`: the fill rule, `contain: size` and `overflow: clip`): its box is sized as if it were empty, so what the view renders — a `VirtualScroll` given the measured height — never feeds back into the root's size, into the intrinsic size of an ancestor (a host column with a minimum height does not grow) or into the document's scroll height. The clip keeps rows drawn for the previous height from painting outside the box during the one frame before the view renders the new height.
828
- Layout and paint are not contained, so the mobile overlay, a fixed-position descendant, keeps the viewport as its containing block.
889
+ - **One page frame and one column for every screen**: the status screen (loading or the load error, one panel) and the loaded screen share the page box (`VIEW_PAGE_FRAME_CLASS_NAME`: the fill rule and the page surface, slate-50 / dark slate-950) and its centred column (`VIEW_COLUMN_CLASS_NAME`: the fill rule, at most `max-w-6xl`, 4 px from the frame's top and side edges, starting on the 4 px lattice). So the page keeps its colour in both schemes while loading ends (a dark page never shows the light surface first) and the content does not move sideways between the screens.
890
+ - **The view's root contains its size** (`VIEW_ROOT_CLASS_NAME`: the fill rule, `contain: size` and `overflow: clip`, its children in block flow): its box is sized as if it were empty, so what the view renders — a `VirtualScroll` given the measured height — never feeds back into the root's size, into the intrinsic size of an ancestor (a host column with a minimum height does not grow) or into the document's scroll height. The clip keeps rows drawn for the previous height from painting outside the box during the one frame before the view renders the new height.
891
+ The root's layout and paint are not contained: either would make the root the containing block of the mobile overlay, a fixed-position descendant, and as a flex item of its column the root could not be a relayout boundary anyway (Chromium makes no flex or grid item one, contained or not).
892
+ - **The view's body is the relayout boundary** (`VIEW_BODY_CLASS_NAME`: a column flex container at `height: 100%` of the root, with `contain: size layout style`): it fills the root exactly, and since the root lays it out in block flow it is not a flex item, so with its size and layout contained it is a relayout boundary. Everything the view lays out — the list column, the panel group and the side pane, the scroll bar — sits inside it, so a change there (an ids stream publish that changes the row count or the scroll bar's thumb) lays out from the body, never from the document.
893
+ The List's mobile overlay (a fixed-position `<dialog>`) is rendered directly under the root, outside the body: layout containment would make the body the containing block of its fixed descendants, and the dialog, which stays mounted outside the top layer while it slides out after `close()`, keeps the viewport as its containing block. Paint is not contained (the root clips).
829
894
  - **One measurement, in one unit**: `VirtualScroll` needs its viewport size as a number (`viewportSize`), so each view takes its root's border-box block size in layout px from one `ResizeObserver` of the root's own window, observing the root alone (`box: "border-box"`; `useViewBoxHeight`). Every value, the first included, is the delivery's `borderBoxSize[0].blockSize`: the layout effect only starts the observation, and no code reads a size from the DOM.
830
895
  The value is `null` until the first delivery, and the view renders no body until then, so no row is ever drawn at a guessed height (the List also waits for its row slot, below). The platform delivers the first observation in the rendering update after the observation starts, after layout and before paint, and that one delivery is committed at once (`flushSync`), so the view's content is drawn before the same frame paints; later deliveries only set React state, which React renders after the delivery.
831
896
  A delivery reads and writes nothing in the DOM, and a value equal to the last one written is not written again.
@@ -833,7 +898,8 @@ Each view (List and DetailList) is as tall as the space the host's layout leaves
833
898
  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.
834
899
  - **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.
835
900
  `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.
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.
901
+ 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.
902
+ The slots are whole device pixels at every ratio of the device-pixel lattice, every multiple of 1/8 from 1 to 3 (the List slot where it is a multiple of 8, as at the default 16 px root; **Row slots on the lattice**): 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 4 px lattice.
837
903
  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).
838
904
  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.
839
905
  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.
@@ -855,6 +921,7 @@ The keys are delegated to each view's **list**: the element that holds the view'
855
921
  | Any other key, a modifier combination, IME | Not handled, and cancels a pending focus request | The same | No |
856
922
 
857
923
  - **The move starts from the focused row**, not from the selection: Tab into a row, press `ArrowDown`, and the report after that row is selected.
924
+ - **`Home` and `End` name a report**, the first or the last: pressed on a focused row that already is that report but is not selected — the last row left focused after the selected report was deleted, for example — they select it in place, aligned like any `Home` / `End` (focus is already there, so nothing requests it). The relative moves name a neighbour, so an arrow, `PageUp` or `PageDown` that cannot move at an end selects nothing; and the frames of a held `Home` / `End` do not select again what its first press selected (or the host refused).
858
925
  - **Held keys move at once, then at most once per frame.** The first repeated keydown (`repeat`) of a key is checked like any key and starts a repeat stream from the focused row; it and every later repeat on the same axis have their default prevented.
859
926
  A repeat that finds the stream's frame fence open — the first one, and any repeat after a frame in which nothing was pending — moves at once inside its own keydown (one selection, the scroll, one focus request; React commits the discrete event synchronously), so a key that repeats more slowly than the display refreshes moves once per keydown and never waits for a frame.
860
927
  The repeats behind the fence only add their step: the arrows one row each (up and down cancel out), `PageUp` / `PageDown` one page each, and `Home`, `End` and `Enter` / `Space` on a DetailList row count once; they read neither the DOM nor the layout.
@@ -931,8 +998,11 @@ The keys are delegated to each view's **list**: the element that holds the view'
931
998
  An insert or a delete that commits between such a change and the next frame therefore keeps the position: `End`, `PageDown` and a held key are never reverted, focus stays on the key's destination, and a re-measured row shifts no report.
932
999
  The single path is a type: the keyboard navigation and the views receive only a read-only handle (`ListNavigationHandle`, the getters they read), so a view that calls `scrollToIndex`, `scrollBy`, `scrollTo`, `applyWheel` or `updateItemSize` fails the type check, however the call is spelled.
933
1000
  Only the anchor's scroller holds the full handle (the end-to-end test handle gets the read-only part and the scroller, so its one position change, `revealIndex`, records the anchor like a key; see [Test hooks](#test-hooks)), and `src/client/components/view-scroller.spec.ts` also scans the sources for such a call outside the scroller.
934
- - **Position changes `VirtualScroll` makes on its own** — the layout-shift compensation of a row re-measured above the first visible row, the reconciliation with a new row height after a render, the re-pinning of a pending alignment after a size change, and the second stage of a compensation the pane had clamped — are reported synchronously, once each change is complete, through its `onScrollAdjust` (since 3.9.0).
935
- Both views pass the anchor's `handleScrollAdjust` there, which records the anchor from the handle at once, so every position change `VirtualScroll` makes is in the anchor before a list change can commit.
1001
+ - **Position changes `VirtualScroll` makes on its own** — the layout-shift compensation of a row re-measured above the first visible row, the reconciliation with a new row height after a render, the re-pinning of a pending alignment after a size change, the second stage of a compensation the pane had clamped, and the pane's clamp of the position to a smaller maximum when the content shrinks under the view (`cause: "clamp"`, 3.13.0) — are reported synchronously, once each change is complete, through its `onScrollAdjust` (since 3.9.0).
1002
+ Both views pass the anchor's `handleScrollAdjust` there. For every cause but the clamp it records the anchor from the handle at once, so every position change `VirtualScroll` makes is in the anchor before a list change can commit.
1003
+ The clamp is the list change itself, seen from the scroll pane: rows before the window deleted near the end of the list shrink the content, and the pane moves the position to the new maximum inside the commit of that change, before the anchor's restoring layout effect.
1004
+ The anchor is kept as recorded and only the position it was recorded at moves by the clamp's `delta`, so the restore does not count the clamp as a scroll of the user and puts the anchored report back at its offset, 42 px above the end or exactly at it alike
1005
+ (re-recording there would read the clamped position against the previous list's rows, name another report and leave the view displaced by up to the last row's height).
936
1006
  - **Scrolls `VirtualScroll` makes for the user** (wheel, drag, the scroll bar, inertia) are recorded each time the view reports its visible range, once per frame. A list change in the frame before that report first advances the anchor by the distance scrolled since it was recorded and restores from there: it neither undoes the user's scroll nor shifts the content by a row.
937
1007
  Over rows of one height (the List) the new anchor follows from the distance by arithmetic; over measured rows (the DetailList) it walks the previous list's row heights from the anchor to the report now at the top, so the walk is bounded by the rows passed since the record (one frame's scroll), whatever the list's length or the position.
938
1008
  An insert or a delete before or inside the rendered window therefore leaves the first visible report — and the focus inside the rows — where it was. At the start of the list (scroll position 0) no anchor is kept, so reports that arrive at the top are shown;
@@ -998,17 +1068,22 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
998
1068
 
999
1069
  - **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;
1000
1070
  a DetailList row is its measured body plus 2G = 16.
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.
1071
+ 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 device-pixel lattice is built for: every multiple of 1/8 from 1 to 3. Where 2·dpr is whole (1, 1.5, 2, 2.5, 3) L is 2 px; at the twelve other ratios it is ⌊2·dpr⌋ device pixels: 2 at 1.125, 1.25 and 1.375 (16/9, 1.6 and 16/11 px), 3 at 1.625, 1.75 and 1.875 (24/13, 12/7 and 1.6 px), 4 at 2.125, 2.25 and 2.375 (32/17, 16/9 and 32/19 px) and 5 at 2.625, 2.75 and 2.875 (40/21, 20/11 and 40/23 px).
1072
+ A 2 px lift would end a fraction of a device pixel off at those twelve ratios (2.5, 3.5, 4.5 and 5.5 device pixels at the quarters, 5.25 at 2.625), which puts the hovered surface between device pixels and blends its 1 px border, the ring and the outline into the next row of device pixels.
1073
+ The card sets the value on itself as the custom property `--aqdr-hover-lift` (`2px`, overridden once under the media condition `resolution: <dpr>dppx` of each of the twelve ratios 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.
1004
1074
  - **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.
1005
1075
  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).
1006
1076
  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.
1007
1077
  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.
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).
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).
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.
1078
+ - **Row slots on the lattice**: every row slot of both views is built on the 4 px layout lattice u (one constant, `LAYOUT_LATTICE_PX` in `src/shared/layout-lattice.ts`, which the List slot and the DetailList estimate 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).
1079
+ The device-pixel lattice covers every multiple of 1/8 from 1 to 3, the default ratios of phones included (2.625 for many 412 px Android phones): at the odd multiples of 1/8, 4 CSS px ends half a device pixel off (10.5 at 2.625) while 8 CSS px is whole, so the boxes whose content decides their height round up to the block quantum q of the ratio — 4 px at the multiples of 1/4 and 8 px at the odd multiples of 1/8 (`LATTICE_BLOCK_QUANTUM_CLASS_NAME`: the custom property `--aqdr-lattice-block`, `4px` overridden to `8px` under the media condition `resolution: <dpr>dppx` of each of the eight odd eighths).
1080
+ The view roots carry the property (an attachment tile inherits it from its view), and so does each box sized by it (`LATTICE_BLOCK_SIZE_CLASS_NAME`, which therefore also works outside a view).
1081
+ 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).
1082
+ The List slot is P above, a multiple of 8 at the default 16 px root (160) and so whole at every ratio of the domain; at a root where P is an odd multiple of 4 (188 at a 20 px root) the List's rows step by half a device pixel at the odd eighths.
1083
+ 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 q (`LATTICE_BLOCK_SIZE_CLASS_NAME`: `height: calc-size(auto, round(up, size, var(--aqdr-lattice-block)))`), so rem line boxes at a root other than 16 px (a 25 px line at a 20 px root) and a body of an odd multiple of 4 px at an odd eighth leave the next row on whole device pixels; at a 16 px root and a quarter ratio the content is already on the lattice.
1084
+ Engines without `calc-size()` drop the declaration and keep the content height (see the browser floor table).
1085
+ Measured in Chromium 148 at 412 × 915 and 2.625: with bodies rounded to 4 px, a DetailList row of 980 px ended at 6,478.5 device pixels and the surface edge of the next row blended into two rows of device pixels; rounded to q, the revealed row's surface top sits at device pixel 273 in every reveal.
1086
+ A row that has not been measured yet is laid out at an estimate of 352 px (`UNMEASURED_ROW_HEIGHT_ESTIMATE_PX` = 88 u = 44 × 8), also on the lattice at every ratio of the domain, so the unmeasured rows above the window move the rows below by whole device pixels. An image attachment tile is padded to q as well (**Tile** in [Attachment display](#attachment-display)), so the rows below an image grid stay on whole device pixels.
1012
1087
  - **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
1088
  The content of a full card is 6.75 rem: the pills' line 1.5 rem + 0.5 + three one-line preview paragraphs of 1.25 rem, 0.5 rem apart (24 + 8 + 3 × 20 + 2 × 8 = 108 px at a 16 px root), inside a 1 + 15 inset at the top and the bottom (the inset counts the border, below), so 1 + 15 + 6.75R + 15 + 1 ≤ P − 2G with at least 4 px to spare at every root font size (140 ≤ 144 at 16 px).
1014
1089
  - **Forced colours**: the ring (a box shadow) disappears, so the row frame draws the selection itself: a 2 px solid `Highlight` outline at offset −8 (−G), which puts the line in the ring's band, 0–2 px outside the surface. The frame's corner radius is the surface's radius plus G (`calc(var(--radius-2xl) + 8px)`, 24 at a 16 px root), so the line is concentric with the surface at every root font size.
@@ -1043,7 +1118,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
1043
1118
  The switch track is slate-500 when off and blue-600 when on (dark: slate-400 / blue-400) under a white thumb (dark: slate-950). The selected tab is a white surface (dark: slate-950) with slate-900 text (dark: slate-100) and a straight 2 px blue-600 bar (dark: blue-400) along the straight part of its bottom edge, the bar being the cue that does not depend on colour. Unselected labels are slate-600 (dark: slate-300).
1044
1119
  The bar is an `::after` box at the tab's bottom edge, inset on each side by the tab's own corner radius (`--radius-lg`), so it never runs into the rounded corners: it is 2 px thick along its whole length at every device pixel ratio (a bottom border on a rounded box thins and rises along both corners), and it stays on the straight part for any host `--radius`. It is out of the flow and takes no space, so selecting a tab moves nothing and every label sits in the centre of its 24 px tab.
1045
1120
  In forced colours the thumb is `ButtonText`, the track has a `ButtonText` border and the on track a `Highlight` fill, and the selected bar is `Highlight`, painted as declared (`forced-color-adjust: none` on the bar), so the state survives the colour override.
1046
- The package's error messages — the load-error screen's message and its badge in the host's header, and the side pane's report-load error — take their colour from one token (`ERROR_TEXT_CLASS_NAME`: red-700, dark red-400); the badge sits on the host's header, whose colour is the host's, so it has no pair below.
1121
+ The package's error messages — the status panel's load-error message and its badge in the host's header, and the side pane's report-load error — take their colour from one token (`ERROR_TEXT_CLASS_NAME`: red-700, dark red-400); the badge sits on the host's header, whose colour is the host's, so it has no pair below.
1047
1122
  `src/client/ui/ui-state-contrast.spec.ts` computes every pair below from the compiled CSS and the palette (the toolbar surface is slate-100 at 60 % over the slate-50 page, dark slate-900 at 60 % over slate-950; the card surface is white, dark slate-900 at 70 % over the page; the overlay panel is white, dark slate-900):
1048
1123
 
1049
1124
  | Pair | Light | Dark | Minimum |
@@ -1066,35 +1141,39 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
1066
1141
  The card-like surfaces — the List card and its skeleton, the desktop side pane, the DetailList card and its skeleton — share one token (`CARD_SURFACE_CLASS_NAME`): a 16 px corner, a 1 px slate-200 / dark slate-700 border, white / dark slate-900 at 70 %, a 16 px inset that counts the border (1 px border + 15 px padding, so the content edge is 16 px from the visible edge and its corner concentric with the surface's) and `shadow-sm`.
1067
1142
  Two more surfaces have one token each, and no other place writes their classes: the placeholder panel (`PLACEHOLDER_PANEL_CLASS_NAME`: the side pane's selection prompt and its loading and empty tabs; a dashed 1 px border, the 12 px corner of an inner panel and the 12 px inset of every such panel (1 + 11), slate-50 / dark slate-900 at 40 %, slate-600 / dark slate-300 text) and the reading panel (`READING_PANEL_CLASS_NAME`: a report's content in the side pane and in the DetailList card;
1068
1143
  a 12 px corner and a concentric 12 px inset, slate-50 / dark slate-800 at 60 %, slate-700 / dark slate-200 text on 24 px lines). The scroll bar's business-day bubble is the bordered pill (`BORDERED_PILL_CLASS_NAME`: 24 px tall, its text 12 px in = 1 px border + 11 px padding, the radius of its round ends). `src/client/ui/border-style.spec.ts` checks these rules, a source scan included.
1069
- - **Motion**: everything that moves animates only transform or opacity under `motion-safe` — the hover lift, the thumbnail fade, the loading pulse (on a thumbnail frame's skin, only while the frame may show its load activity, see [Attachment display](#attachment-display)), the switch's thumb, the entrance of the comment delete confirmation and of the error banner, the mobile overlay's slide and its backdrop.
1144
+ - **Motion**: everything that moves animates only transform or opacity under `motion-safe` — the hover lift, the thumbnail fade, the loading pulse (on a thumbnail frame's skin, only while the frame may show its load activity, see [Attachment display](#attachment-display)), the switch's thumb, the entrance of the comment delete confirmation and of the error notice, the mobile overlay's slide and its backdrop.
1070
1145
  The one exception is the mobile overlay layer: its right edge follows the host's pinned-chrome offset (`--aqdr-overlay-right`) over 0.5 s, also under `motion-safe`, in step with the host's own transitions.
1071
1146
  State surfaces never animate their opacity: a row's surface (`data-daily-report-row-surface`, which paints the selection ring and the focus outline) and its ancestors up to the row frame stay opaque and still: the List's loading skeleton pulses an element inside its surface (the DetailList's skeleton does not pulse), so a selected or focused loading row keeps both indicators at 3:1 or more at the pulse's trough.
1072
1147
  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"`).
1073
1148
  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.
1074
1149
  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`).
1150
+ The header's total count eases 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` it shows the new value in the same render and asks for no animation frame (`useAnimatedNumber`, which reads the preference with `useReducedMotion`).
1151
+ The ids stream's progress badge shows the published counts as they are, with no easing in either case: publishes arrive at most every 50 ms, so a 250 ms ease would never settle and would only change the text on every frame.
1152
+ Both counters write their text into a contained box (`CounterText` in `src/client/ui/counter-text.tsx`): an invisible sizer that holds the widest text at the current number of digits reserves the box's width (with tabular digits every number of that many digits fits it, and the sizer changes only when the number of digits does),
1153
+ and the shown text sits over the sizer in a box with `contain: size layout style`, a relayout boundary (`COUNTER_BOX_CLASS_NAME`, `COUNTER_SIZER_CLASS_NAME`, `COUNTER_TEXT_CLASS_NAME`). A change of the text lays out that box alone, never the document, and neither the badge's width nor the header moves; only the shown text is read out.
1076
1154
  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`
1077
1155
  (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.
1078
1156
  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.
1157
+ and so that only the files of an allowlist with a written reason ask for an animation frame: the header total's 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.
1080
1158
  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.
1081
1159
  - **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.
1082
1160
  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**).
1083
1161
  Two lengths follow the container instead, by design: the attachment grid's track width t = ⌊(W − 16 (n − 1)) / n⌋, a whole pixel but not a multiple of 4 (horizontal), and the tile frame's height derived from it, h(t) = round(2t / 3) (see [Attachment display](#attachment-display)).
1084
1162
  The frame height is not snapped to 4 px: snapping moves the frame's shape more than 0.01 away from 3 : 2 (196 → 132 gives |t / h − 3 / 2| = 0.0152, 171 → 116 gives 0.026) and letterboxes a 3 : 2 image, which fills an unsnapped frame exactly.
1085
- The tile around the frame is on the lattice anyway: its last line carries the pad δ(t) = ⌈h / 4⌉ · 4 − h (0–3 px) as a bottom margin, so every tile is ⌈h / 4⌉ · 4 + 48 px tall, every tile row top and every grid's height are multiples of 4 px, and what follows a grid stays on the lattice (**Tile** in [Attachment display](#attachment-display)).
1086
- A bordered box counts its border in its inset, so that its content edge sits on the grid measured from the visible edge: border + padding is on the ladder — 16 for the card surfaces, the error panel, the sides of the error banner and the mobile overlay's close button (1 + 15), 8 for the form fields, the comment input and the top and bottom of the error banner (1 + 7), 4 for the bordered tab bar, the development box and the top and bottom of the text input (1 + 3) — or equals a 12 px corner.
1163
+ The tile around the frame is on the lattice anyway: its last line carries the pad δ(t) = ⌈h / q⌉ · q − h (0 to q − 1 px, q the block quantum of **Row slots on the lattice**) as a bottom margin, so every tile is ⌈h / q⌉ · q + 48 px tall, every tile row top and every grid's height are multiples of q, and what follows a grid stays on whole device pixels (**Tile** in [Attachment display](#attachment-display)).
1164
+ A bordered box counts its border in its inset, so that its content edge sits on the grid measured from the visible edge: border + padding is on the ladder — 16 for the card surfaces, the error panel, the sides of the error notice and the mobile overlay's close button (1 + 15), 8 for the form fields, the comment input and the top and bottom of the error notice (1 + 7), 4 for the bordered tab bar, the development box and the top and bottom of the text input (1 + 3) — or equals a 12 px corner.
1087
1165
  **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).
1088
1166
  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.
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.
1167
+ - **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 status screen starts there too), less than 4 px left of the exact centre.
1090
1168
  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.
1169
+ The inline axis has no 8 px quantum: at the odd multiples of 1/8 an inline edge at an odd multiple of 4 px from the origin starts half a device pixel off (a DetailList surface's left edge, 12 px in, at 31.5 device pixels at 2.625).
1091
1170
  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.
1092
1171
  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.
1093
1172
  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.
1094
1173
  A host that wants the same whole-pixel strokes on the top and bottom edges at the quarter ratios 1.25, 1.5 and 1.75 puts the edges on the lattice too — for example with a header and a footer whose heights are their content rounded up to the lattice unit `DAILY_REPORT_LAYOUT_LATTICE_PX` (`height: calc-size(auto, round(up, size, 4px))`), since a bar sized by its font metrics alone ends on a fraction (such as 30.4375 px).
1095
1174
  The top edge then lies on the lattice, and the bottom edge too when the window height is a multiple of the unit; the bottom-aligned surface then sits exactly G from the view's end.
1096
1175
  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)).
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.
1176
+ - **Side pane** (List, desktop layout): the panel group fills the view's body (which 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.
1098
1177
  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
1178
  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
1179
  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.
@@ -1136,9 +1215,11 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
1136
1215
  Every native scroll container that holds a grid reserves its scroll-bar gutter (the side pane's scroll box; the views' rows sit beside `VirtualScroll`'s own scroll bar of fixed width), so a grid's track width never depends on whether its container overflows.
1137
1216
  Every length of the grid — the breakpoints, the cap, the gap and the gaps inside the width expressions — is written in px, because the bounds are CSS-pixel quantities: in rem they would grow with the root font size (a 20 px root would make the tracks 300 px wide, upscaled 1.25× at a device pixel ratio of 2).
1138
1217
  - **Tile**: one link to the original wraps the thumbnail frame and the file name; below it one line holds the size and the download link. The frame is decorative (`aria-hidden="true"`, no link of its own), so the tile link is named by the file name and a tile has two Tab stops (the preview, then the download). The download link has a 24 px hit area, and its accessible name is the download label followed by the file name.
1139
- The tile root (the grid item) is the tile's one inline-size container, exactly as wide as its track, so `100cqi` inside the tile is t. A tile is the frame (h, below), the 8 px gap, the 20 px name, the 4 px gap and the 16 px last line, h + 48 px in all, and h need not be a multiple of 4.
1140
- The last line therefore carries a bottom margin, the lattice pad δ(t) = ⌈h / 4⌉ · 4 − h (`ATTACHMENT_TILE_LATTICE_PAD_STYLE`: `margin-block-end: calc(round(up, h, 4px) - h)`, where h is the frame height's own expression, resolved against the same container), which puts the remainder of 0–3 px under the last line and keeps the gaps between the frame, the name and the last line.
1141
- Every tile is then ⌈h / 4⌉ · 4 + 48 px tall, so every tile row top and the grid's height stay on the 4 px lattice, and so does what follows the grid (the DetailList rows below an image grid start on device pixels); for the tracks above, t = 196 gives h = 131 and δ = 1, 171 gives 114 and 2, 231 gives 154 and 2, and 234 gives 156 and 0. On Chrome 121–124, which lack `round()`, the margin declaration is dropped with the frame height, and a tile stays its frame's height + 48 px tall.
1218
+ The tile root (the grid item) is the tile's one inline-size container, exactly as wide as its track, so `100cqi` inside the tile is t. A tile is the frame (h, below), the 8 px gap, the 20 px name, the 4 px gap and the 16 px last line, h + 48 px in all (48 and the grid gap of 16 are multiples of 8), and h need not be a multiple of 4.
1219
+ The last line therefore carries a bottom margin, the lattice pad δ(t) = ⌈h / q⌉ · q − h (`ATTACHMENT_TILE_LATTICE_PAD_STYLE`: `margin-block-end: calc(round(up, h, var(--aqdr-lattice-block)) - h)`, where h is the frame height's own expression, resolved against the same container, and q the block quantum of the device-pixel lattice: 4 px at the multiples of 1/4, 8 px at the odd multiples of 1/8; **Row slots on the lattice**), which puts the remainder of 0 to q − 1 px under the last line and keeps the gaps between the frame, the name and the last line.
1220
+ Every tile is then ⌈h / q⌉ · q + 48 px tall, a whole number of device pixels at every ratio of the domain, so every tile row top and the grid's height stay on whole device pixels, and so does what follows the grid (the DetailList rows below an image grid start on device pixels); for the tracks above, t = 196 gives h = 131 and δ = 1 at q = 4 (5 at q = 8), 171 gives 114 and 2 (6), 231 gives 154 and 2 (6), and 234 gives 156 and 0 (4).
1221
+ The quantum is the view root's inherited custom property, so a tile renders inside a view (the List's side pane and the DetailList card do); measured at 412 × 915 and 2.625, the mobile overlay's tile rows step by 192 CSS px = 504 device pixels (a 4 px pad stepped by 188 = 493.5).
1222
+ A `var()` declaration is resolved at computed-value time, so where the property is not defined, and on Chrome 121–124, which lack `round()`, the margin is its initial 0 (the frame height is dropped as well there), and a tile stays its frame's height + 48 px tall.
1142
1223
  - **File name**: a long name is shortened in its stem and keeps its extension visible, on one line. In a tile grid the name is fitted exactly to the track: the longest start of the stem that still fits, an ellipsis that touches the extension, then the extension, drawn as one run clipped to the track (so the ellipsis never floats a glyph's width away from the extension, and a sub-pixel misfit is clipped instead of adding a second ellipsis).
1143
1224
  The extension is kept whole up to half the track; a longer one keeps the start that fits in half the track plus an ellipsis. The names are cut between code points of the NFC-normalized name, not between graphemes, so a combining sequence or a joined emoji at the cut can be split (`Intl.Segmenter` is above the browser floor).
1144
1225
  One `ResizeObserver` per document watches every grid's size container in it (created from the document's own window, disconnected when its last grid stops) and supplies the track width, computed from the container's content width W with the track formula above, not by measuring tiles. It reads the container, not the grid: the grid's own width leaves the remainder out and does not tell its column count (a 3-column grid at W = 496 is 494 px wide, as wide as a 2-column grid at W = 494).
@@ -1221,7 +1302,9 @@ DOM hooks are `data-*` attributes, test ids and ARIA; class names are styling an
1221
1302
  | `[data-thumbnail-active]` | Thumbnail frame that may show its load activity (visible while observed, or a granted load); a `pending` frame's skin pulses only with it |
1222
1303
  | `[data-daily-report-root-font-size-probe]` | Hidden 1 rem probe inside the List's root whose size change re-reads the root font size (`aria-hidden`, fixed, out of the flow) |
1223
1304
  | `[data-daily-report-attachment-preview]` | Tile link that wraps a thumbnail frame (the skin's hover and press rim read it) |
1224
- | `data-testid` | `daily-report-root`, `daily-report-list`, `daily-report-detail-list`, `daily-report-side-pane`, and the attachment ids above |
1305
+ | `[data-daily-report-notice-slot]` | The place the error notice renders in: the provider's own slot after the page, or the mobile overlay's slot inside its dialog |
1306
+ | `[data-daily-report-stream-status]` | The header's ids-stream badge container (persistent; empty while there is nothing to show) |
1307
+ | `data-testid` | `daily-report-root`, `daily-report-list`, `daily-report-detail-list`, `daily-report-side-pane`, `daily-report-error-banner` (the error notice), `daily-report-status-panel` (the status screen's panel, `data-phase` `loading` / `failed`), `daily-report-load-error-retry`, `daily-report-stream-announcer` (the page's status region), `daily-report-ids-stream-status` and `daily-report-ids-stream-retry` (the badge's content and its retry button), and the attachment ids above |
1225
1308
 
1226
1309
  Only the package writes `data-daily-report-keyboard-focus` and `data-daily-report-scrolling`. Hosts may select on them — in tests, or in CSS to adapt their own content inside a view — but never set them. When the pane content matters, wait for `data-displayed-report-id`, not `data-report-id`: during a key's render the frame already names the new selection while the content still shows the previous report.
1227
1310
 
@@ -1248,7 +1331,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1248
1331
  - **One surface for all wording.** The catalog covers the 11 engine chrome keys of
1249
1332
  `@aiquants/virtualscroll` plus 85 own keys: field headings, the page title (`title`, passed to
1250
1333
  `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
1251
- screen readers, tabs, buttons, placeholders, `confirm()` / `alert()` texts, stream badges, the load-error screen
1334
+ screen readers, tabs, buttons, placeholders, `confirm()` / `alert()` texts, stream badges, the status screen and the page's status region
1252
1335
  and the mutation-failure message. The engine part of each catalog is `VIRTUAL_SCROLL_LABEL_CATALOGS[locale]`, reused by
1253
1336
  value, so the scroll arrows, the names of the scroll bar and its thumb, and the "No items" text always speak the same language as the rest.
1254
1337
  - **Forwarded to every embedded `VirtualScroll`.** Both the List and the DetailList pass `locale`
@@ -1360,10 +1443,10 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
1360
1443
  | `autoRead` | auto-read switch label | Auto-read | 自動既読 |
1361
1444
  | `autoReadTitle` | auto-read switch tooltip | Mark reports as read automatically while viewing them | 日報閲覧時の自動既読処理切替 |
1362
1445
  | `loadErrorBadge` | header annotation on load failure | Error | エラー |
1363
- | `loadError` | load-failure screen | Failed to load the daily reports. | 日報データの読み込みに失敗しました。 |
1364
- | `reload` | load-failure screen button | Reload | 再読み込み |
1365
- | `loadingIds` | loading screen | Loading report IDs... | 日報 ID を読み込み中です... |
1366
- | `streamFailed` | stream badge (failed) | Failed to load | 読み込みに失敗しました |
1446
+ | `loadError` | status panel on load failure; the page's status region announces it as an alert | Failed to load the daily reports. | 日報データの読み込みに失敗しました。 |
1447
+ | `reload` | status panel's retry button on load failure | Reload | 再読み込み |
1448
+ | `loadingIds` | status panel while loading; the page's status region announces it | Loading report IDs... | 日報 ID を読み込み中です... |
1449
+ | `streamFailed` | stream badge (failed); the page's status region announces it as an alert once reports have arrived | Failed to load | 読み込みに失敗しました |
1367
1450
  | `streamRetry` | stream badge retry button | Retry | 再試行 |
1368
1451
  | `streamRevalidating` | stream badge (revalidating) | Refreshing | 最新化中 |
1369
1452
  | `selectPrompt` | side pane without a selection | Select a report from the list on the left. | 左側の一覧から日報を選択してください。 |
@@ -1380,7 +1463,7 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
1380
1463
  | `deleteReport` | report delete button | Delete | 削除する |
1381
1464
  | `edit` | edit button of the viewer's own report (`aria-label` / tooltip) | Edit | 編集 |
1382
1465
  | `resizeHandle` | list / detail resize handle `aria-label` | Resize the border between the report list and the detail pane | 日報一覧と詳細ペインの境界のサイズ変更ハンドル |
1383
- | `close` | mobile overlay close button, error banner close `aria-label` | Close | 閉じる |
1466
+ | `close` | mobile overlay close button, the error notice's close `aria-label` | Close | 閉じる |
1384
1467
  | `formTitle` | edit form | Title | タイトル |
1385
1468
  | `formTitlePlaceholder` | edit form | Enter the report title | 日報のタイトルを入力 |
1386
1469
  | `formContent` | edit form | Content | 内容 |
@@ -1398,11 +1481,11 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
1398
1481
  | `debounce` | header annotation | `(300)` → Debounce: 300 ms | `(300)` → デバウンス: 300 ms |
1399
1482
  | `streamProgressCount` | stream badge before the total is known | `(1234)` → Loading 1,234 | `(1234)` → 読み込み中 1,234 件 |
1400
1483
  | `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%) |
1401
- | `streamRetrying` | stream badge (reconnecting) | `(1234)` → Reconnecting... (1,234 received) | `(1234)` → 再接続中... (1,234 件受信済) |
1484
+ | `streamRetrying` | stream badge (reconnecting); the page's status region announces it | `(1234)` → Reconnecting... (1,234 received) | `(1234)` → 再接続中... (1,234 件受信済) |
1402
1485
  | `reportLoadFailed` | detail load failure (side pane, DetailList card) | `(12345)` → Failed to load report 12,345. | `(12345)` → 日報 12,345 の読み込みに失敗しました。 |
1403
1486
  | `reportSummaryLoadFailed` | list card load failure | `(12345)` → Failed to load the summary of report 12,345. | `(12345)` → 日報 12,345 の概要読み込みに失敗しました。 |
1404
1487
  | `interviewerWithAffiliation` | interviewer chip | `("Sato", "Acme")` → Sato (Acme) | `("佐藤", "山田商事")` → 佐藤 (山田商事) |
1405
- | `operationFailed` | error banner / `DailyReportErrorInfo.message` | `("update")` → Failed to save the report. Please try again later. | `("update")` → 日報の保存に失敗しました。時間をおいて再度お試しください。 |
1488
+ | `operationFailed` | the error notice / `DailyReportErrorInfo.message` | `("update")` → Failed to save the report. Please try again later. | `("update")` → 日報の保存に失敗しました。時間をおいて再度お試しください。 |
1406
1489
  | `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 })` → 未読、スター付き |
1407
1490
  | `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
1491
  | `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 件目 |
@@ -1480,9 +1563,17 @@ Every breaking change and its migration is listed per version in `CHANGELOG.md`,
1480
1563
 
1481
1564
  1. Mutation services publish to Redis Stream (`daily-report:sse-stream`) in sequence: `invalidate -> epoch increment -> SSE publish`.
1482
1565
  2. `DailyReportSseReader` uses a single blocking `xRead` loop to fan-out events to all SSE connections (eliminating per-connection Redis TCP connections). `createDailyReportServer` creates it; `createDailyReportHandlers` takes the reader as `sseReader` typed by the port `DailyReportSseReaderPort` — `ready()` and `subscribe(onEntry, onError)`, with the contract under **`connected` frame** below — which is the type a host's own reader implements: a plain object with the two methods is accepted, while the class type, which has private fields, would refuse one.
1566
+ The reader reads each live entry once per process and hands every subscriber the same frozen value (`StreamEntry`: `{ id, message }`): `message` is the entry's `data` field parsed as JSON and validated by `dailyReportSseMessageSchema` — the validated message (`parsed`), the JSON value as published (`published`, with the server-only fields) and its text (`text`) — or `null` when the field is absent, is not JSON or fails the schema (the last two logged once at `error`; such an entry is written to no connection — fail-closed — while the reader's own read position moves past it).
1567
+ Each connection only filters that value (recipient, source-type visibility, comment redaction) and never changes it, and the frame texts of the variants that differ from the published text — without `recipientRawUserId`, and with the embedded report's comments emptied — are built at most once per message and shared by every connection that writes them, so a live entry costs one parse in proportion to its size plus a filter per connection, not one parse per connection.
1568
+ A host's own reader keeps the same contract: it reads an entry once — `readDailyReportSseStreamEntry(raw, logger)` (server entry) reads it as the package's reader does — and hands the same value to every subscriber.
1483
1569
  3. `sse.loader` is built on `@aiquants/sse/server` (`createSseResponse`, `terminalStreamResponse`, `startSseHeartbeat`, `readLastEventId`). Its responses carry only `SSE_RESPONSE_HEADERS` plus the forwarded `Set-Cookie` (no `Connection` or other hop-by-hop header, which HTTP/2 forbids). `recipientRawUserId` is filtered server-side and removed before transmission to prevent internal ID leaks. Catch-up and live entries pass through the same filters (recipient, source-type visibility, comment redaction).
1484
1570
  4. On the client, `useDailyReportSseConnection` runs on `useReopeningEventSource` (`@aiquants/sse/react`): full-jitter backoff from 2 s to 30 s, the failure count reset only after 60 s of healthy open (the 45 s stale window plus one heartbeat period), one native browser reconnect (which carries the cursor in the `Last-Event-ID` header), and a stale watch of 45 s (three heartbeat periods).
1485
1571
  The action context correlates optimistic updates with SSE echoes using `clientTempId`, and exposes the connection status as `sseStatus` (`DailyReportSseConnectionStatus`: the reopening status, or `{ kind: "resyncing" }` while a `resync-required` waits for a fresh ids anchor).
1572
+ The action context ignores the SSE echo of its own action for 30 s after sending it, holds a message about a report that is not cached yet for up to 30 s (when the report's cache is written within that time the held messages are handled once, by the last committed render's handler; otherwise they are dropped),
1573
+ and keeps a created report locked against older background answers for 1 s; each is a deadline on the monotonic clock read when it matters, with no timer, so nothing runs after the provider unmounts (`src/client/deferred-callback-scope.spec.ts` lists every timer, frame, listener and observer the client arms, each with its reason).
1574
+ The echo ids and the held messages each live on an expiring ledger (`createExpiringLedger` in `src/client/contexts/expiring-ledger.ts`): insertion order is deadline order, and every add and every read removes the expired entries from its head, so a ledger is bounded by construction — a held message expires at the next add or read of any report, not only of its own, and ends its cache subscription then.
1575
+ A comment that arrives by SSE is the viewer's own exactly when its author id equals the viewer's (`userId`, from the deterministic `encodeUserId` port), whichever tab or device posted it; a comment of another user with the same text as one being posted is never taken for it, and a posted comment's temporary id becomes its real id only through its own action's answer.
1576
+ Each action holds its own lock on the report it changes (`acquireMutationLock` returns that lock's release, which frees only that lock and only once), so an overlapping action, an SSE change (`markReportMutated`, which records the change time without locking) and the 1 s lock after a creation (which a later hold only extends) never end one another's locks.
1486
1577
 
1487
1578
  ### SSE wire contract
1488
1579
 
@@ -1497,6 +1588,7 @@ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000
1497
1588
  | Unknown endpoint | 404 | treated as retryable; backoff up to 30 s |
1498
1589
  | A `HEAD` request | the status and headers a `GET` would get (one of the rows here), with no body work: no subscription, no heartbeat, no visibility refresh timer (**Streaming routes** in [Server wiring](#server-wiring-di)) | never sent by the client |
1499
1590
  | Unexpected error before the stream starts (for example the internal-user lookup fails) | 500 with the body `Internal Server Error` (no error detail), `Set-Cookie` forwarded | treated as retryable; backoff up to 30 s |
1591
+ | The viewer already holds every stream slot of the worker (**Streams per viewer** in [Server wiring](#server-wiring-di): 32, SSE and ids streams together), after the resume cursor is read | 503 with the body `Too many streams` and `Retry-After: 5`, `Set-Cookie` forwarded; nothing is subscribed and the visibility is not resolved | treated as retryable; backoff up to 30 s |
1500
1592
  | Malformed cursor | 200, `retry: 86400000` → `event: resync-required` | rereads the ids (server cache bypassed) and reopens from the new anchor |
1501
1593
  | Cursor older than the oldest retained entry (the stream is trimmed with `MAXLEN ~ streamMaxLen`) | 200, `connected` → heartbeat → `retry: 86400000` → `event: resync-required` → end | same as above |
1502
1594
  | The start of a catch-up page is trimmed away before the page is read | 200, the entries delivered so far → `retry: 86400000` → `event: resync-required` → end | same as above |
@@ -1510,7 +1602,9 @@ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000
1510
1602
  When the tail cannot be read, `ready()` rejects and the failed read is not kept, so the next call reads again: the connection that waited for it ends (`producer-failed`, the row above) and the client reopens it, with its cursor when it has one, so the catch-up covers everything since; and a run that cannot fix its position fails like any other failing run (every subscriber's `onError`). A host with its own SSE handler awaits `ready()` the same way and closes the connection when it rejects.
1511
1603
  The same frame is used as an anchor-only frame for entries the viewer's filters drop (addressed to another user, or a source type the viewer cannot see), so the client's cursor keeps advancing without receiving their content.
1512
1604
  Without it, a tab whose visible traffic is quiet while other users' read / star updates flow would keep an old cursor, and its next reconnect would fall outside the retained window (`resync-required`, then an ids rescan that bypasses the server cache).
1513
- - **Catch-up**: entries after the cursor are read in pages of 1,000 (`xRange` is inclusive, so each page skips its start id) until the stream end; every entry is delivered. When a page does not start with its start id, the oldest retained entry is read again: if it is newer than the start id, the range after it may have been trimmed, so the connection ends with `resync-required` instead of silently skipping it (a cursor that is simply not an entry id inside the retained window continues).
1605
+ - **Reads within a byte budget**: Redis bounds an `XREAD` or an `XRANGE` only by a count of entries, so the count of both comes from one byte budget (`src/server/payload-limits.ts`): `SSE_READ_BUDGET_BYTES` (64 MiB) divided by the largest event, `SSE_EVENT_MAX_BYTES` (**Event size** below), rounded down and at least 1 — `SSE_READ_ENTRY_COUNT` = 10.
1606
+ The shared reader's live `XREAD` and every catch-up page (`DAILY_REPORT_SSE_CATCH_UP_BATCH`, exported) read that many entries, so one read brings at most 64 MiB into the process, also when every entry is as large as an event can be. A catch-up reads its pages per connection; a catch-up of 1,000 small events takes 113 round trips (one check of the oldest entry and 112 pages, each new page reading 9 entries after its start).
1607
+ - **Catch-up**: entries after the cursor are read in pages of `DAILY_REPORT_SSE_CATCH_UP_BATCH` (`xRange` is inclusive, so each page skips its start id) until the stream end; every entry is delivered. When a page does not start with its start id, the oldest retained entry is read again: if it is newer than the start id, the range after it may have been trimmed, so the connection ends with `resync-required` instead of silently skipping it (a cursor that is simply not an entry id inside the retained window continues).
1514
1608
  - **Ids on the wire only increase**: live entries that arrive during the catch-up are queued, not written. The catch-up stops in front of the first queued entry (the fan-out reader delivers every entry after it in order), then the queue is written in id order and later live entries are written as they arrive. Writing a live entry first would let a disconnect move the client's resume position past catch-up entries it never received, and an older `report-update` would overwrite a newer one on the client.
1515
1609
  - **Heartbeat**: `startSseHeartbeat` writes a jittered `retry:` (2,000–10,000 ms), an immediate named `event: heartbeat` (`data: {"serverTime":<ms>}`, no `id:`), then one every 15 s (`DAILY_REPORT_SSE_HEARTBEAT_MS`). Named events are ignored by `onmessage`, so old bundles are unaffected.
1516
1610
  - **Stream anchor (`streamAnchor`)**: the ids stream carries the newest SSE entry id on its first authoritative line
@@ -1523,6 +1617,7 @@ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000
1523
1617
  - **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
1618
  - **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
1619
  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.
1620
+ The persistent stream itself is bounded by a count of entries, not by bytes: at most `streamMaxLen` (10,000 by default, trimmed with `MAXLEN ~`) events of at most `SSE_EVENT_MAX_BYTES` each, about 59.2 GiB in the worst case, while a status update is under 200 bytes and a report event carries one report's detail. One read of it is bounded by the byte budget above; bounding the stream's own bytes needs thin events (the type, the ids and a version, with the detail read again through the ETag'd endpoints), which is a change of the client's protocol.
1526
1621
  - **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.
1527
1622
 
1528
1623
  ## API Surface (Summary)
@@ -1532,8 +1627,11 @@ Every entry exports by name (no `export *`), so a module-internal helper never b
1532
1627
  - **shared** (`@aiquants/daily-report`, isomorphic):
1533
1628
  - 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),
1534
1629
  `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`,
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)`.
1536
- - Types: `DailyReportItem` / `DailyReportDetail` / `DailyReportUser` / `DailyReportInterviewer` / `DailyReportLabelDef` / `DailyReportPostedComment` / `DailyReportExternalComment` / `DailyReportAttachmentSummary`, `DailyReportSseMessage` / `DailyReportSseStreamAnchor` / `DailyReportIdsStreamChunkLine`, `DailyReportAttachmentDeliveryRequest` / `DailyReportAttachmentInvalidQuery` / `DailyReportAttachmentThumbnailVariant` / `DailyReportAttachmentThumbnailBox`.
1630
+ `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)`,
1631
+ and the wire contract of the action and the JSON endpoints (**One wire contract** in [Server wiring](#server-wiring-di)), for a host that calls the routes itself: `DAILY_REPORT_ACTION_INTENTS` / `DAILY_REPORT_ACTION_FIELDS` (the intents and the form's field names),
1632
+ `encodeDailyReportActionCommand(command)` (a command as its `FormData`), `parseDailyReportActionResult(intent, json)` (the strict reading of a 200 answer: `{ result }` or `{ mismatch }`), `DAILY_REPORT_API_ENDPOINTS` / `DAILY_REPORT_API_QUERY_PARAMS` (the endpoint and GET query-parameter names) and `DAILY_REPORT_CLIENT_TEMP_ID_PATTERN` (the echo id's two canonical forms).
1633
+ - Types: `DailyReportItem` / `DailyReportDetail` / `DailyReportUser` / `DailyReportInterviewer` / `DailyReportLabelDef` / `DailyReportPostedComment` / `DailyReportExternalComment` / `DailyReportAttachmentSummary`, `DailyReportSseMessage` / `DailyReportSseStreamAnchor` / `DailyReportIdsStreamChunkLine`, `DailyReportAttachmentDeliveryRequest` / `DailyReportAttachmentInvalidQuery` / `DailyReportAttachmentThumbnailVariant` / `DailyReportAttachmentThumbnailBox`,
1634
+ and the action's `DailyReportActionIntent` / `DailyReportActionCommand` (one command per intent) / `DailyReportActionResult` (the 200 answer of every intent, discriminated by `intent`) / `DailyReportActionResultOf<Intent>` (one intent's answer) / `DailyReportActionFailure` (a refusal, `{ error }`).
1537
1635
  - **client** (`@aiquants/daily-report/client`, React):
1538
1636
  - Components: `DailyReportPage` / `DailyReportResolvedContent` / `DailyReportList` / `DailyReportDetailList` / `DailyReportAttachmentIndicator`, the providers `DailyReportConfigProvider` / `DailyReportActionProvider` / `DailyReportErrorProvider`.
1539
1637
  - 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` /
@@ -1545,8 +1643,10 @@ Every entry exports by name (no `export *`), so a module-internal helper never b
1545
1643
  - **server** (`@aiquants/daily-report/server`, Node.js):
1546
1644
  - Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor, snapshot }`; `readSseStreamAnchor`; `attachmentDelivery`, `null` without the `attachments` block; `titleMaxLength`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
1547
1645
  - 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`.
1548
- - SSE and caching: `DailyReportSseReader` / `DAILY_REPORT_SSE_VISIBILITY_REFRESH_MS` / `DAILY_REPORT_SSE_CATCH_UP_BATCH` / `isStreamIdLte` / `SqlResultCache` / `transformJsonArray` / `jsonResponseWithETag` / `generateETag`.
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`,
1646
+ - SSE and caching: `DailyReportSseReader` / `readDailyReportSseStreamEntry(raw, logger)` (reads one Redis stream entry, `{ id, message }` as the client returns it, once: `{ entry, json }`, the frozen `StreamEntry` the shared reader hands every subscriber and the entry's parsed JSON value; for a host whose own reader keeps the same contract) /
1647
+ `DAILY_REPORT_SSE_VISIBILITY_REFRESH_MS` / `DAILY_REPORT_SSE_CATCH_UP_BATCH` (the entries of one catch-up page, derived from the read byte budget) / `isStreamIdLte` / `SqlResultCache` / `transformJsonArray` / `jsonResponseWithETag(request, cookie, payload, status = 200)` (a pure builder of the JSON response with the shared security headers and the ETag's 304; it writes no log line) / `generateETag`.
1648
+ - Request values: `parseClientTempId` and its branded result type `DailyReportClientTempId` (the action's echo id in its two canonical forms, the only id the service's write methods take; **The echo id** in [Server wiring](#server-wiring-di)).
1649
+ - 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` (an entry as the reader hands it to every subscriber: its id and its message read once, or `null`) / `ExternalReportFields`,
1550
1650
  and for attachments `DailyReportAttachmentsConfig` / `DailyReportAttachmentThumbnailsConfig` (the `attachments` block and its `thumbnails`) / `DailyReportAttachmentDelivery` / `DailyReportReadAttachment` / `DailyReportAttachmentError` / `DailyReportAttachmentFailure` / `DailyReportAttachmentThumbnailRenderer` / `DailyReportAttachmentThumbnail` / `DailyReportAttachmentThumbnailSourceFormat` / `DailyReportAttachmentThumbnailOutputMediaType` / `SharpLike`.
1551
1651
 
1552
1652
  MIT